Next.js 16: next-i18next with App Router (2026)
A practical guide to next-i18next in the Next.js App Router
(8 minute read)
Available Next.js i18n Tutorials:
Not sure which library is right for you? Learn the differences
next-i18next for the App Router is Here
Until recently, next-i18next was intended solely for the Pages Router. If you were using the App Router, the i18next maintainers recommended using react-i18next directly instead.
That changed this year with next-i18next v16, which added App Router support, including Next.js 16’s proxy.ts convention.
If you prefer using react-i18next directly, that is still a totally solid option. We also have a tutorial for that right here.
In this tutorial we’re going to set up a Next.js 16 app with locale-prefixed routes and connect next-i18next in the App Router for a simple site with home, about, and contact pages.
Here’s what our project looks like. It’s a fictional marketing studio site, and right now it has no i18n at all, just hardcoded text everywhere:

Install next-i18next
Let’s install the packages we need:
$ npm install next-i18next i18next react-i18next
That gives us the App Router integration from next-i18next, the underlying i18next library, and the React bindings used on the client side.
Configuration
Next, create an i18n config file at the root of your project called i18n.config.ts:
i18n.config.tsimport type { I18nConfig } from 'next-i18next/proxy'
const i18nConfig: I18nConfig = {
supportedLngs: ['en', 'de', 'sv'],
fallbackLng: 'en',
defaultNS: 'common',
ns: ['common', 'home', 'about', 'contact'],
hideDefaultLocale: true
}
export default i18nConfigsupportedLngs is the list of languages our app supports.
fallbackLng is the language we fall back to if we do not support the visitor’s preferred language.
defaultNS is the namespace i18next will use by default. It is conventional to call this namespace common.
ns is the list of namespaces we want available in the app. In this tutorial, common is for shared UI text, and home, about, and contact map to our three pages.
The hideDefaultLocale: true option keeps the default language at routes like /about while the other languages use routes like /de/about.
proxy.ts
In Next.js 16, the old middleware.ts convention was replaced with proxy.ts. So let’s add that at the project root:
proxy.tsimport { createProxy } from 'next-i18next/proxy'
import i18nConfig from './i18n.config'
export const proxy = createProxy(i18nConfig)
export const config = {
matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js|site.webmanifest).*)']
}This file handles the language routing for us.
It detects the language using cookie, then Accept-Language, then your fallback locale, and redirects users to the correct locale pathname.
And it adds an x-i18next-current-language header so Server Components can know what language they’re rendering.
Move Pages Under [lng]
Because the locale is part of the URL, all of our App Router pages and layouts need to live under a dynamic segment named [lng]:
└── app
└── [lng]
├── layout.tsx
├── page.tsx
├── about
│ └── page.tsx
└── contact
└── page.tsxIf you already have routes in your app, just move them underneath app/[lng]. In this example, that means our home, about, and contact pages.
Root Layout
Now let’s wire up the root layout. This is where we initialize the server-side i18n setup, generate static params, and pass the loaded resources into the client provider:
app/[lng]/layout.tsximport { initServerI18next, getT, getResources, generateI18nStaticParams } from 'next-i18next/server'
import { I18nProvider } from 'next-i18next/client'
import i18nConfig from '../../i18n.config'
initServerI18next(i18nConfig)
export async function generateStaticParams() {
return generateI18nStaticParams()
}
export default async function RootLayout({
children,
params
}: {
children: React.ReactNode
params: Promise<{ lng: string }>
}) {
const { lng } = await params
const { i18n } = await getT()
if (process.env.NODE_ENV === 'development') {
await i18n.reloadResources(i18nConfig.supportedLngs, i18nConfig.ns)
}
const resources = getResources(i18n)
return (
<html lang={lng}>
<body>
<I18nProvider fallbackLanguage={i18nConfig.fallbackLng} language={lng} resources={resources}>
{children}
</I18nProvider>
</body>
</html>
)
}The key pieces are:
initServerI18next(i18nConfig) gets called once at module scope.
generateI18nStaticParams() creates params for each supported language.
getResources(i18n) serializes loaded translations so Client Components can hydrate properly.
Reloading translations in development
One small thing to know here: in development, translation JSON updates will not show up until you restart the server.
To avoid that, we are calling i18n.reloadResources in layout.tsx when NODE_ENV is development.
That way, when our locale files change during development, the app can pick them up without needing a server restart.
Until the library gets a smoother update for this, this is the simplest way to handle it.
Language Switching
Since we’re hiding the default locale in the URL, our switcher needs to keep the default language unprefixed and prefix the other languages:
components/LanguageSwitcher.tsx'use client'
import { usePathname, useRouter } from 'next/navigation'
import { useT } from 'next-i18next/client'
import i18nConfig from '../../i18n.config'
export default function LanguageSwitcher() {
const pathname = usePathname()
const router = useRouter()
const { i18n } = useT()
const { supportedLngs } = i18nConfig
const currentLng = i18n.language
const switchLocale = (locale: string) => {
const segments = pathname.split('/').filter(Boolean)
const pathWithoutLocale = supportedLngs.includes(segments[0])
? segments.slice(1)
: segments
const nextPath =
locale === i18nConfig.fallbackLng
? `/${pathWithoutLocale.join('/')}`
: `/${locale}/${pathWithoutLocale.join('/')}`
router.push(nextPath === '/' ? '/' : nextPath.replace(/\/$/, ''))
}
return (
<div className="language-switcher">
{supportedLngs.map((lng) => (
<button
key={lng}
onClick={() => switchLocale(lng)}
type="button"
className={`language-button${currentLng === lng ? ' active' : ''}`}
>
{lng}
</button>
))}
</div>
)
}So if we’re on /about and click de, we go to /de/about. If we click sv, we go to /sv/about. If we switch back to English, we go right back to /about.
Rendering Translations: getT vs useT
Server Components
Using translations in a Server Component is very straightforward. We just call getT() and grab the namespace we need:
app/[lng]/page.tsximport { getT } from 'next-i18next/server'
export default async function Home() {
const { t } = await getT('home')
return (
<main>
<h1>{t('title')}</h1>
<p>{t('subtitle')}</p>
</main>
)
}
export async function generateMetadata() {
const { t } = await getT('home')
return {
title: t('meta_title')
}
}Client Components
For Client Components, next-i18next gives us a useT() hook:
components/Counter.tsx'use client'
import { useT } from 'next-i18next/client'
export default function Counter() {
const { t } = useT('home')
return <button>{t('click_me')}</button>
}Replacing Hardcoded Text
Up above, we kept the translation examples intentionally small. But our actual home page is not small. Right now it still looks like this:
app/[lng]/page.tsximport type { Metadata } from 'next';
import Link from 'next/link';
export const metadata: Metadata = {
title: 'Creative Brand Systems For Modern Teams'
};
export default function HomePage() {
return (
<div className="page-stack">
<section className="hero-section">
<div className="hero-copy">
<span className="eyebrow">Independent studio</span>
<h1>Creative brand systems for modern teams.</h1>
<p className="lead">
We help product-focused companies clarify their story, sharpen their
visual identity, and launch with confidence.
</p>
<div className="hero-actions">
<Link className="button button-primary" href="/contact">
Book a consultation
</Link>
<Link className="button button-secondary" href="/about">
Meet the team
</Link>
</div>
</div>
</section>
<section className="grid-section">
<article className="feature-card">
<h2>Brand strategy</h2>
<p>
Positioning, messaging, and narrative systems that give every launch
a clearer point of view.
</p>
</article>
<article className="feature-card">
<h2>Creative production</h2>
<p>
Campaign concepts, visual direction, and polished execution built to
feel distinct and consistent.
</p>
</article>
<article className="feature-card">
<h2>Campaign reporting</h2>
<p>
Useful reporting frameworks that help teams understand what landed
and what to improve next.
</p>
</article>
</section>
</div>
);
}At this point, we could go through that file line by line, invent keys one by one, and manually add everything to our JSON files. We could even ask an AI assistant like Claude or Codex to do that part for us.
But if we stop there, we just end up with a pile of unverified AI translations sitting in local files, plus the usual JSON merge conflicts with other developers. That is fast for one person, but it is not a great workflow for a real team.
Automating and Managing Translations
We’ll use i18nexus to manage the translations for this app. It generates AI translations, gives the team a shared place to edit and review copy, and keeps everyone’s local files in sync.
With a single prompt, we can have Codex use the i18nexus CLI to replace the hardcoded copy with keys, create the strings, generate AI translations, and sync everything directly into the project:

