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
i18nkey innext.config.jsis a Pages Router feature. The App Router uses a[lang]route segment instead. - In Next.js 16, locale detection lives in
proxy.ts, which replacedmiddleware.ts. - Load dictionaries in Server Components, so translation files never reach the client bundle.
- Use
generateStaticParamsin the[lang]layout to prerender every locale at build time. - SEO needs three things per page: a localized
<html lang>, a self-referencing canonical, andalternates.languagesfor hreflang.
How Next.js i18n routing works in the App Router
The shape of the project looks like this:
app/
[lang]/
layout.tsx
page.tsx
about/page.tsx
dictionaries.ts
dictionaries/
en.json
ne.json
proxy.tsEvery 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:
npm install negotiator @formatjs/intl-localematcher
npm install -D @types/negotiatorThen the Proxy file at the project root (or inside src/):
// 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:
- The wildcard filter. An empty or missing
Accept-Languageheader makes Negotiator return*, which is not a valid locale tag for the matcher. Filtering it out lets the default locale win. - The cookie check. If someone picks Nepali from a language switcher, respect that choice over the browser header. Set the cookie from the switcher.
- The matcher. Skip
_next, API routes and files with an extension. Otherwise/logo.pnggets redirected to/en/logo.pngand 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:
{
"home": {
"title": "Hire a React developer in Kathmandu",
"cta": "Book a call"
}
}{
"home": {
"title": "काठमाडौंमा React डेभलपर खोज्दै हुनुहुन्छ?",
"cta": "कल बुक गर्नुहोस्"
}
}The loader imports them lazily and narrows the locale type:
// 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:
// 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:
// 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:
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:
// 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 isne-NP. Do not invent codes likenp. x-defaulttells 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:
// 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:
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:
'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-baremainstead 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
i18nconfig 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
/enin<Link>components instead of using the currentlang. - 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
Keep reading
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 articleNext.js8 min read
Next.js App Router vs Pages Router: Migration Guide
Next.js App Router vs Pages Router explained: the real differences, when to migrate, and a step-by-step migration guide updated for Next.js 16.4.
Read articleNext.js8 min read
Next.js Authentication Guide: Sessions, proxy.ts, DAL
Next.js authentication guide for Next.js 16: pick an auth library, manage sessions, protect routes with proxy.ts and secure Server Components and Actions.
Read article