I just launched my SaaS to a mix of European and American visitors. The European ones needed to see "€15/mo." The American ones needed to see "$15/mo." And both needed Stripe Checkout to charge them the matching currency without me running two parallel pricing flows.
The official Stripe docs gesture at currency_options but skip the Next.js wiring. Here's the full picture, end to end, in ~50 lines.
TL;DR: One Stripe Price object holds both currencies via currency_options. Detect the visitor's country server-side from Vercel's free geo header. Render the right symbol. Stripe Checkout follows automatically based on billing address.
Zero client-side JavaScript for currency detection. Zero duplicate Price IDs. Zero forked checkout flows.
The wrong ways I tried first
Approach 1: two separate Stripe Prices, switch in code.
// don't do this
const priceId = isAmerican ? "price_usd_xxx" : "price_eur_yyy";
Works, but now you have:
- Two SKUs to keep in sync (raise EUR price → forget USD price → drift)
- Two webhook event paths to handle
- An "is this person American?" decision baked into your code instead of Stripe
Approach 2: client-side currency switching with a JS library.
Tried @formatjs/intl reading navigator.language. Two problems:
- The page renders on the server first. The first paint shows the wrong currency, then hydrates and flickers.
- Browsers lie. A French expat in Boston has
navigator.language === "fr-FR"but bills in USD.
Approach 3 (the right one): currency_options + server-side geo header.
Stripe lets one Price object carry multiple currencies. Stripe Checkout picks the right one based on the customer's billing address. Your job is just to display the matching currency on your marketing page so the price shown there matches the price charged.
Step 1: add currencies to the Stripe Price
When you create the Price, pass currency_options:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const price = await stripe.prices.create({
product: "prod_xxx",
unit_amount: 1500, // €15.00 — primary currency
currency: "eur",
recurring: { interval: "month" },
currency_options: {
usd: { unit_amount: 1500 }, // $15.00
},
});
Already have a Price? Update it:
await stripe.prices.update("price_xxx", {
currency_options: {
usd: { unit_amount: 1500 },
},
});
That's it on the Stripe side. Customers with US/CA billing addresses will now be charged in USD at checkout. Everyone else stays on the primary currency (EUR in my case).
Step 2: detect the visitor's currency server-side
Vercel sets x-vercel-ip-country on every edge request — a free 2-letter ISO country code with no extra API call.
// src/lib/i18n/currency-server.ts
import "server-only";
import { headers } from "next/headers";
export type Currency = "EUR" | "USD";
const USD_COUNTRIES = new Set([
"US", "CA", "AU", "NZ", "SG", "HK",
]);
export async function getServerCurrency(): Promise<Currency> {
try {
const h = await headers();
// 1. Vercel's geolocation header — most reliable
const country = h.get("x-vercel-ip-country");
if (country && USD_COUNTRIES.has(country.toUpperCase())) return "USD";
if (country) return "EUR";
// 2. Fallback for local dev / non-Vercel hosts
const al = (h.get("accept-language") ?? "").toLowerCase();
if (al.startsWith("en-us") || al.startsWith("en-ca")) return "USD";
return "EUR";
} catch {
return "EUR";
}
}
Notes:
-
"server-only"makes Next.js error loudly if anyone tries to import this into a client component. - Fallback to
accept-languageonly matters in local dev. In production Vercel always sets the header. - Australia and New Zealand technically use AUD/NZD but their billing addresses charge USD on Stripe just fine, and the SaaS pricing convention there is USD anyway. Adjust the set to your taste.
Step 3: a pure formatter (importable from client)
Server-only helpers can't be imported into client components. Split the type and the formatter into a separate file with no "server-only" directive:
// src/lib/i18n/currency.ts
export type Currency = "EUR" | "USD";
export function formatPrice(amount: number, currency: Currency): string {
return currency === "USD" ? `$${amount}` : `€${amount}`;
}
I considered Intl.NumberFormat but for SaaS pricing the symbol-prefix style is convention everywhere ("$15", "€15"). The full Intl formatter writes "US$15.00" by default and gets it wrong for German locale ("15,00 €"), which doesn't match how I show prices anywhere else.
Step 4: a regex helper for translated strings with embedded prices
Here's the gotcha nobody tells you about. If you have localized marketing copy like:
const t = {
en: { tagline: "Just €15/mo, billed annually €144/year" },
de: { tagline: "Nur 15 €/Monat — 144 € im Jahresabo" },
};
…you have prices baked into the translation strings. You don't want to ship N × M translations (5 locales × 2 currencies = 10 versions). You want the translation strings to stay as-is, and flip the currency symbol at render time.
Regex to the rescue:
export function localizePriceText(text: string, currency: Currency): string {
if (currency === "EUR") return text;
// "€144" or "€ 144" → "$144"
let out = text.replace(/€\s?(\d+(?:[.,]\d+)?)/g, "$$$1");
// "144 €" or "144€" (German style) → "$144"
out = out.replace(/(\d+(?:[.,]\d+)?)\s?€/g, "$$$1");
return out;
}
Examples:
-
"billed annually €144 / year"→"billed annually $144 / year" -
"144 €/Jahr im Jahresabo"→"$144/Jahr im Jahresabo" -
"Cancel anytime · €15/mo"→"Cancel anytime · $15/mo"
The double $$ in the replacement is required because $ is a special replacement character in JS. $$1 writes a literal $ followed by capture group 1.
Step 5: wire it into your pricing page
Server component reads the currency, passes it down:
// src/app/pricing/page.tsx
import { getServerCurrency } from "@/lib/i18n/currency-server";
import { PricingClient } from "./pricing-client";
export default async function PricingPage() {
const currency = await getServerCurrency();
return <PricingClient currency={currency} />;
}
Client component uses the formatters:
// src/app/pricing/pricing-client.tsx
"use client";
import { formatPrice, localizePriceText, type Currency } from "@/lib/i18n/currency";
import { useT } from "@/lib/i18n/context";
export function PricingClient({ currency }: { currency: Currency }) {
const t = useT();
return (
<div>
<h1>{formatPrice(15, currency)}<small>/mo</small></h1>
<p>{localizePriceText(t.pricing.tagline, currency)}</p>
</div>
);
}
Step 6: don't break checkout
The checkout URL itself doesn't need a currency parameter. Stripe Checkout reads the customer's billing country and picks the right currency from currency_options automatically:
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: "price_xxx", quantity: 1 }],
success_url: `${origin}/welcome`,
cancel_url: `${origin}/pricing`,
});
Test it: open Checkout in an incognito browser, enter a US ZIP — the displayed currency switches to USD. Enter a German ZIP — back to EUR. Same Price ID throughout.
Gotchas I hit
1. payment_method_collection: "if_required" doesn't work with mode: "payment".
I had a one-time-payment SKU (lifetime plan). Stripe rejects this param outside mode: "subscription". Just remove it.
2. Force-dynamic the page if billing config might change at runtime.
export const dynamic = "force-dynamic";
Otherwise Next.js caches the SSR'd HTML and serves stale prices to the wrong region.
3. Don't index pages where the currency varies.
If /pricing returns different prices to different regions, Google might cache one and serve to the wrong audience. Add a canonical and don't worry about it — Google handles the geo-variant case fine if you serve via the geo header (which is what we're doing) instead of via redirect.
4. Vercel's header is x-vercel-ip-country, not cf-ipcountry (Cloudflare) or x-country-code (others).
If you migrate hosts, update the header name.
Result
One Stripe Price. Two currencies on the marketing page. Zero flicker. Zero duplicate SKUs. Stripe Checkout charges the right one automatically.
Total LOC for the currency layer in my codebase: 47 across currency.ts + currency-server.ts. The wiring into UI components added maybe another 30 lines.
I'm currently shipping this on Invest-like — a value-investing tool that scores 12,000 stocks against the published criteria of Buffett, Graham, Lynch, and Greenblatt. Free tier, no signup needed to browse: invest-like.com.
Hit me with questions on the Stripe side or the Next.js side — happy to share more code from the production setup.
Top comments (0)