And the best part: the i18nexus dashboard becomes the single source of truth for the whole team, with automatic AI translations already in place, a review workflow for translators and teammates, built-in change tracking, and any string updates synced back into every developer’s project.

Much better. Let’s set it up.
Initializing i18nexus
1) Create Languages
After you create an i18nexus account, the first thing to do is add your languages in the UI. In this example, I’m using en, de, and sv.

2) Add Namespaces
Next, open your i18nexus project and create the namespaces you want to use. For this tutorial that means common, home, about, and contact.

3) Sync Translations Automatically
After that, go to the Project Settings tab in your i18nexus dashboard and grab your Project API Key.
Then generate a Personal Access Token (PAT) here:

When creating the PAT, enable only base_strings:create, namespaces:create, and import_strings.
Add both of them to .env (or .env.local):
.envI18NEXUS_API_KEY="your_project_api_key"
I18NEXUS_PAT="your_personal_access_token"Now we can let the i18nexus CLI automatically sync translations from i18nexus into our app. 🙌
Setting Up the CLI
Install it as a dev dependency:
$ npm install i18nexus-cli concurrently --save-dev
Then update package.json so development runs listen and prebuild runs pull:
package.json...
"scripts": {
"dev": "concurrently -k \"next dev\" \"i18nexus listen\"",
"prebuild": "i18nexus pull",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
...This is a nice setup because i18nexus listen keeps your locale files fresh in development, while prebuild makes sure builds always use the latest content.
In practice, listen automatically pulls newly created translations down into your codebase in real time while you work.
next-i18next is still reading from public/locales, but those JSON files are being written for us automatically by i18nexus.
That means we never have to manually update JSON files again.
Many teams choose to gitignore their locales directory since listen keeps translations synced in development and prebuild grabs the latest translations before each production build.
Adding Strings
Once the project is connected, there are three easy ways to add strings.
🖥️ In the i18nexus web UI
🧩 From the editor extension in VSCode or Cursor
🔮 With AI agents using the i18nexus CLI
1) Add Strings in the i18nexus UI
First, you can add strings directly in the i18nexus platform UI. This is great for quick edits, for non-technical teammates, and for translators.
2) Add Strings with the i18nexus Extension
Second, you can use the i18nexus editor extension in VSCode and Cursor. That makes it easy to create strings without leaving your editor.
The AI Note field in i18nexus is especially helpful here. It lets you add context or instructions so AI translations come out more accurately.

