← Back to Blog
Next.js2026-05-04Β·53 min read

Multi-currency Stripe pricing without duplicate Price objects (Next.js 15)

By Zaid G.

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_usd and price_eur SKUs 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.language or 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_xxx holds all regional variants.
  • Stripe Checkout automatically resolves currency_options based 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-language only 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

  1. Dual Price SKU Drift: Maintaining separate price_usd and price_eur objects forces manual synchronization. Any price adjustment requires dual API calls, leading to revenue leakage, webhook duplication, and checkout routing errors.
  2. Client-Side Hydration Flicker: Using navigator.language or 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.
  3. Ignoring server-only Boundary: Importing geo-detection logic into client components breaks Next.js 15 server component guarantees and causes runtime headers() errors. Always isolate server-side header access in a server-only module.
  4. Regex Replacement Syntax Errors: JavaScript's String.replace() treats $ as a special replacement character. Using $$1 is mandatory to output a literal $ symbol; otherwise, the replacement string mangles or throws.
  5. Checkout Currency Mismatch: Passing currency parameters to Stripe Checkout URLs is unnecessary and error-prone. Stripe automatically resolves currency_options based on the customer's billing address during session creation. Do not override this behavior.
  6. Fallback Header Reliability: accept-language fallbacks only apply to local dev or non-Vercel hosts. In production, rely exclusively on x-vercel-ip-country to avoid locale vs. billing address conflicts (e.g., a French expat in Boston has fr-FR locale 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_options delegation.
  • Checklist: Multi-currency pricing implementation steps including Stripe Price payload validation, Vercel header verification, server-only module isolation, regex localization testing, and checkout session verification.
  • Configuration Templates: Ready-to-deploy modules: currency-server.ts (geo-detection), currency.ts (formatter), localizePriceText regex helper, Stripe Price creation/update payloads, and Next.js 15 pricing page wiring.
Multi-currency Stripe pricing without duplicate Price objects (Next.js 15) | Codcompass