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.

1 Decide whether to internationalize 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:

  • Under $5K MRR with no specific non-English customer. Skip. Your time is worth more building product than maintaining two language tracks of a homepage that may not be the right homepage.
  • Above $5K MRR and 30%+ of organic traffic from non-English locales. Now the math works. You have a market signal and revenue to fund the maintenance.
  • Specific enterprise contract requires it. Different conversation. The customer is paying for the work; budget the translation cost into the deal.
  • Programmatic-SEO play targeting localized search intent. i18n is the strategy itself. Build it from day one but plan the translation budget realistically.

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.

2 Pick the i18n library

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.

3 Install next-intl and the middleware

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()
  };
});

4 Set up the [locale] route segment

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.

5 Create message files and use translations

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.

6 Format currency and dates with Intl

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.

7 Internationalize Stripe pricing

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.

8 SEO: hreflang, sitemap, Accept-Language

Translating your product is half the work. Telling search engines you have a multilingual site is the other half. Three signals matter.

hreflang link tags

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).

Sitemap with locale URLs

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}`
          ])
        )
      }
    }))
  );
}

Do not auto-redirect by IP

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.

Translation cost reality

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:

  • Machine translation only (DeepL, Google Translate API). Around $20–$25 per million characters. A typical SaaS app has 5,000–15,000 words across UI and marketing — call it 100K characters — so the bill per locale is roughly $2–$3. Quality is good for navigation and short labels, weaker for marketing copy where tone matters.
  • Machine + light human review. A bilingual contractor reviews the MT output, fixes obvious errors, and adjusts tone. Roughly $0.05–$0.10 per word. The same 5,000-word app costs $250–$500 per locale to ship at acceptable quality.
  • Native professional translation. $0.10–$0.20 per word for general copy, more for technical or legal content. The same app is $500–$2,000 per locale. Required for legal pages (terms of service, privacy policy) and recommended for marketing copy in any market you actually care about.

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.

Search intent varies by locale

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.

Time zones are not locale

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.

Common mistakes

Internationalizing too early

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.

Auto-redirecting based on IP

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.

Translating without localized SEO

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.

Skipping hreflang

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.

Building a custom date and currency formatter

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.

Forgetting RTL

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.

Summary
Decide first → middleware → [locale] → messages → Intl → Stripe → hreflang

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.”

Related guides

Get one SaaS build breakdown every week

The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.