DEV Community

Cover image for Building an Automatic Currency Switcher in Next.js
Luke
Luke

Posted on • Edited on

Building an Automatic Currency Switcher in Next.js

What We're Working With

For this project, I ended up using:

  • 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
  • IP Flare API for the geolocation stuff
  • 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

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 (3)

Collapse
 
lukem121 profile image
Luke

Any issues please leave a comment!

Collapse
 
dust_chen_c648533393ec6a8 profile image
dust chen

could you please give the source code link

Collapse
 
lukem121 profile image
Luke

I got all code snippets from the docs: ipflare.io/documentation/ipflare