DEV Community

Luke
Luke

Posted on • Edited on

Building Smart Multi-Currency Pricing with IP Geolocation πŸŒπŸ’°

So here's something that's been bugging me for a while. You know when you're browsing a website from another country and you see prices in some foreign currency? And you're sitting there trying to do mental math, wondering if ΰΈΏ259 is actually expensive or not in your local money?

Yeah, that's exactly what happened to me last week. I was looking at this SaaS tool, saw the price in USD, and honestly... I just closed the tab. Too much work to figure out if it was worth it.

But then I realized - I'm probably doing the same thing to my own users. Oops.

So today we're fixing that. We'll build a smart multi-currency pricing system that automatically detects where users are and shows them prices in their local currency. It's actually not as complicated as I thought it would be, though there are definitely some gotchas.

Why This Actually Matters (More Than I Expected)

I did some digging after my little revelation, and apparently displaying prices in local currency can boost conversion rates by up to 30%. That's... significant. I mean, I knew it mattered, but 30%?

When users see prices in their familiar currency, it removes that mental friction. No more calculator apps, no more "is this expensive or cheap?" moments. Just instant understanding.

But here's the thing - and I learned this the hard way - implementing this properly isn't just about slapping a currency converter on your site. You need to think about accuracy, performance, user preferences, error handling... the list goes on.

Let me walk you through what I built, including the mistakes I made along the way.

What We're Working With

For this project, I ended up using:

  • IP Flare API for the geolocation stuff
  • React/Next.js for the frontend (though honestly, these concepts work with pretty much any framework)
  • TypeScript because I like my code to yell at me when I mess up
  • Some exchange rate API for the actual currency conversion

The cool thing about IP Flare - and why I picked it over the other options - is that it gives you not just location data, but also the local currency info. Saves you from maintaining some massive country-to-currency mapping file. Trust me, I started going down that rabbit hole and it's not fun.

Step 1: Getting the Geolocation Working

First things first, let's get the basic geolocation service running. I'm using the ipflare npm package because it has solid TypeScript support and, perhaps more importantly, actually handles errors gracefully.

npm install ipflare
Enter fullscreen mode Exit fullscreen mode

Here's what I ended up with for the basic service:

// services/geolocation.ts
import { IPFlare } from 'ipflare';

const geolocator = new IPFlare({
  apiKey: process.env.IPFLARE_API_KEY!,
});

export interface UserLocation {
  country: string;
  countryCode: string;
  currency: string;
  currencyName: string;
  city?: string;
  timezone?: string;
}

