Skip to main content
Web app skills · Lesson 2 of 11 · 14 min

next-intl Routing in Next.js: i18n for Beginners

Learn i18n routing with next-intl in Next.js: locale prefix, middleware, getTranslations vs useTranslations, localized Link, static params and hreflang.

By Matthews WongReviewed on

Key takeaway

next-intl adds locale-aware routing to the Next.js App Router. Every URL starts with a locale such as /en or /id, a middleware negotiates and redirects to it, a [locale] folder passes it to every page, and one request config loads that locale's messages. This lesson follows one request through this site's real files.

You will be able to

  • Trace a request from a bare URL to a translated page, file by file.
  • Pick getTranslations or useTranslations, and the right Link, router and static params.
  • Emit hreflang that search engines accept, once, from one place.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

One request through next-intl

A bare path is redirected by the middleware to a locale prefix, the locale layout validates it, request.ts loads the messages, the page renders translated HTML and alternates emit hreflang.

Step 1 of 7. One request crosses five places: middleware, the locale layout, the request config, a messages file and the page. Each has one job, and the URL carries the locale.

Keys: ← → step · Home / End · K play or pause

  1. One request crosses five places: middleware, the locale layout, the request config, a messages file and the page. Each has one job, and the URL carries the locale.

  2. A visitor opens the bare path /about with an Indonesian browser. The locale prefix is missing, so the site cannot yet know which language to serve.

    Predict the next step The path has no locale prefix. What does the middleware do with it? (Serves English with no redirect · Redirects to a prefixed URL)
  3. next-intl negotiates a locale from the prefix, the accept-language header or the default, then redirects the bare path to /id/about. This site turned the locale cookie off.

    localePrefix: "always",Predict the next step The layout receives the locale fr. What does it do? (Falls back to English · Answers with a 404)
  4. The layout checks the locale. id passes; anything else calls notFound. Only en and id are prerendered, and dynamicParams false turns other paths into 404. setRequestLocale keeps pages static.

  5. getRequestConfig in request.ts resolves the locale and imports the messages for it. Here that is id.json, whose key tree must match en.json exactly.

  6. The async page awaits getTranslations with the locale and a namespace, and renders Indonesian text into HTML with lang set to id. A client component would call useTranslations.

  7. The page metadata emits hreflang for en, id and x-default as absolute URLs. The English page lists the same set, so each version points at the other.

How does locale routing work in next-intl?

You describe your locales once with defineRouting: the list of locales, a default locale, and how the prefix behaves. With localePrefix set to always, which is also the default, every pathname starts with the locale, so the English About page is /en/about. This site pins that choice in i18n/routing.ts so nobody changes it by accident.

Read more (2)

A middleware turns that config into behavior. According to the next-intl docs it handles locale negotiation, applies redirects and rewrites, and provides alternate links for search engines. Negotiation reads the locale prefix first, then a cookie, then the accept-language header, then the default locale. Your app files then move under a top-level [locale] folder, so every layout and page receives the locale as a param.

Mind the matcher. The middleware here skips api, _next and any path containing a dot, so static files in public are served untouched. This site is on Next.js 15, so the file is middleware.ts. The next-intl docs note that proxy.ts was called middleware.ts up until Next.js 16.

getTranslations or useTranslations: which one where?

Inside a component that is not async, call the useTranslations hook. next-intl gives a shared component the implementation that suits the environment it runs in, so the same hook works in a Server and in a Client Component. Hooks cannot be called from async components. An async Server Component, such as this site's About page, awaits getTranslations instead, with the locale and a namespace.

Read more (1)

A Client Component only sees the messages you hand it. Messages passed to NextIntlClientProvider are emitted into the page during the streaming render, so sending everything is easy but costs payload, and the docs suggest being selective and measuring first. This site's root layout strips blog article bodies and game copy before the provider, and the game page re-provides its own namespace.

Why import Link and useRouter from @/i18n/navigation?

createNavigation(routing), called once, returns lightweight wrappers around the Next.js navigation APIs: Link, redirect, usePathname, useRouter and getPathname. They handle the user locale and pathnames behind the scenes. Link localizes the pathname, useRouter localizes programmatic navigation, and usePathname returns the path without the locale prefix, which is why SiteChrome can compare it to /play.

