StripePricingTutorial

Localize Stripe Prices to the Visitor's Currency in 25 Lines

ApogeoAPI7 min read

Stripe is opinionated: a Price object has one currency. If your product is priced at $79 USD, that's what Stripe charges. But for conversion optimization, you want to display the price in the visitor's local currency — Argentinians see ARS, Brazilians see BRL, Germans see EUR — converted at the live FX rate.

The math is simple. The trick is doing it without a 200ms client-side flash.

The architecture

  1. Stripe holds the canonical price (USD) — never change this.
  2. At request time, detect visitor country (IP-based, server side).
  3. Look up the local currency for that country.
  4. Fetch the live USD->X rate for that currency.
  5. Render "$79 USD ≈ AR$ {price * rate}" on the page.
  6. When the user clicks "Buy", Stripe still charges in USD. The conversion was display-only.

This pattern is conversion-friendly (visitor sees comfortable numbers) AND chargeback-safe (Stripe records what they actually paid).

Server-side helper

Drop this in lib/localized-price.ts in your Next.js app:

import { headers } from 'next/headers';

interface LocalizedPrice {
  usd: number;
  display: string;
  currency: string;
  rate: number;
}

const APOGEO = 'https://api.apogeoapi.com/v1';

export async function localizedPrice(usdAmount: number): Promise {
  const ip =
    headers().get('x-forwarded-for')?.split(',')[0] ??
    headers().get('x-real-ip') ??
    '';
  if (!ip) return fallback(usdAmount, 'USD', 1);

  // Step 1: detect visitor country
  const ipRes = await fetch(`${APOGEO}/ip/${ip}`, {
    headers: { 'X-API-Key': process.env.APOGEOAPI_KEY! },
    next: { revalidate: 3600 },
  });
  if (!ipRes.ok) return fallback(usdAmount, 'USD', 1);
  const { country } = await ipRes.json();
  const currency: string = country.currency;

  if (currency === 'USD') return fallback(usdAmount, 'USD', 1);

  // Step 2: fetch live rate
  const fxRes = await fetch(`${APOGEO}/exchange-rates/${currency}`, {
    headers: { 'X-API-Key': process.env.APOGEOAPI_KEY! },
    next: { revalidate: 14400 }, // 4h
  });
  if (!fxRes.ok) return fallback(usdAmount, 'USD', 1);
  const { usdRate }: { usdRate: number } = await fxRes.json();

  const localAmount = Math.round(usdAmount * usdRate);
  const display = new Intl.NumberFormat(undefined, {
    style: 'currency',
    currency,
    maximumFractionDigits: 0,
  }).format(localAmount);

  return { usd: usdAmount, display, currency, rate: usdRate };
}

function fallback(usdAmount: number, currency: string, rate: number): LocalizedPrice {
  return {
    usd: usdAmount,
    display: `$${usdAmount} ${currency}`,
    currency,
    rate,
  };
}

Using it in a Server Component

import { localizedPrice } from '@/lib/localized-price';

export default async function PricingCard() {
  const price = await localizedPrice(79); // your Stripe price in USD

  return (
    <div>
      <h3>Professional Plan</h3>
      <p className="text-3xl font-bold">$79 USD</p>
      {price.currency !== 'USD' && (
        <p className="text-sm text-slate-400">
          ≈ {price.display} at today's rate
        </p>
      )}
      <form action="/api/checkout" method="POST">
        <input type="hidden" name="priceId" value="price_xxx_USD" />
        <button type="submit">Buy now</button>
      </form>
    </div>
  );
}

What happens at checkout

Stripe charges price_xxx_USD ($79 USD). The visitor sees their card statement in whatever currency their bank uses (their bank does the FX conversion). You as the merchant receive USD. Net effect for visitor: they paid roughly the AR$ amount they saw, give or take 1-2% for the bank's FX spread.

Pitfalls to avoid

  • Don't change the Stripe Price object dynamically. If you create one Price per currency, you'll have hundreds in Stripe and accounting becomes a mess. One canonical USD price + display-only localization.
  • Don't show fractional ARS. 300 ARS = 1 USD-ish. Showing "AR$ 7,900.43" looks weird; round to the nearest 100. The maximumFractionDigits: 0 in Intl.NumberFormat handles this.
  • Fall back gracefully. If ApogeoAPI is down or quota exceeded, fall back to USD display. Don't show "$undefined" or block checkout.
  • Cache at 4h. ApogeoAPI's exchange rates refresh every 4 hours, matching the ECB update cadence. There's no benefit to caching for less than that.

Stripe's own currency conversion (when it's actually the right choice)

Stripe offers Adaptive Pricing where Stripe creates per-currency Price objects automatically. It works but has trade-offs: 2% FX spread that Stripe pockets, locked-in to Stripe-decided rates, and you lose visibility into what the visitor actually saw before clicking buy. The display-only approach in this tutorial is simpler, transparent, and free.

Going further

If you want a "Switch currency" dropdown (where user can override auto-detect), store the choice in a cookie and check it before falling through to IP detection. The same localizedPrice() helper handles both flows; just pass an optional currency override.

ApogeoAPI free tier (1,000 req/month) covers this for ~5,000 page views per month with the 4h cache. Get a key at apogeoapi.com.

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key