Web Design and EngineeringJuly 18, 20269 min read

Next.js i18n without a library: the App Router pattern we ship

Next.js App Router ships no built-in i18n. We run three locales on native routing with zero i18n dependencies. Here is the exact production pattern.

a signpost with several arrows

By the end of this guide you have a Next.js 16 site serving three languages on native routing, with zero i18n packages in your dependency tree. English at /en, Italian at /it, Spanish at /es, localized URLs where they matter, and correct hreflang tags so Google shows the right variant per market. This is the exact pattern we run in production on a three-locale site.

The App Router dropped the built-in i18n routing that the Pages Router had shipped since Next.js 10. There is no i18n key in next.config.js anymore. That reads like a gap. It is closer to a clean slate: locale routing now lives in code you own, and for a fixed set of languages you do not need a package to manage it. A library becomes worth its weight for a different reason, and we get to that at the end.

What you need before starting

  • Next.js 16 on the App Router, React 19.
  • A decided, small set of locales. This pattern fits 2 to 6 languages. Past that, a translation management tool starts to pull its weight.
  • Translations you can express as key-value strings. Marketing copy, UI labels, SEO metadata: yes. Legal contracts with per-jurisdiction clauses: handle those as separate documents, not dictionary keys.
  • One decision made up front: which locale is the default and whether it carries a URL prefix. We prefix all three (/en, /it, /es) because symmetric URLs are simpler to reason about than a bare-root default.

Step 1: Put every page under an app/[lang] segment

The whole pattern hangs off one dynamic segment. Move every public route under app/[lang]/. Next.js forwards the lang param to every layout and page below it.

app/
  [lang]/
    layout.tsx      // reads lang, sets the provider
    page.tsx        // home
    blog/
      page.tsx
      [slug]/page.tsx
  layout.tsx        // root: html, fonts, nothing locale-specific

Keep folder names in the default locale (English): blog, projects, about. The translated URLs come later in Step 5 through a rewrite, so you never keep three copies of the same route tree.

Step 2: Detect the locale in proxy.ts, the file formerly known as middleware

Next.js 16 renamed middleware.ts to proxy.ts and the exported function from middleware to proxy. The logic is unchanged, the runtime is Node only, and the old file still works but is deprecated. Run npx @next/codemod middleware-to-proxy . if you are upgrading an existing project. Fresh projects start on proxy.ts directly.

The proxy does three jobs: send bare / to the visitor's best-guess locale, block requests that already carry a valid prefix, and skip static assets and /api. Locale detection reads the Accept-Language header.

// proxy.ts
import { NextRequest, NextResponse } from 'next/server'

const locales = ['en', 'it', 'es']
const defaultLocale = 'en'

function pickLocale(req: NextRequest) {
  const header = req.headers.get('accept-language') ?? ''
  const wanted = header.split(',').map(p => p.split(';')[0].trim().slice(0, 2))
  return wanted.find(l => locales.includes(l)) ?? defaultLocale
}

export function proxy(req: NextRequest) {
  const { pathname } = req.nextUrl
  const hasLocale = locales.some(l => pathname === '/' + l || pathname.startsWith('/' + l + '/'))
  if (hasLocale) return NextResponse.next()
  const locale = pickLocale(req)
  return NextResponse.redirect(new URL('/' + locale + pathname, req.url))
}

export const config = { matcher: ['/((?!api|_next|.*\..*).*)'] }

The hand-rolled two-letter match is fine for a handful of locales. The official Next.js guide reaches for Negotiator and @formatjs/intl-localematcher to run a proper best-fit match against quality values. Both are tiny. Use them the moment you support regional variants like pt-BR versus pt-PT, where a naive slice breaks.

Step 3: Keep dictionaries as plain strings, never functions

This is the rule that saves you a build-time crash. A dictionary is a nested object of strings, one file per locale, with an identical shape across all three.

// dictionaries/en.ts
export const en = {
  nav: { home: 'Home', blog: 'Blog', contact: 'Contact' },
  blog: { readingTime: '{n} min read' },
} as const

export type Dictionary = typeof en
// it.ts and es.ts declare the same keys, or TypeScript complains

Never put a function in a dictionary. React 19 and Next 16 throw "Functions cannot be passed directly to Client Components" at build time the moment a function value crosses the Server-to-Client boundary. So no readingTime: (n) => `${n} min read`. Store the placeholder as a string and interpolate at the call site: t.blog.readingTime.replace('{n}', String(minutes)). Typing Dictionary = typeof en makes English the source of truth: any key you add to English but forget in Italian is a type error, not a production blank.

Step 4: Read the dictionary in Server Components, hand it to Client Components

A getDictionary function maps a locale to its object. Server Components call it directly after awaiting params. In Next 16, params is a promise.

// dictionaries/index.ts
import { en } from './en'; import { it } from './it'; import { es } from './es'
const dicts = { en, it, es }
export const getDictionary = (lang: string) => dicts[lang] ?? en

// app/[lang]/page.tsx (Server Component)
export default async function Home({ params }: { params: Promise<{ lang: string }> }) {
  const { lang } = await params
  const t = getDictionary(lang)
  return <h1>{t.nav.home}</h1>
}

Client Components cannot call an async server function, so wrap the tree in a context provider that receives the already-resolved dictionary from the layout. A useDictionary() hook then reads it anywhere below. This keeps the dictionary out of the client bundle except for the strings a given page actually renders. If you are unsure which components need to be client-side, our website architecture piece covers where we draw that line.

Step 5: Give each locale its own slugs

