CONTACT US
TABLE OF CONTENTS

Next.js internationalization guide for multilingual app

Jakub Dakowicz
By Jakub DakowiczUpdate:
A Next.js technical guide titled Next.js Internationalisation Guide for Multilingual App with a glowing red atom graphic.

TL;DR

  • Internationalization (i18n) means designing a Next.js application so it adapts to different languages and regions without code changes, while localization translates the actual strings.
  • In the App Router, a dynamic locale folder and the next-intl proxy negotiate locales, redirect requests and pass configuration to translation providers.
  • With the as-needed prefix, the default locale stays at the root while other languages keep their prefix, which avoids a redirect map for existing URLs.
  • Next.js prevents static export alongside the native i18n config, so static sites lose request-time negotiation, proxy rewrites and server-side root redirects.
  • Grouping messages into namespaces and passing only the ones a route needs to the client provider keeps unused strings out of the client bundle.
  • Each localized page needs a self-referencing canonical URL and bidirectional hreflang links with an x-default entry, sent either by the proxy or by generateMetadata.

Internationalization vs localization in Next.js

Internationalization, abbreviated as i18n, means designing the application architecture so it adapts to different languages and regions without code changes. Localization, or l10n, is the actual process of translating strings and providing locale-specific assets for targeted audiences. In Next.js, i18n covers routing, metadata, formatting and layout direction, and localization fills those structures with translated content.

This guide targets Next.js 16 and the App Router, uses next-intl for the code examples, and notes where the Pages Router differs. In Next.js 16 the middleware file convention was renamed to proxy (Next.js proxy docs); if your project runs Next.js 15 or earlier, read every proxy.ts below as middleware.ts.

A locale identifier pairs a language with an optional territory to define regional expectations. Standard tags like en-US and en-GB tell the application whether to expect American or British spelling, dates formatted as month-first versus day-first, and specific currency symbols. Next.js does not enforce a single content-localization method, leaving developers free to choose patterns that suit their project.

True internationalization extends well beyond static string extraction:

  • Locale-aware routing (structuring dynamic URL paths or domains so search engines and users reach regional versions directly).
  • Document metadata and SEO (generating localized page titles, descriptions, and hreflang annotations that align with regional search results).
  • Data formatting (transforming numbers, currencies, and timestamps to match regional rules).
  • Directional layout adaptation (configuring document direction and mirrored layouts for right-to-left scripts).

Retrofitting routing, layout boundaries, and data pipelines into an established codebase requires extensive refactoring, so establishing i18n conventions early makes scaling across markets much simpler.

Choosing your i18n approach and library

A simple dictionary lookup might work for a personal blog, but production applications rapidly outgrow homegrown key-value dictionaries. Dedicated translation libraries manage pluralization rules across language families, inject rich text components directly into translated strings, handle locale-accurate formatting for currencies, timestamps, and decimals, and provide fallback pipelines that prevent untranslated keys from breaking client views.

Modern Next.js ecosystems offer several well-supported options:

  • next-intl: Built specifically for modern Next.js paradigms, providing streaming and asynchronous translation access across boundary layers.
  • next-i18next: Built on the mature i18next ecosystem, commonly chosen for its plugin support and existing codebases. Version 16 supports the App Router, the Pages Router, and mixed setups.
  • React Intl: Part of FormatJS, delivering standards-based ICU message parsing and the FormatJS tooling around it.
  • Intlayer: A modular alternative focusing on declarative content declarations placed alongside application source files.

How to choose: start a new App Router project with next-intl, because its routing, proxy and server utilities map directly onto Server and Client Components. Pick next-i18next when your team already runs i18next or needs its plugins, including codebases that mix both routers. Pick React Intl when the same message catalogs and FormatJS tooling are shared with React apps outside Next.js. Consider Intlayer when you want translations declared next to each component rather than in central message files.

Architectural router differences also dictate your choice. The classic Pages Router supplied automated, configuration-level routing through next.config.js out of the box. The App Router replaces that automatic layer with an explicit proxy file, layout nesting, and path-segment control, which makes dedicated tooling especially useful for orchestrating locale state across server and client boundaries.

