Skip to content

Next.js9 min read

Next.js i18n Guide: App Router, Routing and hreflang

Next.js internationalization with the App Router: locale routing in Proxy, server-side dictionaries, hreflang and a localized sitemap, with English and Nepali.

Next.js internationalization in the App Router works like this: you move every route under a dynamic app/[lang] segment, use Proxy (the Next.js 16 name for middleware) to redirect visitors without a locale prefix to the right one, and load translation dictionaries in Server Components. Then you add hreflang alternates in metadata and in your sitemap so Google shows the right language to the right people.

There is no single i18n switch for the App Router. That is the first thing that confuses people coming from the Pages Router. This guide builds a working English and Nepali setup step by step, following the patterns in the official docs.

Key takeaways

  • The i18n key in next.config.js is a Pages Router feature. The App Router uses a [lang] route segment instead.
  • In Next.js 16, locale detection lives in proxy.ts, which replaced middleware.ts.
  • Load dictionaries in Server Components, so translation files never reach the client bundle.
  • Use generateStaticParams in the [lang] layout to prerender every locale at build time.
  • SEO needs three things per page: a localized <html lang>, a self-referencing canonical, and alternates.languages for hreflang.

How Next.js i18n routing works in the App Router

The shape of the project looks like this:

Terminal
app/
  [lang]/
    layout.tsx
    page.tsx
    about/page.tsx
    dictionaries.ts
    dictionaries/
      en.json
      ne.json
proxy.ts

Every request ends up at a URL like /en/about or /ne/about. The lang value flows into every layout and page as a route param. Proxy makes sure a visitor who types /about gets redirected to the version they prefer.

You can also route by domain (example.com and example.com.np), but sub-paths are simpler to host and to explain to search engines, so I use them by default.

Approach Example Pros Cons
Sub-path /ne/about One domain, one deploy, easy hreflang Locale always visible in the URL
Domain example.com.np/about Strong local signal More DNS, certificates and config
Cookie or header only /about for everyone Clean URLs Bad for SEO: Google sees one language

The last row is the trap. If the same URL returns English or Nepali depending on a cookie, Googlebot (which usually crawls without a language preference) only ever indexes one version.

Step 1: Detect the locale in proxy.ts

The docs use two small packages for language negotiation:

Terminal
npm install negotiator @formatjs/intl-localematcher
npm install -D @types/negotiator

Then the Proxy file at the project root (or inside src/):

TypeScript
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'

const locales = ['en', 'ne']
const defaultLocale = 'en'

function getLocale(request: NextRequest): string {
  const saved = request.cookies.get('NEXT_LOCALE')?.value
  if (saved && locales.includes(saved)) return saved

  const headers = {
    'accept-language': request.headers.get('accept-language') ?? '',
  }
  const languages = new Negotiator({ headers })
    .languages()
    .filter((l) => l !== '*')

  return match(languages, locales, defaultLocale)
}

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl
  const hasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )
  if (hasLocale) return

  request.nextUrl.pathname = `/${getLocale(request)}${pathname}`
  return NextResponse.redirect(request.nextUrl)
}

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

Three details here come from real bugs:

  1. The wildcard filter. An empty or missing Accept-Language header makes Negotiator return *, which is not a valid locale tag for the matcher. Filtering it out lets the default locale win.
  2. The cookie check. If someone picks Nepali from a language switcher, respect that choice over the browser header. Set the cookie from the switcher.
  3. The matcher. Skip _next, API routes and files with an extension. Otherwise /logo.png gets redirected to /en/logo.png and every image breaks.

If you are upgrading from Next.js 15, the logic is the same. You rename middleware.ts to proxy.ts and the exported function from middleware to proxy.

Step 2: Load translations with server-side dictionaries

Each locale gets a JSON file:

JSON
{
  "home": {
    "title": "Hire a React developer in Kathmandu",
    "cta": "Book a call"
  }
}
JSON
{
  "home": {
    "title": "काठमाडौंमा React डेभलपर खोज्दै हुनुहुन्छ?",
    "cta": "कल बुक गर्नुहोस्"
  }
}

The loader imports them lazily and narrows the locale type:

TypeScript
// app/[lang]/dictionaries.ts
import 'server-only'

const dictionaries = {
  en: () => import('./dictionaries/en.json').then((m) => m.default),
  ne: () => import('./dictionaries/ne.json').then((m) => m.default),
}

export type Locale = keyof typeof dictionaries

export const locales = Object.keys(dictionaries) as Locale[]