3) Have Your AI Assistant Add Strings for You 🔮
Third, you can have AI agents like Codex create strings for you through the i18nexus-cli, just like we did earlier in this tutorial.
Your AI can add new user-facing strings to i18nexus as it writes code, so translation management stays in sync without you having to stop and do it manually.

Out of caution, you may want to give the PAT create/import strings only so your AI can't accidentally delete content in i18nexus.
With Codex, you can even create an i18nexus skill for your project so it knows to add new strings through the CLI whenever it introduces user-facing text. Check out an example skill we wrote in this repo: example skill repo. If you use Claude, you can do something similar with CLAUDE.md.
Vercel and Serverless Deployments
If you're deploying to Vercel or another serverless environment, there's one extra thing to know: these environments can't rely on public/locales the same way a traditional server can. You can see the discussion in this next-i18next issue.
For that setup, use next-i18next's resourceLoader so translations are bundled from a server-safe location instead:
i18n.config.tsimport type { I18nConfig } from 'next-i18next/proxy'
const i18nConfig: I18nConfig = {
supportedLngs: ['en', 'de', 'sv'],
fallbackLng: 'en',
defaultNS: 'common',
ns: ['common', 'home', 'about', 'contact'],
hideDefaultLocale: true,
...(process.env.NODE_ENV === 'production'
? {
resourceLoader: (language, namespace) =>
import(`./app/i18n/locales/${language}/${namespace}.json`)
}
: {})
}
export default i18nConfigAnd update package.json so i18nexus pulls into app/i18n/locales before production builds:
package.json...
"scripts": {
"dev": "concurrently -k \"next dev\" \"i18nexus listen\"",
"prebuild": "i18nexus pull -p app/i18n/locales",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
...The reason we don't use this setup in development too is that public/locales works nicely with i18n.reloadResources(), which lets us see JSON translation changes without restarting the dev server.
resourceLoader bundles those translations instead, so changes to the JSON files won't show up during development until the server restarts. That's just how next-i18next works right now.
If the library gets an update that makes this easier, we'll update this tutorial. For now, this split setup is the cleanest option.
That's It!

We now have a Next.js 16 App Router project using next-i18next v16 with clean locale routing, Server and Client Component support, AI translations flowing in from i18nexus, and a setup that keeps the app synced automatically.
If you want the full example project, you can find it here: next-i18next App Router example repo.
Level up your localization
It only takes a few minutes to streamline your translations forever.
Get started