Bundle overhead also varies across libraries, and loading entire translation catalogs inside root layouts ships strings that belong to other pages to every client. Splitting messages per route, covered below, keeps that cost down whichever library you pick.

Stop fighting App Router i18n. Make localization simple.

Setting up locale-aware routing

Built-in multilingual routing entered the framework in Next.js 10, establishing conventions for defining supported languages and a default locale. Configuration begins by listing your target languages, such as English, Spanish, and German, while designating a primary fallback. How these locales surface in user-facing URLs dictates both site architecture and crawlability.

RoutingStrategy
Sub-path routingSub-path routing places language identifiers directly into the path, such as example.com/en/about or example.com/es/about. This strategy keeps all regional variants under a single domain, which simplifies SSL certificate management, unified deployments, and shared caching rules
Domain-level routingLocale-specific domains assign distinct hostnames like example.com, example.es, and example.de. While domain routing presents cleaner regional branding, it introduces operational overhead in infrastructure, domain maintenance, and cross-domain session preservation

For sub-paths, next-intl’s localePrefix setting decides where the locale appears (next-intl routing configuration):

  • always: The default. Every path starts with a locale, including the default language, as in /en/about.
  • as-needed: The default locale stays unprefixed (/about), other locales keep their prefix (/de/about), and a superfluous /en/about redirects to /about.
  • never: No locale appears in the URL and requests are rewritten internally, which suits internal tools; alternate links are disabled because URLs are not unique per locale.

For the marketing team that owns the site, the routing choice changes day-to-day work. Sub-path routing keeps every language inside one domain, so one Google Search Console domain property covers all locales and reports can be filtered by the /de/ or /es/ folder. Separate domains each need their own property and build their own backlink profile. Adding locales to an existing site also has a cost: moving unprefixed URLs under an /en/ prefix changes every URL and needs a 301 redirect map, which as-needed avoids by keeping the default language at the root. The examples below use as-needed for that reason.

Next.js internationalization with the App Router

Implementing sub-path internationalization in the App Router begins by wrapping your pages inside a dynamic [locale] folder within the app directory. This structure maps URLs such as /about and /es/about to matching layout trees and enables build-time static generation for known language parameters. With next-intl, the setup takes four small files (next-intl routing setup):

// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing'

export const routing = defineRouting({
  locales: ['en', 'es', 'de'],
  defaultLocale: 'en',
  localePrefix: 'as-needed',
})
// src/proxy.ts (middleware.ts before Next.js 16)
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'

export default createMiddleware(routing)

export const config = {
  // Skip API routes, Next.js internals and any path with a file extension
  matcher: '/((?!api|_next|_vercel|.*\\..*).*)',
}
// src/i18n/request.ts
import * as rootParams from 'next/root-params'
import { notFound } from 'next/navigation'
import { getRequestConfig } from 'next-intl/server'
import { hasLocale } from 'next-intl'
import { routing } from './routing'

export default getRequestConfig(async ({ locale }) => {
  if (!locale) {
    const paramValue = await rootParams.locale()
    if (hasLocale(routing.locales, paramValue)) {
      locale = paramValue
    } else {
      notFound()
    }
  }

  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  }
})
// src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'

export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)

Proxy runs before routes are rendered and defaults to the Node.js runtime. Without a matcher it runs on every request, including static files and image optimization assets, so a locale redirect can block CSS, JavaScript, or images from loading.

For static rendering, add generateStaticParams to the [locale] layout and return routing.locales.map((locale) => ({ locale })). The next/root-params import used in request.ts is available by default from Next.js 16.3; on earlier versions enable experimental.rootParams, or call the legacy setRequestLocale(locale) in each layout and page, which next-intl still supports for backwards compatibility. If the project enables Cache Components, generateStaticParams on the [locale] layout becomes mandatory: every root parameter needs at least one value, or the build fails.