Read more (1)

The bare imports lose that. A link written with next/link points at /about, the unprefixed path, which this site can only serve after a redirect. Redirect also needs a locale. getPathname builds a path for a given locale, handy for sitemaps and canonical or hreflang links.

How do generateStaticParams and setRequestLocale keep pages static?

Because [locale] is a dynamic segment, generateStaticParams must list the locales so each route renders at build time. The locale layout returns one entry per locale. Next.js also lets dynamicParams control what happens for a param you did not list: set to false, unlisted paths 404. This site sets it on the locale layout, so /fr/about never renders.

Read more (1)

The next-intl docs warn that it currently opts into dynamic rendering when APIs like useTranslations are used in Server Components. The workaround is setRequestLocale, called in every layout and page before any next-intl function. The docs now call it a legacy API, and this site still uses it on Next.js 15.

How do you emit hreflang that Google accepts?

Google's rules are short. Each language version must list itself and all the others, the annotations must be reciprocal or they are ignored, and alternate URLs must be fully qualified, including https. An x-default entry is the fallback for visitors whose language matches nothing. You can use HTML tags, HTTP headers or a sitemap, and using all three brings no extra benefit.

Read more (1)

The Next.js metadata field alternates.languages renders those link tags in head. This site builds the same three entries for every page in one function, buildLanguageAlternates, and turns next-intl's own header off with alternateLinks false. The reason is in the code comment: the middleware's header pointed x-default at an unprefixed URL that redirects, which disagreed with the HTML.

How do you keep en.json and id.json in step?

A missing key in one language is a runtime error for that language, so the rule here is blunt: both files must have exactly the same key tree. For blog posts the merge script compares the two trees before it writes anything and fails on a difference. For your own project, a small test that walks both trees is the cheapest guard you can add.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

i18n/routing.ts:6-23
export const routing = defineRouting({
  locales,
  defaultLocale: "en",
  localePrefix: "always",
  // Locale is always in the URL path, so the NEXT_LOCALE cookie is redundant.
  // Disabling it removes the per-response Set-Cookie that blocks Cloudflare
  // from edge-caching the static HTML.
  localeCookie: false,

  // Every page already emits hreflang in <head> via `alternates.languages`
  // (see buildLanguageAlternates in lib/seo.ts). Leaving this on made the
  // middleware emit a SECOND set as an HTTP Link header whose x-default
  // pointed at the unprefixed "/blog/<slug>/" — a URL that 307-redirects,
  // and a different target from the "/en/blog/<slug>/" in the HTML. Google
  // may discard hreflang entirely when the annotations conflict, which on a
  // bilingual site risks losing the en/id pairing.
  alternateLinks: false,
});

The whole routing contract. localePrefix always means /en/... and /id/..., never a bare path. The cookie is off because the locale is already in the URL, and a per-response Set-Cookie blocks edge caching. alternateLinks is off because the HTML already carries hreflang.

i18n/request.ts:4-16
export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;

  // Ensure that a valid locale is used
  if (!locale || !routing.locales.includes(locale as any)) {
    locale = routing.defaultLocale;
  }

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

Per-request config. requestLocale is a promise, so it is awaited, and an invalid value falls back to the default. The dynamic import loads only the messages for that locale. The current next-intl docs mark requestLocale as legacy in favor of a locale argument, and this site has not migrated.

i18n/navigation.ts:1-5
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';

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

The only place these five wrappers are created. Every Link, useRouter and usePathname on the site is imported from here, never from next/link or next/navigation, so each carries the locale.

app/[locale]/(pages)/about/page.tsx:29-33
  const { locale } = await params;
  setRequestLocale(locale);
  const pageLocale = asSiteLocale(locale);

  const t = await getTranslations({ locale, namespace: "aboutPage" });

An async Server Component page. It sets the request locale first, then awaits getTranslations with a namespace. Hooks are not allowed here. The Header, a Client Component, calls the hook version, useTranslations with the nav namespace.

app/[locale]/layout.tsx:167-169
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

One entry per locale, so the [locale] segment is prerendered for en and id. Just below it in the file, export const dynamicParams = false turns every other locale into a 404 before anything renders.

