
Tutorials · · 7 min read
Building a Multilingual Next.js Application
- Next.js
- Tutorial
- TypeScript
- Nepal
Adding a second language to a Next.js app is not hard. Adding it in a way that doesn't leak English strings, break your fonts, or confuse Google takes a bit more thought. Here's the setup I use for English and Nepali in Next.js 16.
I build a lot of things for people in Nepal, and "just ship it in English" stops working the moment your user is a restaurant owner in Pokhara or a farmer checking crop advice on a cheap Android phone. So multilingual support has become a default question on my projects instead of a nice-to-have.
There are good libraries for this (next-intl is the one I'd reach for on a big app). But the App Router gives you enough primitives to do it yourself with very little code, and doing it by hand once teaches you what those libraries are actually doing. That's what this post walks through.
What changed in Next.js 16
If you learned i18n on older Next.js versions, three things will trip you up:
- Middleware is now Proxy. The file is
proxy.tsat the project root and it exports a function calledproxy. Same idea, new name. - `params` is a Promise. In layouts and pages you
await paramsbefore readinglang. - `PageProps` and `LayoutProps` are global type helpers. You don't import them. Next generates them from your folder structure, so
PageProps<'/[lang]'>gives you a correctly typedparams.
The old i18n key in next.config was a Pages Router feature. In the App Router, locale routing is just a dynamic segment plus a bit of Proxy logic. Once that clicks, everything else is ordinary React.
The folder structure
Every route lives under a [lang] segment, including the root layout:
app/
[lang]/
layout.tsx
page.tsx
menu/
page.tsx
dictionaries.ts
dictionaries/
en.json
ne.json
components/
LanguageSwitcher.tsx
proxy.tsSo /en/menu and /ne/menu render the same page with a different lang param. I prefer sub-paths over domains or cookies-only approaches because the URL is shareable, cacheable, and indexable. If someone sends a Nepali link on Viber, the person who opens it sees Nepali.
Dictionaries
The translations are plain JSON. Keep the keys nested by feature so the files stay readable as they grow:
{
"nav": { "home": "Home", "menu": "Menu" },
"menu": {
"title": "Today's menu",
"addToOrder": "Add to order",
"itemsInCart": "{count} items in your order"
}
}{
"nav": { "home": "गृहपृष्ठ", "menu": "मेनु" },
"menu": {
"title": "आजको मेनु",
"addToOrder": "अर्डरमा थप्नुहोस्",
"itemsInCart": "तपाईंको अर्डरमा {count} वटा परिकार"
}
}Then a loader that only ever runs on the server:
// 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 defaultLocale: Locale = 'en'
export const hasLocale = (value: string): value is Locale =>
value in dictionaries
export const getDictionary = async (locale: Locale) => dictionaries[locale]()
export type Dictionary = Awaited<ReturnType<typeof getDictionary>>Two details matter here. The dynamic imports mean each locale is its own chunk, and because pages are Server Components, none of this JSON ships to the browser unless you pass it to a client component yourself. And hasLocale is a type guard: lang arrives as a string, and the guard narrows it to 'en' | 'ne' so TypeScript stops complaining.
Redirecting with proxy.ts
When someone visits /menu with no locale, I want to send them to the right one. The order I check is: a cookie (they picked a language before), then the Accept-Language header, then the default.
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
const locales = ['en', 'ne']
const defaultLocale = 'en'
function getLocale(request: NextRequest) {
const saved = request.cookies.get('NEXT_LOCALE')?.value
if (saved && locales.includes(saved)) return saved
const header = request.headers.get('accept-language') ?? ''
const preferred = header
.split(',')
.map((part) => part.split(';')[0].trim().slice(0, 2).toLowerCase())
return preferred.find((code) => locales.includes(code)) ?? defaultLocale
}
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const hasLocale = locales.some(
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)
)
if (hasLocale) return
request.nextUrl.pathname = `/${getLocale(request)}${pathname}`
return NextResponse.redirect(request.nextUrl)
}
export const config = {
matcher: ['/((?!_next|api|.*\\..*).*)'],
}The matcher skips _next, API routes, and anything with a file extension, so your images, manifest.webmanifest and service worker don't get redirected to /en/sw.js. I learned that one the hard way after turning my portfolio into a PWA.
My header parsing is deliberately simple. If you support regional variants like en-GB vs en-US, use @formatjs/intl-localematcher with negotiator instead, which is what the official guide shows.
The layout and pages
The root layout sets <html lang>, pre-renders both locales, and loads a font that actually covers Devanagari:
// app/[lang]/layout.tsx
import { notFound } from 'next/navigation'
import { Inter, Noto_Sans_Devanagari } from 'next/font/google'
import { hasLocale, locales } from './dictionaries'
const inter = Inter({ subsets: ['latin'], variable: '--font-latin' })
const devanagari = Noto_Sans_Devanagari({
subsets: ['devanagari'],
variable: '--font-devanagari',
})
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} className={`${inter.variable} ${devanagari.variable}`}>
<body>{children}</body>
</html>
)
}In Tailwind CSS v4 I set the font stack per language with a :lang(ne) rule, so Nepali text gets Noto Sans Devanagari and slightly more generous line height. Devanagari has tall ascenders and the matra sits above the line, so the leading-tight you tuned for English headings will clip or crowd it. Check every heading in both languages before you call it done.
A page then looks like this:
// app/[lang]/menu/page.tsx
import { notFound } from 'next/navigation'
import { getDictionary, hasLocale } from '../dictionaries'
import { AddToOrderButton } from '@/app/components/AddToOrderButton'
export default async function MenuPage({ params }: PageProps<'/[lang]/menu'>) {
const { lang } = await params
if (!hasLocale(lang)) notFound()
const dict = await getDictionary(lang)
return (
<main>
<h1>{dict.menu.title}</h1>
<AddToOrderButton label={dict.menu.addToOrder} />
</main>
)
}Notice the client component only receives the one string it needs. I don't pass the whole dictionary down, because anything you hand to a client component gets serialized into the page payload.
If you're tired of threading lang through every function, the installed Next.js docs also describe next/root-params, which lets server code read the root lang segment directly. Check that it's available in your exact version before you build around it.
A language switcher that keeps your place
The switcher should swap the first path segment, not dump you back on the home page. It's one of the few places that needs a client component:
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
const labels = { en: 'English', ne: 'नेपाली' } as const
export function LanguageSwitcher({ current }: { current: keyof typeof labels }) {
const pathname = usePathname()
return (
<nav aria-label="Language">
{Object.entries(labels).map(([code, label]) => {
const href = pathname.replace(/^\/[^/]+/, `/${code}`)
return (
<Link
key={code}
href={href}
hrefLang={code}
aria-current={code === current ? 'true' : undefined}
onClick={() => {
document.cookie = `NEXT_LOCALE=${code}; path=/; max-age=31536000`
}}
>
{label}
</Link>
)
})}
</nav>
)
}Label each language in its own script. A Nepali speaker scanning for "Nepali" in English is a small but real bit of friction.
SEO: tell Google the pages are siblings
Without hreflang, search engines may treat /en/menu and /ne/menu as duplicate content. generateMetadata handles this with alternates:
export async function generateMetadata({ params }: PageProps<'/[lang]/menu'>) {
const { lang } = await params
return {
alternates: {
canonical: `/${lang}/menu`,
languages: { en: '/en/menu', ne: '/ne/menu' },
},
}
}Set metadataBase in the root layout so these resolve to absolute URLs.
The parts that aren't strings
Translation files are maybe half the work. The rest:
- Numbers.
new Intl.NumberFormat('ne-NP').format(1250)gives you Devanagari digits. Decide per screen whether you want them; for prices on a POS, some owners prefer Latin digits even in a Nepali UI. - Currency. Format NPR with
Intl.NumberFormattoo, rather than gluing "Rs." onto a number by hand. - Dates.
Intl.DateTimeFormathandles month names, but it does not give you the Bikram Sambat calendar. If your users think in BS dates, you need a conversion library and you should test it against a real calendar. - Plurals and interpolation. My
{count}placeholder is a simple replace. Once you need real plural rules, that's the point where a library like next-intl starts paying for itself. - Length. Nepali strings are often longer than English ones. Buttons with fixed widths will break.
What I'd tell you
Start with the URL structure and the server-only dictionary loader; everything else hangs off those. Keep client components dumb and feed them single strings. Test with real Nepali copy from day one, not lorem ipsum, because the bugs are almost always about layout and fonts, not routing. And if you find yourself writing your own plural engine, stop and install a library. I list the rest of what I reach for in my tech stack for 2026.
The official internationalization guide is short and worth reading alongside this. It's the source of truth when the APIs move again, and with Next.js, they will.