Pages Router and static export limits

In the classic Pages Router, internationalized routing relies on the built-in i18n configuration block inside next.config.js. It handles automatic locale prefixes and negotiation for dynamic Node.js runtimes, but Next.js explicitly prevents running next export (or setting output: 'export') alongside the native i18n config key, throwing a validation error during the build.

Because static hosting eliminates the server runtime, several internationalization capabilities are lost:

  • Request-time negotiation: Static servers cannot parse incoming Accept-Language headers to serve locale variants dynamically.
  • Proxy orchestration: Proxy is not supported in a static export, so runtime rewrites and redirects cannot execute on traditional static storage buckets.
  • Root redirection: Visiting the domain root without an active server requires client-side JavaScript redirects rather than immediate HTTP status responses.

Teams targeting static exports must drop the native i18n key entirely, adopting custom path generation or client-side routing logic to generate distinct localized HTML directories.

Organizing translation files and messages

Most Next.js codebases store localized copy as key-value pairs inside locale-keyed JSON files, typically placed in a dedicated messages/ folder such as messages/en.json and messages/es.json, which is where the request.ts above loads them from. Separate, language-specific files give translators clear boundaries and simplify synchronizing regional updates across repositories.

Structuring strings into nested JSON objects avoids flat files cluttered with repetitive prefix names. Grouping related UI copy into namespaces lets components scope their translations to specific contexts, such as pointing directly to auth.SignUp. Use alphanumeric characters and underscores for property identifiers, reserving dot notation solely to traverse nested objects (for instance, calling t('form.placeholder')).

Route-level code splitting is the biggest operational benefit of disciplined namespacing. Rather than shipping your entire global translation catalog to the client runtime, group messages into namespaces like Common, Navigation and About. Sending only the namespaces the requested route needs to the client eliminates unused UI strings, keeps hydration payloads lightweight, and prevents client-side bundle bloat on initial page loads.

Translating Server and Client Components

Server Components cannot consume context providers, so they resolve strings during server rendering with asynchronous utilities like getTranslations and getFormatter from next-intl/server. When leaf elements need only static output, a parent Server Component can pass translated labels down as plain string props, which keeps translation runtime logic out of browser bundles.

Interactive elements need client-side translation access through hooks like useTranslations and useFormatter. When NextIntlClientProvider is rendered from a Server Component, it inherits all messages by default (next-intl configuration docs). To ship only what the client needs, pass a subset:

// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
import pick from 'lodash/pick'

export default async function LocaleLayout({ children }: { children: React.ReactNode }) {
  const messages = await getMessages()

  return (
    <NextIntlClientProvider messages={pick(messages, ['Navigation'])}>
      {children}
    </NextIntlClientProvider>
  )
}

Because any property forwarded across the server-client boundary becomes visible in the client browser bundle, avoid passing raw user entities or administrative data alongside translation payloads.

Route Handlers and Server Actions run outside the page render, and next/root-params is not available there (Next.js root params docs), so the locale has to be passed in explicitly. In a Server Action, pass the locale as an argument from the calling component and call getTranslations({ locale, namespace: 'LoginForm' }). In a Route Handler, read the locale from the request (a search param, a path segment or the Accept-Language header), check it with hasLocale(routing.locales, locale) and pass it to getTranslations the same way (next-intl Server Actions and Route Handlers docs). That keeps error messages and API responses in the visitor’s language without relying on client hooks.

Detecting and remembering the user’s language

On a first visit to an unprefixed URL, the next-intl proxy resolves the locale in a fixed order: the path prefix, a cookie with a previously detected locale, the Accept-Language header, then defaultLocale (next-intl proxy docs). A hand-written proxy, as in the Next.js internationalization guide, handles the header with Negotiator and @formatjs/intl-localematcher.

Explicit user selections must always take precedence over browser settings. The proxy stores the matched locale in a cookie, so once a visitor opens a page in the language they picked, later requests skip header parsing. With as-needed, keep in mind that this cookie can redirect a returning visitor from an unprefixed URL to the last locale they used.