Italian users search for progetti, not projects. Serving /it/projects leaves ranking on the table. Instead of duplicating the route tree, keep one internal tree in English slugs and rewrite localized URLs to it in the proxy.

// config/routes.ts
export const ROUTE_SLUGS = {
  it: { projects: 'progetti', about: 'chi-siamo' },
  es: { projects: 'proyectos', about: 'nosotros' },
}
// proxy rewrites /it/progetti -> internal /it/projects, transparently

The visitor sees /it/progetti. Next.js renders the file at app/[lang]/projects/. One route tree, three URL surfaces. A translatePathname helper does the reverse for your language switcher, so a user on /it/progetti lands on /es/proyectos, not /es/progetti.

Step 6: Emit canonical and hreflang from generateMetadata

Search engines need to know the three URLs are the same page in different languages. Emit hreflang alternates and a self-referencing canonical from generateMetadata. Get this wrong and Google treats your Italian and Spanish pages as thin duplicates of the English one.

export async function generateMetadata({ params }) {
  const { lang } = await params
  return {
    alternates: {
      canonical: `https://example.com/${lang}`,
      languages: { en: '/en', it: '/it', es: '/es', 'x-default': '/en' },
    },
  }
}

The root <html lang> can stay static at the default while per-page hreflang carries the real signal, which is enough for crawlers. Making <html lang> dynamic means moving the element into [lang]/layout.tsx, a change you can defer until you need it.

How to verify it works

  • Hit / with an Accept-Language: it header and confirm the redirect lands on /it.
  • Load /it/progetti and confirm it renders the projects page in Italian, with the URL unchanged in the address bar.
  • View source on any page and check for three <link rel="alternate" hreflang> tags plus a canonical.
  • Add a key to en.ts only and run tsc. A red type error confirms the dictionaries are locked in sync.
  • Run the production build. No "Functions cannot be passed" error means your dictionaries are clean.

Common failures and how to fix them

The proxy redirect-loops. Your matcher is catching /_next or static files, so every asset gets redirected and re-requested. Tighten the config.matcher to exclude api, _next, and anything with a file extension.

Build fails with "Functions cannot be passed directly to Client Components". A dictionary value is a function, or you are passing a server helper as a prop into a client component. Convert dynamic strings to placeholders and interpolate at the call site.

The language switcher sends users to a 404. It is swapping only the locale prefix and keeping the source slug, so /it/progetti becomes /es/progetti, which does not exist. Route the switch through your reverse slug map, or prefer the hreflang alternates already in the DOM.

Italian pages rank under the English URL. Missing or wrong hreflang. Confirm every locale lists all alternates including x-default, and that each canonical points to itself, not to the English version.

When a library earns its place

The routing above needs no dependency. The honest limit is grammar. The moment your copy needs real pluralization, a library pays for itself. English has 2 plural forms, Arabic has 6, and Polish and Russian sit in between with rules that a ?: ternary cannot express cleanly. That is what ICU message syntax solves, and it is the actual reason to reach for something like next-intl, which ships around 2KB and can precompile ICU messages down to under 1KB on top of the native Intl API.

Reach for a library when any of these is true: you need ICU pluralization or gender, you hand translations to non-developers through a translation management system, or your locale count is climbing past a handful. Stay native when your locales are few, your strings are flat key-value pairs, and your team edits them in the repo. We stayed native. For the Intl object alone, which formats dates, numbers, and currencies per locale, you never need a package at all. If you are weighing the broader dependency question, our take on what changed in Next.js 16 and React 19 frames why we keep the tree small.

Sources

Photo by Ian Ward on Unsplash

Frequently asked questions

Do I need an i18n library for Next.js in 2026?
Not for a fixed, small set of locales whose translations are flat key-value strings. The App Router gives you dynamic route segments, proxy-based locale detection, and the native Intl API, which cover routing, formatting, and metadata without a dependency. You need a library when your copy requires ICU pluralization or gender, when non-developers edit translations through a management tool, or when your locale count climbs past a handful. For two to six languages edited in the repo, native is less code to own and nothing to keep updated.
What replaced middleware.ts for i18n routing in Next.js 16?
Next.js 16 renamed middleware.ts to proxy.ts and the exported function from middleware to proxy. The i18n logic is identical: detect the locale, redirect the bare root, rewrite localized slugs. The runtime is Node only, so the Edge runtime is no longer available for this file, and the old middleware.ts still works but is deprecated. Run npx @next/codemod middleware-to-proxy . to migrate an existing project mechanically.
How do I handle pluralization without an i18n library?
For simple cases, the native Intl.PluralRules API tells you which plural category a number falls into for a given locale, and you branch on that. This works well for English, Italian, and Spanish, which have two forms each. It gets painful for languages with three to six forms, such as Polish, Russian, or Arabic, where the rules are non-obvious and easy to get wrong. That is the point where ICU message syntax, and a library that implements it, stops being optional. If your locales all have simple plural rules, stay native; if one has complex rules, adopt ICU rather than hand-code it.
Can I add a fourth language later without refactoring?
Yes, if you sourced the locale list from one config array from the start. Adding a language means three things: append the code to the locales array, add its dictionary file with the same keys as English, and fill its entries in the localized slug map. No route files change, because every page already lives under the single app/[lang] segment. The real cost is translation, not code. This is the payoff of keeping English as the typed source of truth: the moment a new dictionary is missing a key, TypeScript points at it before you ship.

Related articles

Studio

Start a project.

One partner for the digital product you need to build. Faster delivery, modern tech, lower costs. One team, one invoice.