export const hasLocale = (locale: string): locale is Locale =>
  locale in dictionaries

export const getDictionary = async (locale: Locale) => dictionaries[locale]()

import 'server-only' makes the build fail if a Client Component imports this file by accident. Because pages and layouts are Server Components by default, the JSON stays on the server and only the rendered HTML goes to the browser. That matters when your Nepali dictionary grows to a few hundred keys.

Using it in a page:

TSX
// app/[lang]/page.tsx
import { notFound } from 'next/navigation'
import { getDictionary, hasLocale } from './dictionaries'

export default async function Home({ params }: PageProps<'/[lang]'>) {
  const { lang } = await params
  if (!hasLocale(lang)) notFound()

  const dict = await getDictionary(lang)

  return (
    <main>
      <h1>{dict.home.title}</h1>
      <a href={`/${lang}/contact`}>{dict.home.cta}</a>
    </main>
  )
}

Note that params is a Promise. That changed in Next.js 15, and Next.js 16 removed the temporary synchronous access, so always await it. PageProps and LayoutProps are global type helpers generated by Next.js, so you do not import them.

Recent Next.js 16 docs also describe next/root-params, which lets server code read lang directly instead of passing it through props. It is handy in shared data utilities, but it does not work in Client Components, Server Actions or Route Handlers, so check the docs for your exact version before relying on it.

Step 3: Static rendering and the html lang attribute

Put the root layout inside [lang] so you can set <html lang> per locale, and prerender both locales at build time:

TSX
// app/[lang]/layout.tsx
import { notFound } from 'next/navigation'
import { hasLocale, locales } from './dictionaries'

export async function generateStaticParams() {
  return locales.map((lang) => ({ lang }))
}

export default async function RootLayout({
  children,
  params,
}: LayoutProps<'/[lang]'>) {
  const { lang } = await params
  if (!hasLocale(lang)) notFound()

  return (
    <html lang={lang}>
      <body>{children}</body>
    </html>
  )
}

The lang attribute is not decoration. Screen readers use it to pick a voice, browsers use it for hyphenation and font selection, and it is one of the signals search engines read.

Fonts for Devanagari

If your Latin font has no Devanagari glyphs, the browser falls back to a system font for Nepali text, which looks inconsistent and can shift layout. next/font lets you load a matching subset:

TSX
import { Noto_Sans_Devanagari } from 'next/font/google'

const devanagari = Noto_Sans_Devanagari({
  subsets: ['devanagari'],
  display: 'swap',
})

Apply devanagari.className only when lang === 'ne', so English visitors never download it. I go deeper on this in my post on Next.js image and font optimization.

Step 4: hreflang tags and canonical URLs in Next.js

Search engines need to know that /en/about and /ne/about are the same page in two languages. That is what hreflang does. In Next.js you return it from metadata:

TSX
// app/[lang]/about/page.tsx
import type { Metadata } from 'next'

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

  return {
    title: lang === 'ne' ? 'हाम्रो बारेमा' : 'About us',
    alternates: {
      canonical: `/${lang}/about`,
      languages: {
        en: '/en/about',
        ne: '/ne/about',
        'x-default': '/en/about',
      },
    },
  }
}

Set metadataBase: new URL('https://example.com') in the root layout so these relative paths become absolute URLs. Without it, relative URL fields cause a build error.

Rules that are easy to get wrong:

  • The canonical must point to the page itself, not to the English version. Pointing every locale at /en/... tells Google to drop the Nepali pages.
  • hreflang must be reciprocal. If the English page lists the Nepali one, the Nepali page must list the English one.
  • Use valid codes. Nepali is ne, and Nepali as used in Nepal is ne-NP. Do not invent codes like np.
  • x-default tells Google which version to show when no language matches.

Add the same alternates to app/sitemap.ts. Localized sitemaps have been supported since Next.js 14.2:

TypeScript
// app/sitemap.ts
import type { MetadataRoute } from 'next'

const base = 'https://example.com'
const pages = ['', '/about', '/contact']

export default function sitemap(): MetadataRoute.Sitemap {
  return pages.map((path) => ({
    url: `${base}/en${path}`,
    lastModified: new Date(),
    alternates: {
      languages: {
        en: `${base}/en${path}`,
        ne: `${base}/ne${path}`,
      },
    },
  }))
}

For the wider metadata picture (Open Graph, JSON-LD, robots), see my Next.js SEO guide. Google's own guidance on localized versions is the reference for hreflang rules.

Formatting dates, numbers and currency