Search engines see a different picture. Google’s crawler sends requests without an Accept-Language header (Google on locale-adaptive pages), so with as-needed it lands on the default-language version at the root and reaches the other languages through their own URLs and hreflang links.

Under the classic Pages Router, automatic detection runs out of the box because localeDetection defaults to true in next.config.js. This mechanism redirects visitors on default routes based on incoming language headers and sets the NEXT_LOCALE cookie automatically. Teams frequently set localeDetection: false when implementing custom negotiation or preventing unexpected redirects that interfere with search engine crawlers and proxy caches.

Building a language switcher

A functional language switcher lets visitors change language without losing their place on your website. Rather than hard-coding language links, map over your configured locales to build a dropdown or navigation menu, highlighting the current selection, and keep the visitor on the same path name when the locale changes.

With the navigation helpers from src/i18n/navigation.ts, the switcher replaces only the locale and keeps the path (next-intl navigation docs):

// src/components/locale-switcher.tsx
'use client'
import { useLocale } from 'next-intl'
import { usePathname, useRouter } from '@/i18n/navigation'
import { routing } from '@/i18n/routing'

export function LocaleSwitcher() {
  const locale = useLocale()
  const pathname = usePathname()
  const router = useRouter()

  return (
    <select value={locale} onChange={(event) => router.replace(pathname, { locale: event.target.value })}>
      {routing.locales.map((option) => (
        <option key={option} value={option}>{option.toUpperCase()}</option>
      ))}
    </select>
  )
}

With as-needed, choosing Spanish on /pricing routes to /es/pricing, and the English version of the same page lives at /pricing. Under the Pages Router, router.push(pathname, asPath, { locale: nextLocale }) does the same. The Link component from the same file prefixes links with the current locale and prefetches the target route, so in-language navigation usually feels instant, without a full page reload.

Plurals, rich text and formatted values

Simple string concatenation produces awkward phrasing like “1 item(s)” or breaks down across language families. The ICU Message Format embeds conditional logic, variable interpolation, and locale-aware formatting directly into message strings, with six plural categories: zero, one, two, few, many, and other.

A plural message and its call look like this (next-intl translations docs):

"message": "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}."
t('message', { count: 3580 }) // "You have 3,580 followers."

The Unicode CLDR plural rules define which categories each language uses:

  • Cardinal forms: English uses 2 forms (one, other), Chinese requires only 1 form (other), while Arabic spans all 6 categories to account for singular, dual, and complex plural ranges.
  • Ordinal rankings: Selecting ranks via selectordinal addresses suffixes, allowing English to manage 4 distinct forms (one for “st”, two for “nd”, few for “rd”, and other for “th”).
  • Categorical selection: The select argument handles fixed choices like gender or UI states without duplicating outer sentence structures.
  • Rich-text integration: Embedding custom tags inside message strings lets translation functions like t.rich render designated phrase segments as interactive React components or bold text without splitting phrases.
  • Standardized formatting: The # symbol and built-in formatters convert numbers, dates, and currencies according to regional conventions, which is why 3580 renders as “3,580 followers” for English readers.

Supporting right-to-left languages

When serving right-to-left (RTL) languages such as Arabic, Hebrew, or Persian, the reading flow, layout orientation, and interactive cues must mirror to match user expectations. Start at the document root by applying dynamic lang and dir attributes to the <html> element based on the resolved locale parameter.

  • Locale mapping: Map Arabic to rtl, while assigning English, French, and Dutch to ltr, attaching the resolved value to the document dir attribute or wrapping content containers.
  • Layout mirroring: Replace physical CSS properties like margin-left or right with logical counterparts such as margin-inline-start and inset-inline so grids, flex containers, and text alignments reverse automatically.
  • Icon orientation: Invert directional interface iconography, including back arrows, forward indicators, and breadcrumb carets, while keeping symmetric or universal icons unchanged.

Adopting logical properties early prevents the maintenance burden of writing duplicate stylesheet overrides for bidirectional layouts.

