An eight-step tutorial covering the App Router locale segment, next-intl middleware, message files, currency and date formatting via the built-in Intl API, and the SEO tags Google needs to rank a multilingual site.
Methodology. This tutorial synthesizes Next.js App Router i18n patterns and the next-intl docs as of May 2026. Code mirrors the canonical examples from next-intl-docs.vercel.app and the Next.js routing docs at nextjs.org/docs/app/building-your-application/routing/internationalization. The Intl.NumberFormat and Intl.DateTimeFormat APIs are part of the JavaScript standard library and available in every modern Node and browser runtime.
Internationalization is the feature most solo founders add too early and the rest add too late. Too early because translating a product before you have product-market fit means maintaining ten copies of every screen while still figuring out the screen. Too late because once an enterprise customer in Germany or a Spanish-speaking growth channel becomes real, every page in the codebase needs a locale-aware refactor that should have been the first commit.
This guide walks the canonical Next.js App Router i18n setup with next-intl — the library most production teams converge on — and ends with the SEO tags Google requires to rank a multilingual site. The real work is in Step 1: deciding whether you should be doing this at all.
The honest answer for most solo SaaS founders is: not yet. Internationalization is a tax that compounds. Every new feature, every marketing page, every error message has to ship in N languages from then on. That tax is worth paying when you have evidence that a non-English market exists; it is wasted when you are still finding the first version of the product.
A pragmatic threshold:
The trap is that internationalization feels like growth work. Translating the product into Spanish does not, by itself, generate Spanish-speaking customers — you still need a localized go-to-market motion (search intent, payment methods, trust signals, support hours) for that traffic to convert. Without those, a translated product just doubles your maintenance surface.
The rest of this tutorial assumes you have decided i18n is worth the cost. If you are still on the fence, the should you build SaaS in 2026 guide and the customer acquisition guide cover the upstream questions about market selection.
Three real options for Next.js in 2026:
| Library | Best for | Trade-off |
|---|---|---|
| next-intl | App Router projects starting fresh | Tight integration with Server Components, opinionated routing, larger feature surface |
| next-i18next | Pages Router legacy apps | Mature ecosystem, but the App Router story is awkward; team is migrating to alternatives |
| DIY route segments | Tiny apps with two locales and no plurals | No runtime cost, no library lock-in, but you rebuild plurals, dates, currency yourself |
For a Next.js App Router SaaS in 2026, next-intl is the default. It supports Server Components without “use client” gymnastics, exposes the same translation API in client and server code, integrates with the App Router middleware for locale routing, and has first-class TypeScript support for message keys. The rest of this guide uses next-intl.
If you are on the Pages Router with a large existing app, next-i18next is still the path of least resistance — but plan the migration to next-intl whenever you move to the App Router. The DIY approach is fine for a marketing site with two locales and no dynamic content; it falls over the moment you need plurals (one item / two items / many items, with different rules in Russian than English) or relative time formatting.
npm install next-intl # or pnpm add next-intl
The first wiring is a small middleware that detects the active locale from the URL or the Accept-Language header and rewrites the request to a locale-prefixed path. Create middleware.ts at the repo root:
// middleware.ts
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['en', 'es', 'fr', 'de', 'pt'],
defaultLocale: 'en',
localePrefix: 'as-needed' // / for default, /es, /fr, etc. for others
});
export const config = {
matcher: [
// Match all paths except API routes, Next.js internals, and static files
'/((?!api|_next|_vercel|.*\\..*).*)'
]
};
The localePrefix: 'as-needed' setting means English (the default) lives at /pricing, while Spanish lives at /es/pricing and French at /fr/pricing. The alternatives are 'always' (every locale gets a prefix, including /en/pricing) and 'never' (locale comes from a cookie or header instead of the URL). For SEO, ‘as-needed’ or ‘always’ are the right choices — Google needs distinct URLs per locale to index them as separate pages.
Add i18n.ts at the repo root to centralize the locale config:
// i18n.ts
import { getRequestConfig } from 'next-intl/server';
import { notFound } from 'next/navigation';
const locales = ['en', 'es', 'fr', 'de', 'pt'] as const;
type Locale = (typeof locales)[number];
export default getRequestConfig(async ({ locale }) => {
if (!locales.includes(locale as Locale)) notFound();
return {
messages: (await import(`./messages/${locale}.json`)).default,
timeZone: 'UTC',
now: new Date()
};
});
Move every page under app/[locale]/. The dynamic [locale] segment is what catches the locale prefix from the middleware. A typical structure:
app/
[locale]/
layout.tsx # loads messages for the active locale
page.tsx # / (home)
pricing/
page.tsx # /pricing, /es/pricing, etc.
dashboard/
page.tsx
api/ # NOT under [locale] — APIs are locale-agnostic
webhooks/
stripe/
route.ts
layout.tsx # optional outer layout (often skipped)
The locale layout is small but important. It calls setRequestLocale for static rendering, sets the lang attribute on <html>, and wraps children in the next-intl provider so client components can access translations:
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
const locales = ['en', 'es', 'fr', 'de', 'pt'];
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params: { locale }
}: {
children: React.ReactNode;
params: { locale: string };
}) {
if (!locales.includes(locale)) notFound();
setRequestLocale(locale);
const messages = await getMessages();
return (
<html lang={locale} dir={locale === 'ar' || locale === 'he' ? 'rtl' : 'ltr'}>
<body>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
The generateStaticParams call is the bridge between dynamic routes and static generation — without it, Next.js renders every locale on demand. With it, every locale gets its own pre-rendered HTML, which is essential for both performance and SEO.
Messages live in JSON, one file per locale, namespaced however you like. A simple structure mirrors your component tree:
// messages/en.json
{
"Marketing": {
"hero": {
"title": "Build your SaaS faster",
"subtitle": "AI-native tools for solo founders",
"cta": "Start free trial"
},
"pricing": {
"monthly": "per month",
"annual": "per year",
"save": "Save {percent}%"
}
},
"Errors": {
"generic": "Something went wrong. Please try again.",
"notFound": "We couldn’t find that page."
}
}
// messages/es.json
{
"Marketing": {
"hero": {
"title": "Construye tu SaaS más rápido",
"subtitle": "Herramientas con IA para fundadores en solitario",
"cta": "Empieza la prueba gratis"
},
"pricing": {
"monthly": "al mes",
"annual": "al año",
"save": "Ahorra un {percent}%"
}
},
"Errors": {
"generic": "Algo salió mal. Inténtalo de nuevo.",
"notFound": "No pudimos encontrar esa página."
}
}
Use useTranslations() in client components and getTranslations() in server components. Both take a namespace and return a function:
// app/[locale]/page.tsx (server component)
import { getTranslations } from 'next-intl/server';
export default async function HomePage() {
const t = await getTranslations('Marketing.hero');
return (
<section>
<h1>{t('title')}</h1>
<p>{t('subtitle')}</p>
<a href="/signup">{t('cta')}</a>
</section>
);
}
// components/pricing-toggle.tsx (client component)
'use client';
import { useTranslations } from 'next-intl';
export function PricingToggle({ savePercent }: { savePercent: number }) {
const t = useTranslations('Marketing.pricing');
return (
<div>
<button>{t('monthly')}</button>
<button>
{t('annual')} — {t('save', { percent: savePercent })}
</button>
</div>
);
}
The {percent} placeholder pattern is ICU MessageFormat. next-intl supports the full ICU syntax including plurals ({count, plural, one {# item} other {# items}}), gender selection, and nested formatting. Plurals are where DIY i18n falls apart — Russian has three plural forms, Polish has four, Arabic has six. ICU MessageFormat handles every CLDR-defined locale correctly; your hand-rolled count === 1 ? 'item' : 'items' does not.
The biggest mistake teams make here is reaching for moment.js, date-fns, or a currency library before checking what JavaScript already ships. Modern Node and browsers include the full Intl namespace from ECMAScript: Intl.NumberFormat, Intl.DateTimeFormat, Intl.RelativeTimeFormat, Intl.PluralRules, Intl.ListFormat. They are zero-bundle-cost, locale-correct for every CLDR locale, and supported back to Node 14 and every browser since 2019.
// lib/format.ts
export function formatCurrency(
amount: number,
locale: string,
currency: string
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
maximumFractionDigits: 2
}).format(amount);
}
export function formatDate(
date: Date,
locale: string,
style: 'short' | 'long' = 'short'
): string {
return new Intl.DateTimeFormat(locale, {
dateStyle: style
}).format(date);
}
export function formatRelativeTime(
date: Date,
locale: string,
now: Date = new Date()
): string {
const diffMs = date.getTime() - now.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
return rtf.format(diffDays, 'day');
}
Use it from a component:
// components/invoice-row.tsx
import { useLocale } from 'next-intl';
import { formatCurrency, formatDate } from '@/lib/format';
export function InvoiceRow({ amount, currency, paidAt }: {
amount: number;
currency: string;
paidAt: Date;
}) {
const locale = useLocale();
return (
<tr>
<td>{formatCurrency(amount, locale, currency)}</td>
<td>{formatDate(paidAt, locale)}</td>
</tr>
);
}
The output adapts automatically. formatCurrency(49, 'en-US', 'USD') renders "$49.00"; formatCurrency(49, 'de-DE', 'EUR') renders "49,00 €"; formatCurrency(49, 'fr-FR', 'EUR') renders "49,00 €" with a non-breaking space between number and symbol — the typographic conventions French copy editors will absolutely flag if you get them wrong.
Stripe handles currency conversion in two distinct ways and choosing the right one matters.
Multi-currency Prices. The recommended approach. Create one Stripe Product, attach multiple Price objects (one per currency), and at Checkout time pass the price id matching the customer’s locale. Stripe presents the localized currency, the local payment methods (SEPA in Germany, iDEAL in the Netherlands, Bancontact in Belgium), and handles VAT collection automatically through Stripe Tax.
// lib/stripe-prices.ts
type Plan = 'pro' | 'business';
type Currency = 'usd' | 'eur' | 'gbp';
const PRICE_MAP: Record<Plan, Record<Currency, string>> = {
pro: {
usd: 'price_pro_usd_monthly',
eur: 'price_pro_eur_monthly',
gbp: 'price_pro_gbp_monthly'
},
business: {
usd: 'price_business_usd_monthly',
eur: 'price_business_eur_monthly',
gbp: 'price_business_gbp_monthly'
}
};
export function getPriceId(plan: Plan, locale: string): string {
const currency: Currency =
locale.startsWith('en-GB') ? 'gbp' :
locale.startsWith('en') || locale.startsWith('es-MX') ? 'usd' :
'eur';
return PRICE_MAP[plan][currency];
}
// app/[locale]/api/checkout/route.ts (excerpt)
import { stripe } from '@/lib/stripe';
import { getPriceId } from '@/lib/stripe-prices';
export async function POST(req: Request) {
const { plan, locale } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: getPriceId(plan, locale), quantity: 1 }],
locale: locale.split('-')[0], // Stripe Checkout UI language
success_url: `${process.env.APP_URL}/${locale}/welcome`,
cancel_url: `${process.env.APP_URL}/${locale}/pricing`
});
return Response.json({ url: session.url });
}
The locale parameter on the Checkout session is what localizes the Stripe-hosted UI. Stripe supports about 30 languages out of the box; pass the two-letter language code and Stripe handles the rest.
Adaptive Pricing. A newer Stripe feature that lets a single USD price auto-convert to local currency at Checkout. Easier to set up but gives you no control over psychological pricing — $29 becomes $29 × today's rate, which presents as €26.84 rather than the round €29 a European customer expects. Use this only if you have one product, one price, and you do not care about price-point optics in non-USD markets.
The Vercel deployment guide covers the rest of the production stack; the Stripe webhook side stays the same regardless of locale because invoices contain explicit currency fields and the webhook is locale-agnostic.
Translating your product is half the work. Telling search engines you have a multilingual site is the other half. Three signals matter.
Every page must announce its locale and link to its translations. Google uses hreflang to serve the right version to the right country and to avoid penalizing you for “duplicate content” across translations. The canonical pattern in the App Router is to emit alternates from a metadata function:
// app/[locale]/pricing/page.tsx
import type { Metadata } from 'next';
const locales = ['en', 'es', 'fr', 'de', 'pt'];
export async function generateMetadata({
params: { locale }
}: {
params: { locale: string };
}): Promise<Metadata> {
const path = '/pricing';
return {
alternates: {
canonical: locale === 'en'
? `https://yoursaas.com${path}`
: `https://yoursaas.com/${locale}${path}`,
languages: Object.fromEntries(
locales.map((l) => [
l,
l === 'en'
? `https://yoursaas.com${path}`
: `https://yoursaas.com/${l}${path}`
]).concat([['x-default', `https://yoursaas.com${path}`]])
)
}
};
}
The x-default entry is what Google serves when no locale matches the user’s preferences. Point it at your most universal page (usually English).
Every translated URL belongs in sitemap.xml with explicit locale alternates:
// app/sitemap.ts
import type { MetadataRoute } from 'next';
const locales = ['en', 'es', 'fr', 'de', 'pt'];
const paths = ['/', '/pricing', '/about'];
export default function sitemap(): MetadataRoute.Sitemap {
return paths.flatMap((path) =>
locales.map((locale) => ({
url: locale === 'en'
? `https://yoursaas.com${path}`
: `https://yoursaas.com/${locale}${path}`,
lastModified: new Date(),
alternates: {
languages: Object.fromEntries(
locales.map((l) => [
l,
l === 'en'
? `https://yoursaas.com${path}`
: `https://yoursaas.com/${l}${path}`
])
)
}
}))
);
}
This is the single biggest UX mistake in i18n. Geo-IP detection is wrong often enough — corporate VPNs, travelers, expats, dual-citizens — that auto-redirecting based on IP creates more friction than it removes. The right pattern is to use Accept-Language as a soft hint, surface a locale switcher prominently in the header, and respect the user’s explicit choice (stored in a cookie) over any automatic detection.
next-intl’s middleware handles this correctly out of the box: on first visit, it reads Accept-Language and routes to the matching locale; a user clicking the locale switcher sets a cookie and that cookie wins on every subsequent visit. Do not override this with IP-based logic.
The translation cost is the part of i18n nobody budgets for accurately. Three tiers, with rough ranges from public translation-agency rate cards as of 2026:
The pragmatic stack is MT for the first pass to validate the i18n plumbing works end-to-end, then upgrade to human-reviewed translation for any locale that starts generating revenue. Right-to-left languages (Arabic, Hebrew, Persian, Urdu) are a different ballgame — the CSS layout has to flip via dir="rtl", icons that imply direction (back arrows, progress bars) need RTL variants, and many third-party UI libraries do not handle RTL correctly. Budget extra engineering time, not just translation cost.
The other half of localized SEO is keyword research per market. Translating your existing English keywords into Spanish gives you the wrong target — Spanish-speaking searchers do not phrase their problems the same way. The keyword “best CRM for small business” is huge in English; the literal Spanish translation has tiny volume because Spanish-speaking founders search “CRM gratis para pymes” instead. Real i18n SEO requires real keyword research per market, ideally with a local consultant or a tool that supports per-country search volume.
One subtle bug: locale and time zone are independent. A French speaker in Quebec uses fr-CA with America/Toronto. A French speaker in Paris uses fr-FR with Europe/Paris. Storing only the locale and inferring the time zone from it is wrong. Store the user’s time zone separately (capture it from the browser via Intl.DateTimeFormat().resolvedOptions().timeZone on signup) and use the locale for formatting and the time zone for date calculations.
The maintenance tax compounds. Until you have a non-English revenue signal, every locale you add is dead weight on every future feature. Most solo founders should grow revenue in English first.
Travelers, expats, and VPN users hate it. Use Accept-Language as a hint, expose a locale switcher, and persist the user’s explicit choice via cookie.
A translated product is not a localized go-to-market motion. If you do not also research keywords per market, set up local payment methods, and translate trust signals (testimonials, customer logos), the translated pages will not convert.
Without hreflang, Google may serve the wrong version to the wrong country, treat translations as duplicate content, or rank only one locale. Emit alternates.languages from every page’s metadata and include alternates in the sitemap.
The built-in Intl API is locale-correct and zero bundle cost. Reaching for a date library to format dates in Next.js in 2026 is overhead with no benefit unless you need parsing or arithmetic the standard library does not cover.
Arabic, Hebrew, Persian, and Urdu need dir="rtl" on the document and a CSS audit for layouts that assume left-to-right. Tailwind’s logical properties (start-0, end-0, ms-4, me-4) make this much less painful than it used to be, but it is still real engineering work, not a config flag.
The Next.js i18n stack in 2026 is small and well-trodden: next-intl middleware, an app/[locale] segment, JSON message files, and the built-in Intl API for currency and dates. The hard part is upstream — deciding whether your product is ready for translation at all and budgeting realistically for translation quality, localized SEO, and RTL support. For most solo founders under $5K MRR, the right answer is “not yet, ship more product first.”
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.