Multi-currency Stripe pricing without duplicate Price objects (Next.js 15)
Multi-currency Stripe pricing without duplicate Price objects (Next.js 15)
Current Situation Analysis
SaaS platforms targeting cross-border markets face a critical pricing consistency challenge: displaying region-appropriate currency symbols on marketing pages while ensuring Stripe Checkout charges the exact matching currency without maintaining parallel billing flows.
Traditional approaches introduce severe operational friction:
- Dual Price Object Drift: Maintaining separate
price_usdandprice_eurSKUs requires manual synchronization. Any price adjustment mandates dual API calls, creating revenue leakage risks and webhook event duplication. - Client-Side Hydration Mismatch: Relying on
navigator.languageor JS libraries (@formatjs/intl) forces SSR-to-CSR transitions. The initial paint renders the default currency, then hydrates to the detected one, causing UI flicker, Lighthouse score degradation, and inaccurate billing assumptions (e.g., locale β billing country). - Forked Checkout Logic: Hardcoding currency decisions in application code bypasses Stripe's native routing capabilities, requiring custom session parameterization and increasing checkout failure rates.
Stripe's currency_options field solves the backend routing problem, but official documentation lacks Next.js 15 server-component wiring, leaving developers to reinvent geo-detection and formatter boundaries.
WOW Moment: Key Findings
Implementing server-side geo-detection with Stripe's native currency_options eliminates SKU drift, removes client-side hydration flicker, and delegates currency resolution to Stripe's checkout engine. Experimental comparison across three implementation strategies demonstrates the operational and performance advantages:
| Approach | Sync Overhead | Hydration Stability | Checkout Accuracy | Dev Complexity |
|---|---|---|---|---|
| Dual Stripe Prices | High (Manual SKU drift) | High (SSR safe) | Medium (Forked logic) | High |
| Client-Side JS Switching | Low | Low (Flicker/Hydration mismatch) | Low (Browser lies) | Medium |
Server-Side Geo + currency_options |
Zero | High (SSR native) | High (Stripe auto-routes) | Low |
Key Findings:
- Zero duplicate Price IDs required. A single
price_xxxholds all regional variants. - Stripe Checkout automatically resolves
currency_optionsbased on the customer's billing address during session creation. - Server-side Vercel geo headers (
x-vercel-ip-country) provide deterministic currency routing before hydration, eliminating FOUC. - Regex-based string localization preserves translation integrity across N locales Γ M currencies without NΓM string duplication.
Core Solution
The implementation follows a six-step architecture optimized for Next.js 15 server components and Stripe's billing API.
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`,
});
Pitfall Guide
- Dual Price SKU Drift: Maintaining separate
price_usdandprice_eurobjects forces manual synchronization. Any price adjustment requires dual API calls, leading to revenue leakage, webhook duplication, and checkout routing errors. - Client-Side Hydration Flicker: Using
navigator.languageor JS libraries causes SSR/CSR mismatch. The initial paint shows the default currency, then hydrates to the detected one, breaking UX consistency and Core Web Vitals scores. - Ignoring
server-onlyBoundary: Importing geo-detection logic into client components breaks Next.js 15 server component guarantees and causes runtimeheaders()errors. Always isolate server-side header access in aserver-onlymodule. - Regex Replacement Syntax Errors: JavaScript's
String.replace()treats$as a special replacement character. Using$$1is mandatory to output a literal$symbol; otherwise, the replacement string mangles or throws. - Checkout Currency Mismatch: Passing currency parameters to Stripe Checkout URLs is unnecessary and error-prone. Stripe automatically resolves
currency_optionsbased on the customer's billing address during session creation. Do not override this behavior. - Fallback Header Reliability:
accept-languagefallbacks only apply to local dev or non-Vercel hosts. In production, rely exclusively onx-vercel-ip-countryto avoid locale vs. billing address conflicts (e.g., a French expat in Boston hasfr-FRlocale but bills in USD).
Deliverables
- Blueprint: Server-side currency routing architecture for Next.js 15 + Stripe, demonstrating edge-request geo-detection, server/client component boundary separation, and Stripe
currency_optionsdelegation. - Checklist: Multi-currency pricing implementation steps including Stripe Price payload validation, Vercel header verification,
server-onlymodule isolation, regex localization testing, and checkout session verification. - Configuration Templates: Ready-to-deploy modules:
currency-server.ts(geo-detection),currency.ts(formatter),localizePriceTextregex helper, Stripe Price creation/update payloads, and Next.js 15 pricing page wiring.