Multilingual SEO for Next.js sites

Search engines treat each regional variation of a page as an independent document that requires distinct metadata. Generating unique, translated titles, meta descriptions, and self-referential canonical tags per route prevents duplicate content flags across parallel regional variants, and distinct URL patterns like /de/page and /es/page let search bots index every language version.

Client-side content switches that hide the language behind query parameters or cookies cause indexation failures, because crawlers see only one version.

Connecting those URLs requires bidirectional hreflang alternate tags in the document head, HTTP headers, or the sitemap, so a German searcher receives the German document rather than an English fallback (Google Search Central on localized versions). An x-default entry routes unmatched traffic to your global default page.

The next-intl proxy already sends these alternate links, including x-default, as an HTTP Link header. If you prefer them in the page head, set alternateLinks: false in the routing config and use the alternates field of generateMetadata, so each page carries one set rather than two. Set metadataBase in the root layout so the relative paths resolve to absolute URLs:

// src/app/[locale]/about/page.tsx
import type { Metadata } from 'next'
import { getPathname } from '@/i18n/navigation'
import { routing } from '@/i18n/routing'

export async function generateMetadata({ params }: PageProps<'/[locale]/about'>): Promise<Metadata> {
  const { locale } = await params

  return {
    alternates: {
      canonical: getPathname({ href: '/about', locale }),
      languages: {
        ...Object.fromEntries(
          routing.locales.map((l) => [l, getPathname({ href: '/about', locale: l })])
        ),
        'x-default': getPathname({ href: '/about', locale: routing.defaultLocale }),
      },
    },
  }
}

Next.js renders each entry as <link rel="alternate" hreflang="es" href="https://example.com/es/about" /> in the page head.

  • Locale-specific sitemaps: Generate sitemaps containing localized URLs or include cross-referenced xhtml:link alternate annotations within a unified XML file, so crawlers discover every language version.
  • Localized canonical URLs: Assign every regional page a self-referencing canonical URL rather than pointing all translations back to the default language path.
  • Robots indexing rules: Grant search engine user agents unrestricted crawl access to all localized sub-paths, and never redirect a prefixed URL such as /es/about to another language based on the visitor’s location.

Pitfalls and type safety in i18n projects

Silent failures are the greatest operational hazard in multilingual Next.js applications: missing message keys or outdated dictionaries leak raw token identifiers and blank labels into client-facing layouts. The fix is to turn translation lapses into build-time errors rather than runtime surprises.

Typed message dictionaries form the primary defense. Libraries like next-intl support global TypeScript interfaces that infer key paths directly from your canonical message files, so a misspelled key path or a removed property triggers a TypeScript compilation error inside your editor and build pipeline.

CI checks can compare regional JSON files against the primary locale dictionary, flagging stale keys, structural discrepancies, and missing translations before code merges to main. Deliberate fallback cascades can serve base-language copy when a locale lacks specific entries, but these fallbacks should log warnings during testing so unlocalized gaps never reach deployment unnoticed.

Finally, standardize naming conventions. Mismatches between URL locale identifiers, internal file names, and content tags (such as confusing en-US with en_US or lowercase region codes) can cause message-loading imports to fail silently and trigger 404 responses at runtime.

Flawless translations. Zero routing bugs. Sub-second speed.

Managing translations as the project grows

Passing raw JSON files over email or chat breaks down once several translators work on the project, causing git merge conflicts and untracked string revisions. A translation management system (TMS) syncs directly with code repositories, lets external linguists work without touching your Next.js source code, and keeps terminology consistent through shared glossaries and translation memories.

With branch-triggered CI workflows, every git push uploads new source keys to services like Crowdin, which can pre-translate them with machine translation before human reviewers step in. Key descriptions, layout screenshots, and in-context previews help translators handle ambiguous labels and tight component boundaries.

Finally, keep UI microcopy separate from marketing content. Interface buttons and validation notices live in version-controlled message files, while landing pages and articles come from a localized headless CMS that feeds translated content into your Next.js layouts. That split lets the marketing team publish independently in multiple languages, without waiting for a developer release.