Translation files cover sentences. Dates, numbers and prices should come from Intl, which is built into Node and every modern browser:

TypeScript
const price = new Intl.NumberFormat('ne-NP', {
  style: 'currency',
  currency: 'NPR',
}).format(150000)

const date = new Intl.DateTimeFormat(lang === 'ne' ? 'ne-NP' : 'en-US', {
  dateStyle: 'long',
}).format(new Date())

Depending on the locale data available, ne-NP may print Devanagari digits. If your design needs Latin digits, pass numberingSystem: 'latn'. Also note that en-IN groups large numbers as 1,50,000, which South Asian users often expect, while en-US prints 150,000.

One gotcha: format on the server and on the client with the same locale and time zone, or you will get hydration mismatch warnings when the output differs.

Client Components and the language switcher

Client Components cannot call getDictionary because it is server-only. Pass the strings they need as props from the parent Server Component:

TSX
'use client'

import { usePathname } from 'next/navigation'
import Link from 'next/link'

export function LanguageSwitcher({ labels }: { labels: { en: string; ne: string } }) {
  const pathname = usePathname()
  const rest = pathname.split('/').slice(2).join('/')

  return (
    <nav aria-label="Language">
      <Link
        href={`/en/${rest}`}
        hrefLang="en"
        onClick={() => (document.cookie = 'NEXT_LOCALE=en; path=/; max-age=31536000')}
      >
        {labels.en}
      </Link>
      <Link
        href={`/ne/${rest}`}
        hrefLang="ne"
        onClick={() => (document.cookie = 'NEXT_LOCALE=ne; path=/; max-age=31536000')}
      >
        {labels.ne}
      </Link>
    </nav>
  )
}

The links are real <a> elements with href, so crawlers can follow them to the other language. A <select> that calls router.push looks the same to users but hides those links from search engines.

When to use next-intl instead

The built-in pattern is fine for a marketing site or portfolio with two languages. A library earns its place when you need:

  • ICU messages with plurals and gender ({count, plural, one {# item} other {# items}}).
  • Translated pathnames, such as /ne/hamro-barema instead of /ne/about.
  • Many Client Components that need translations without prop drilling.
  • Type-safe message keys across a large team.

next-intl is the most common choice for the App Router, and the Next.js docs also list Lingui, Paraglide, next-international and others. Whichever you pick, the SEO rules above stay the same.

Common Next.js i18n mistakes

  • Using the i18n config key with the App Router. It does nothing there.
  • Redirecting based on IP country. A Nepali speaker in Sydney gets the wrong language, and Googlebot crawling from the US never sees your Nepali pages.
  • Forgetting the matcher, so static files get redirected.
  • Hard-coding /en in <Link> components instead of using the current lang.
  • Canonical tags that all point to English.
  • Machine translating and never reviewing. Thin, awkward translations do not rank, and native readers notice immediately.

FAQ

Does the Next.js App Router have built-in i18n?

Not in the way the Pages Router did. The i18n key in next.config.js only applies to the Pages Router. In the App Router you put routes under app/[lang], detect the locale in Proxy, and load translations yourself or with a library like next-intl.

How do I add hreflang tags in Next.js?

Return alternates.languages from the metadata object or generateMetadata on each page, with one URL per locale. Set metadataBase in the root layout so relative paths resolve to absolute URLs, and add the same alternates to your sitemap.

Should I use next-intl or build i18n myself?

For two locales and simple strings, the built-in pattern from the Next.js docs is enough. Use next-intl or a similar library when you need plurals, ICU messages, translated pathnames, or translations inside many Client Components.

What is middleware called in Next.js 16?

Next.js 16 renamed the middleware file convention to Proxy. The file is proxy.ts and it exports a function named proxy. The locale redirect logic works the same way.

Wrapping up

App Router i18n is mostly plain routing: a [lang] segment, a Proxy redirect, and dictionaries loaded on the server. Get the SEO pieces right from the first commit (html lang, self-referencing canonicals, reciprocal hreflang and a localized sitemap) and adding a third language later is just another JSON file. If you are still moving off the Pages Router, start with my App Router migration guide.

  • #nextjs
  • #i18n
  • #seo
  • #app-router

Next.js7 min read

Next.js SEO Guide: Metadata, Sitemap, JSON-LD, OG

A practical Next.js SEO guide for Next.js 16: Metadata API, generateMetadata, canonical URLs, sitemap.ts, robots.ts, JSON-LD and dynamic OG images.

Read article