export async function getUserLocation(ip?: string): Promise<UserLocation | null> {
  try {
    const result = await geolocator.lookup(ip || 'auto');

    if (!result.ok) {
      console.error('Geolocation failed:', result.error.message);
      return null;
    }

    const data = result.data;

    return {
      country: data.country_name || 'Unknown',
      countryCode: data.country_code || 'US',
      currency: data.currency || 'USD',
      currencyName: data.currency_name || 'US Dollar',
      city: data.city,
      timezone: data.timezone,
    };
  } catch (error) {
    console.error('Geolocation error:', error);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

One thing I really like about the ipflare library is this result-based API approach. No messy try-catch blocks everywhere - the library handles the API errors internally and gives you a clean success/failure result. Makes the code much cleaner, I think.

Step 2: The Currency Detection Hook

Now this is where it gets interesting. I needed a React hook that could handle currency detection but also respect user preferences. Because let's be honest - sometimes the geolocation gets it wrong, or users just prefer a different currency.

// hooks/useCurrency.ts
import { useState, useEffect, useCallback } from 'react';
import { getUserLocation, type UserLocation } from '../services/geolocation';

export type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'CAD' | 'AUD';

const SUPPORTED_CURRENCIES: Record<string, SupportedCurrency> = {
  'USD': 'USD',
  'EUR': 'EUR', 
  'GBP': 'GBP',
  'CAD': 'CAD',
  'AUD': 'AUD',
};

const CURRENCY_SYMBOLS: Record<SupportedCurrency, string> = {
  'USD': '$',
  'EUR': '€',
  'GBP': 'Β£',
  'CAD': 'C$',
  'AUD': 'A$',
};

export function useCurrency() {
  const [currency, setCurrency] = useState<SupportedCurrency>('USD');
  const [isLoading, setIsLoading] = useState(true);
  const [userLocation, setUserLocation] = useState<UserLocation | null>(null);

  // Load saved currency preference first
  useEffect(() => {
    const saved = localStorage.getItem('preferred-currency');
    if (saved && saved in SUPPORTED_CURRENCIES) {
      setCurrency(saved as SupportedCurrency);
    }
  }, []);

  // Then try to detect location
  useEffect(() => {
    async function detectCurrency() {
      try {
        const location = await getUserLocation();
        setUserLocation(location);

        if (location && location.currency in SUPPORTED_CURRENCIES) {
          const detectedCurrency = SUPPORTED_CURRENCIES[location.currency];

          // Only auto-set if user hasn't manually chosen something
          const hasManualPreference = localStorage.getItem('preferred-currency');
          if (!hasManualPreference) {
            setCurrency(detectedCurrency);
          }
        }
      } catch (error) {
        console.error('Currency detection failed:', error);
        // Not much we can do here, just stick with USD
      } finally {
        setIsLoading(false);
      }
    }

    detectCurrency();
  }, []);

  const updateCurrency = useCallback((newCurrency: SupportedCurrency) => {
    setCurrency(newCurrency);
    localStorage.setItem('preferred-currency', newCurrency);
  }, []);

  return {
    currency,
    setCurrency: updateCurrency,
    isLoading,
    userLocation,
    currencySymbol: CURRENCY_SYMBOLS[currency],
    supportedCurrencies: SUPPORTED_CURRENCIES,
  };
}
Enter fullscreen mode Exit fullscreen mode

I spent way too much time thinking about the order of operations here. Should I check localStorage first or do the geolocation first? I ended up going with localStorage first because if someone has explicitly chosen a currency, that should probably take precedence.

The hook does a few smart things:

  • Respects what users have already chosen
  • Falls back to geolocation for new visitors
  • Only works with currencies I actually support (learned this lesson the hard way when someone from a country with an obscure currency visited my site)
  • Gives you loading states so you can show something reasonable while it's working

Step 3: Currency Conversion (The Tricky Part)

Alright, so now we know where the user is and what currency they probably want. But we still need to actually convert the prices. This is where things get a bit more complex.

// services/currency.ts
interface ExchangeRates {
  [key: string]: number;
}

class CurrencyService {
  private rates: ExchangeRates = {};
  private lastUpdate: number = 0;
  private readonly CACHE_DURATION = 1000 * 60 * 60; // 1 hour

  async getExchangeRates(): Promise<ExchangeRates> {
    const now = Date.now();

    // Return cached rates if they're still fresh
    if (this.rates && (now - this.lastUpdate) < this.CACHE_DURATION) {
      return this.rates;
    }

    try {
      const response = await fetch('https://api.exchangerate-api.com/v4/latest/USD');
      const data = await response.json();

      this.rates = data.rates;
      this.lastUpdate = now;

      return this.rates;
    } catch (error) {
      console.error('Failed to fetch exchange rates:', error);
      // Return cached rates or some reasonable defaults
      return this.rates || { USD: 1, EUR: 0.85, GBP: 0.73, CAD: 1.25, AUD: 1.35 };
    }
  }

  async convertPrice(
    amount: number, 
    fromCurrency: string = 'USD', 
    toCurrency: string
  ): Promise<number> {
    if (fromCurrency === toCurrency) return amount;

    const rates = await this.getExchangeRates();

    // Convert to USD first, then to target currency
    const usdAmount = fromCurrency === 'USD' ? amount : amount / rates[fromCurrency];
    const convertedAmount = toCurrency === 'USD' ? usdAmount : usdAmount * rates[toCurrency];

    return Math.round(convertedAmount * 100) / 100; // Round to 2 decimal places
  }

  formatPrice(amount: number, currency: string): string {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: currency,
    }).format(amount);
  }
}

export const currencyService = new CurrencyService();
Enter fullscreen mode Exit fullscreen mode

I'm using a free exchange rate API here, which is probably fine for most use cases. Though if you're doing this for a real business, you might want to consider a paid service for better reliability. The free ones sometimes have rate limits or go down at inconvenient times.