Pagepro built Localcoin’s multilingual Next.js site on this split, and its marketing team publishes independently in multiple languages; the client’s product manager summed up the project this way.

They understood our technical goals and our desired performance metric. They were also very detail-oriented, and helped us achieve the KPIs we set for the project, including a lighter website, increased speed, and better SEO ranking.

Michael Winer, Product Manager, Localcoin

Planning a multilingual Next.js build? See how Pagepro approaches Next.js development, or read the Localcoin case study for a multi-language Next.js site in production.

FAQ

How do I set up sub-path locale routing in the Next.js App Router?

Wrap your pages in a dynamic [locale] folder inside the app directory. With next-intl, define locales and localePrefix in routing.ts, export createMiddleware(routing) from proxy.ts (middleware.ts before Next.js 16), and exclude API routes and static files from its matcher.

Why does static export break the built-in i18n config in Next.js?

Static export removes the Node.js server runtime, so Next.js cannot negotiate locales from headers, run proxy rewrites, or redirect the root. Using output: 'export' with the i18n key triggers a build-time validation error.

Which i18n library should I choose for Next.js: next-intl, next-i18next, or React Intl?

Next-intl fits new App Router projects, and next-i18next suits teams that need the i18next plugin ecosystem or already run it, on either router. React Intl fits teams that share message catalogs and FormatJS tooling with React apps outside Next.js.

How do I stop translation files from bloating the client bundle?

Group messages into namespaces like Common and Navigation instead of sending a global catalog. Pass only the namespaces a route needs to NextIntlClientProvider, so unused strings never reach the client.

How do I translate error messages in Server Actions and Route Handlers?

Pass the locale explicitly. next/root-params is not available in Server Actions or Route Handlers, so send the locale as an argument to the action, or read and validate it from the request in a Route Handler, then call getTranslations({ locale, namespace }).

How should hreflang and canonical tags be set for multilingual Next.js pages?

Give each regional page a self-referencing canonical URL, not one that points back to the default language. Link all regional variants with bidirectional hreflang tags using absolute URLs, plus an x-default entry, either from the next-intl proxy or from generateMetadata, not both.

How do I detect a user’s language and remember their choice in Next.js?

The next-intl proxy checks the URL prefix, then a locale cookie, then the Accept-Language header, then the default locale. The proxy stores the matched locale in that cookie, so a language the visitor picked wins over the header on later visits.

What’s the difference between sub-path and domain-level locale routing in Next.js?

Sub-path routing puts the language in the URL path, like example.com/es, which simplifies SSL and deployment. Domain-level routing uses distinct hostnames like example.de for cleaner regional branding, at the cost of more infrastructure and cross-domain sessions.

How do I support right-to-left languages like Arabic in a Next.js layout?

Apply dynamic lang and dir="rtl" attributes to the <html> element in your root layout. Use CSS logical properties such as margin-inline-start instead of margin-left so layouts mirror automatically, and invert directional icons such as back arrows.

Jakub Dakowicz

Jakub Dakowicz is CTO at Pagepro, a software house specializing in Next.js and Sanity migrations. He joined over ten years ago as a junior developer and now owns the technology decisions, delivery processes and code quality behind every project the team ships. Jakub is the person behind the standards Pagepro's migrations run on - Nexity, the in-house Next.js and Sanity framework, and the project starter that makes each new site cheaper and less buggy than the last. Recently he led few WordPress-to-Next.js migrations and has been building quick prototypes - usually a working preview in the client's hands within a day - so decisions get made on something real instead of a slide. He spends a lot of his time on AI-assisted development - agents, coding tools and treating the repository as a living knowledge base for both the team and the models. He speaks about it at local tech meetups and writes about migrations, headless architecture and how AI changes the way agencies build. Politechnika Białostocka alumnus, based in Białystok.

Article link copied

Close button

Leave a Reply

* Required informations.