lib/seo.ts:23-29
export function buildLanguageAlternates(path = ""): Record<string, string> {
  return {
    en: buildCanonical("en", path),
    id: buildCanonical("id", path),
    "x-default": buildCanonical("en", path),
  };
}

The single source of hreflang. Three absolute URLs for every page, and x-default points at English. Pages pass it to alternates.languages, which Next.js renders as link tags in head.

What breaks

Mistakes that cost real time

  • Link from next/link drops the locale

    A link imported from next/link to /about works only because the middleware redirects the bare path, and useRouter from next/navigation does the same. It costs a redirect and can lose the visitor's language. Import Link, useRouter and usePathname from the file that calls createNavigation.

  • A key exists in en.json but not in id.json

    The Indonesian page throws on the missing key while the English page works, so you only notice after switching language. Keep both trees identical and check them in a test, because next-intl throws on a missing key.

  • Two hreflang sets that disagree

    next-intl's middleware adds hreflang as a Link header by default, and metadata adds it in head. Here the header's x-default pointed at a URL that redirects, a different target from the HTML. Conflicting annotations risk being discarded, so emit one set only.

  • A request for a missing file turns into a 500

    The middleware skips paths with a dot, so a missing image path can reach the router and match [locale] with a nonsense locale. Rendering that let next-intl read request headers during a static render, and Next.js answered 500. Rejecting unknown params up front with dynamicParams false makes it a clean 404.

getTranslations or useTranslations
StyleAwaited functionHook
Async Server ComponentUse thisNot allowed
Client ComponentNot usedUse this
Needs messages in the client providerNoYes, in a Client Component
Try it yourself

Do it on your own machine

You need a Next.js App Router project and next-intl installed. You will add a second locale and watch the middleware redirect a bare path.

  1. Describe the locales once, with the prefix always present.

    // i18n/routing.ts
    import { defineRouting } from "next-intl/routing";
    
    export const routing = defineRouting({
      locales: ["en", "id"],
      defaultLocale: "en",
      localePrefix: "always",
    });
  2. Create the wrappers once, and import them everywhere instead of next/link.

    // i18n/navigation.ts
    import { createNavigation } from "next-intl/navigation";
    import { routing } from "./routing";
    
    export const { Link, redirect, usePathname, useRouter, getPathname } =
      createNavigation(routing);
  3. Add the middleware with a matcher that skips api, _next and files with a dot.

    // middleware.ts
    import createMiddleware from "next-intl/middleware";
    import { routing } from "./i18n/routing";
    
    export default createMiddleware(routing);
    
    export const config = {
      matcher: ["/((?!api|_next|.*\\..*).*)"],
    };
  4. Move your pages under app/[locale]. In the layout, validate the locale, list both locales and refuse the rest.

    // app/[locale]/layout.tsx
    import { notFound } from "next/navigation";
    import { routing } from "@/i18n/routing";
    
    export const dynamicParams = false;
    
    export function generateStaticParams() {
      return routing.locales.map((locale) => ({ locale }));
    }
  5. With the dev server running, ask for the bare path and read the redirect. Then ask for a locale you never listed.

    curl -sI -H 'Accept-Language: id' http://localhost:3000/about | head -n 3
    curl -sI http://localhost:3000/fr/about | head -n 1
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.Why does this site set alternateLinks to false in its routing config?
  2. 2.Your page is an async Server Component. How do you read its translations?
  3. 3.A teammate writes a link with next/link to /about inside a client component. What is the problem?
Think it through

Answer in your head, then check

  1. 1.What does localePrefix always change about the URLs?Show the answer

    Answer:Every pathname starts with the locale, so the About page is /en/about and /id/about, with no unprefixed version to serve. A bare path is redirected to a prefixed one.

  2. 2.Name two things that stop /fr/about from rendering.Show the answer

    Answer:generateStaticParams lists only en and id, and dynamicParams false turns every unlisted locale into a 404. The layout also calls notFound when the locale is not in routing.locales.

  3. 3.What is x-default for in hreflang?Show the answer

    Answer:It is the fallback for visitors whose language or region matches none of the listed versions. This site points it at the English page.

Go deeper

Related deep dives on this site