The caching is important - you don't want to hit the exchange rate API on every price conversion. Exchange rates don't change that frequently anyway, so an hour cache seems reasonable. Maybe even longer, depending on your needs.

Step 4: The Pricing Component

Now let's put it all together. This is where the magic happens - or where everything breaks if you're not careful.

// components/PricingCard.tsx
import React, { useState, useEffect } from 'react';
import { useCurrency } from '../hooks/useCurrency';
import { currencyService } from '../services/currency';

interface PricingPlan {
  name: string;
  basePrice: number; // Always in USD
  features: string[];
}

interface PricingCardProps {
  plan: PricingPlan;
}

export function PricingCard({ plan }: PricingCardProps) {
  const { currency, currencySymbol, isLoading: currencyLoading } = useCurrency();
  const [convertedPrice, setConvertedPrice] = useState<number>(plan.basePrice);
  const [isConverting, setIsConverting] = useState(false);

  useEffect(() => {
    async function convertPrice() {
      if (currency === 'USD') {
        setConvertedPrice(plan.basePrice);
        return;
      }

      setIsConverting(true);
      try {
        const converted = await currencyService.convertPrice(
          plan.basePrice, 
          'USD', 
          currency
        );
        setConvertedPrice(converted);
      } catch (error) {
        console.error('Price conversion failed:', error);
        // Just show the original price if conversion fails
        setConvertedPrice(plan.basePrice);
      } finally {
        setIsConverting(false);
      }
    }

    convertPrice();
  }, [plan.basePrice, currency]);

  const formattedPrice = currencyService.formatPrice(convertedPrice, currency);

  return (
    <div className="pricing-card">
      <h3>{plan.name}</h3>

      <div className="price">
        {currencyLoading || isConverting ? (
          <div className="price-skeleton">πŸ’° Loading...</div>
        ) : (
          <>
            <span className="amount">{formattedPrice}</span>
            <span className="period">/month</span>
          </>
        )}
      </div>

      <ul className="features">
        {plan.features.map((feature, index) => (
          <li key={index}>βœ… {feature}</li>
        ))}
      </ul>

      <button className="cta-button">
        Get Started πŸš€
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

I probably spent too much time on the loading states here, but I think it's worth it. There's nothing worse than seeing prices jump around as the conversion happens. Better to show a loading state and then smoothly transition to the final price.

One thing I learned: always have a fallback. If the currency conversion fails for any reason, just show the original USD price. It's better than showing nothing or crashing.

Step 5: Let Users Override the Detection

This is crucial. Sometimes the geolocation gets it wrong, or users just prefer to see prices in a different currency. Maybe they're traveling, or they're an expat, or they just like USD better. Whatever the reason, give them control.

// components/CurrencySelector.tsx
import React from 'react';
import { useCurrency } from '../hooks/useCurrency';

const CURRENCY_OPTIONS = [
  { code: 'USD', name: 'US Dollar', flag: 'πŸ‡ΊπŸ‡Έ' },
  { code: 'EUR', name: 'Euro', flag: 'πŸ‡ͺπŸ‡Ί' },
  { code: 'GBP', name: 'British Pound', flag: 'πŸ‡¬πŸ‡§' },
  { code: 'CAD', name: 'Canadian Dollar', flag: 'πŸ‡¨πŸ‡¦' },
  { code: 'AUD', name: 'Australian Dollar', flag: 'πŸ‡¦πŸ‡Ί' },
];

export function CurrencySelector() {
  const { currency, setCurrency, userLocation } = useCurrency();

  return (
    <div className="currency-selector">
      <label htmlFor="currency">πŸ’± Currency:</label>
      <select 
        id="currency"
        value={currency} 
        onChange={(e) => setCurrency(e.target.value as any)}
      >
        {CURRENCY_OPTIONS.map((option) => (
          <option key={option.code} value={option.code}>
            {option.flag} {option.code} - {option.name}
          </option>
        ))}
      </select>

      {userLocation && (
        <small className="detected-location">
          πŸ“ Detected: {userLocation.city}, {userLocation.country}
        </small>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

I like showing the detected location too. It gives users confidence that the system is working, and it's kind of cool to see "Detected: London, United Kingdom" or whatever.

Some Production Tips I Wish I'd Known Earlier

Server-Side Detection

If you're serious about this, you probably want to do the currency detection server-side for better performance and SEO. Here's a basic Next.js API route:

// pages/api/detect-currency.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { getUserLocation } from '../../services/geolocation';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  try {
    const forwarded = req.headers['x-forwarded-for'];
    const ip = typeof forwarded === 'string' 
      ? forwarded.split(',')[0] 
      : req.connection.remoteAddress;

    const location = await getUserLocation(ip);

    if (location) {
      // Set some cookies for subsequent requests
      res.setHeader('Set-Cookie', [
        `detected-currency=${location.currency}; Path=/; Max-Age=86400; SameSite=Lax`,
        `detected-country=${location.countryCode}; Path=/; Max-Age=86400; SameSite=Lax`
      ]);
    }

    res.status(200).json({ location });
  } catch (error) {
    console.error('Currency detection error:', error);
    res.status(500).json({ error: 'Failed to detect currency' });
  }
}
Enter fullscreen mode Exit fullscreen mode

Testing (Don't Skip This Part)

I made the mistake of not testing this thoroughly at first. Here's a basic test setup that would have saved me some headaches:

// __tests__/currency.test.ts
import { getUserLocation } from '../services/geolocation';

describe('Currency Detection', () => {
  test('detects US currency for US IP', async () => {
    const location = await getUserLocation('8.8.8.8'); // Google DNS
    expect(location?.currency).toBe('USD');
    expect(location?.countryCode).toBe('US');
  });

  test('detects UK currency for UK IP', async () => {
    const location = await getUserLocation('84.17.50.173'); // Some UK IP
    expect(location?.currency).toBe('GBP');
    expect(location?.countryCode).toBe('GB');
  });

  test('handles invalid IPs gracefully', async () => {
    const location = await getUserLocation('not-an-ip');
    expect(location).toBeNull();
  });
});
Enter fullscreen mode Exit fullscreen mode

Things to Keep in Mind

A few things I learned along the way:

Rate limiting - Don't let people abuse your API endpoints. Implement some basic rate limiting.

Privacy - Be transparent about the location detection in your privacy policy. Some people care about this stuff.

Fallbacks - Always have a fallback currency. USD is usually a safe bet.

GDPR - If you're dealing with EU users, you might need to think about data processing implications. I'm not a lawyer, but it's worth considering.

Putting It All Together

Here's what the final pricing page looks like:

// pages/pricing.tsx
import React from 'react';
import { PricingCard } from '../components/PricingCard';
import { CurrencySelector } from '../components/CurrencySelector';

const PRICING_PLANS = [
  {
    name: 'Starter',
    basePrice: 9.99,
    features: ['10,000 API calls', 'Basic support', 'Dashboard access']
  },
  {
    name: 'Pro',
    basePrice: 29.99,
    features: ['100,000 API calls', 'Priority support', 'Advanced analytics']
  },
  {
    name: 'Enterprise',
    basePrice: 99.99,
    features: ['Unlimited API calls', '24/7 support', 'Custom integrations']
  }
];

export default function PricingPage() {
  return (
    <div className="pricing-page">
      <header>
        <h1>πŸ’° Choose Your Plan</h1>
        <CurrencySelector />
      </header>

      <div className="pricing-grid">
        {PRICING_PLANS.map((plan) => (
          <PricingCard key={plan.name} plan={plan} />
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Was It Worth It?

So after implementing all this, did it actually work? I think so. My conversion rates for international visitors definitely improved, though I can't say it was exactly 30%. Maybe 15-20%? Hard to measure precisely because there are so many variables.

But the user feedback has been positive. People seem to appreciate seeing prices in their local currency, and I've gotten fewer "how much is this in euros?" type emails.

The IP Flare API made the whole thing much easier than I expected. Having the currency data built into the geolocation response was a huge time-saver. Plus their free tier gives you 1,000 requests per month, which is perfect for testing and small projects.

If you're thinking about implementing something like this, I'd say go for it. Just start simple, test thoroughly, and always have fallbacks. Your international users will thank you.


Have you implemented multi-currency pricing on your site? How did it go? I'm curious to hear about other approaches - drop a comment below! πŸ‘‡

If this was helpful, give it a ❀️ and maybe follow for more web dev stuff!

Top comments (0)