Simplifying i18next Setup in Next.js App Router


This post is an update to our earlier blog post on Next.js i18n with the App Directory. Here, weâll show you a streamlined way to set up i18next with Next.js that avoids the need to pass the locale or translation function (t) around in every page.
Whatâs the Idea?
The goal is to automatically detect the current language from the URL â whether youâre on a client or server page - so you donât have to manually pass down the language or translation function. We achieve this by:
- Extracting the language code from the URL using Next.js hooks for client pages.
- Using middleware on the server to set the language in a custom header.
- Relying on helper functions that provide the translation function (t) on both client and server sides.
For Client Pages
We use the useParams
hook from Next.js to extract the language from the URL. For example:
import { useParams } from 'next/navigation'
// ...
const lng = useParams()?.lng
The language parameter is provided by our generateStaticParams
function:
export async function generateStaticParams() {
return languages.map((lng) => ({ lng }))
}
For Server Pages
Since the useParams
hook isnât available on the server, we create a small workaround. We extend our middleware to extract the language from the URL and then store it in a custom header:
const lngInPath = languages.find(loc => req.nextUrl.pathname.startsWith(`/${loc}`))
const headers = new Headers(req.headers)
headers.set(headerName, lngInPath || fallbackLng)
// ...
return NextResponse.next({ headers })
Then, on the server side, we retrieve the language using:
import { headers } from 'next/headers'
// ...
const headerList = await headers()
const lng = headerList.get(headerName)
Letâs Walk Through the Setup - File Breakdown
1. Settings (app/i18n/settings.js
)
This file contains the basic settings for i18next:
export const fallbackLng = 'en'
export const languages = [fallbackLng, 'de', 'it']
export const defaultNS = 'translation'
export const cookieName = 'i18next'
export const headerName = 'x-i18next-current-language'
2. Middleware (middleware.js
)
The middleware is responsible for detecting the language from cookies, the Accept-Language header, or directly from the URL. It also handles redirections if the language isnât in the URL and sets a custom header to pass the language info along to server-side code.
import { NextResponse } from 'next/server'
import acceptLanguage from 'accept-language'
import { fallbackLng, languages, cookieName, headerName } from './app/i18n/settings'
acceptLanguage.languages(languages)
export const config = {
// Avoid matching for static files, API routes, etc.
matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js|site.webmanifest).*)']
}
export function middleware(req) {
// Ignore paths with "icon" or "chrome"
if (req.nextUrl.pathname.indexOf('icon') > -1 || req.nextUrl.pathname.indexOf('chrome') > -1) return NextResponse.next()
let lng
// Try to get language from cookie
if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName).value)
// If no cookie, check the Accept-Language header
if (!lng) lng = acceptLanguage.get(req.headers.get('Accept-Language'))
// Default to fallback language if still undefined
if (!lng) lng = fallbackLng
// Check if the language is already in the path
const lngInPath = languages.find(loc => req.nextUrl.pathname.startsWith(`/${loc}`))
const headers = new Headers(req.headers)
headers.set(headerName, lngInPath || lng)
// If the language is not in the path, redirect to include it
if (
!lngInPath &&
!req.nextUrl.pathname.startsWith('/_next')
) {
return NextResponse.redirect(new URL(`/${lng}${req.nextUrl.pathname}${req.nextUrl.search}`, req.url))
}
// If a referer exists, try to detect the language from there and set the cookie accordingly
if (req.headers.has('referer')) {
const refererUrl = new URL(req.headers.get('referer'))
const lngInReferer = languages.find((l) => refererUrl.pathname.startsWith(`/${l}`))
const response = NextResponse.next({ headers })
if (lngInReferer) response.cookies.set(cookieName, lngInReferer)
return response
}
return NextResponse.next({ headers })
}
3. i18next Initialization (app/i18n/i18next.js
)
This file sets up i18next with language detection, resource loading, and the React binding:
import i18next from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import resourcesToBackend from 'i18next-resources-to-backend'
// import LocizeBackend from 'i18next-locize-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { fallbackLng, languages, defaultNS } from './settings'
const runsOnServerSide = typeof window === 'undefined'
i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
// .use(runsOnServerSide ? LocizeBackend : resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`))) // locize backend could be used, but prefer to keep it in sync with server side
.init({
// debug: true,
supportedLngs: languages,
fallbackLng,
lng: undefined, // let detect the language on client side
fallbackNS: defaultNS,
defaultNS,
detection: {
order: ['path', 'htmlTag', 'cookie', 'navigator']
},
preload: runsOnServerSide ? languages : [],
// backend: {
// projectId: '01b2e5e8-6243-47d1-b36f-963dbb8bcae3'
// }
})
export default i18next
4. Server-Side Helper (app/i18n/index.js
)
This helper retrieves the translation function (t) for server pages, reading the language from the custom header:
import i18next from './i18next'
import { headerName } from './settings'
import { headers } from 'next/headers'
export async function getT(ns, options) {
const headerList = await headers()
const lng = headerList.get(headerName)
if (lng && i18next.resolvedLanguage !== lng) {
await i18next.changeLanguage(lng)
}
if (ns && !i18next.hasLoadedNamespace(ns)) {
await i18next.loadNamespaces(ns)
}
return {
t: i18next.getFixedT(lng ?? i18next.resolvedLanguage, Array.isArray(ns) ? ns[0] : ns, options?.keyPrefix),
i18n: i18next
}
}
5. Client-Side Helper (app/i18n/client.js
)
On the client, this hook uses Next.jsâ useParams
to update the language based on the URL:
'use client'
import i18next from './i18next'
import { useParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const runsOnServerSide = typeof window === 'undefined'
export function useT(ns, options) {
const lng = useParams()?.lng
if (typeof lng !== 'string') throw new Error('useT is only available inside /app/[lng]')
if (runsOnServerSide && i18next.resolvedLanguage !== lng) {
i18next.changeLanguage(lng)
} else {
const [activeLng, setActiveLng] = useState(i18next.resolvedLanguage)
useEffect(() => {
if (activeLng === i18next.resolvedLanguage) return
setActiveLng(i18next.resolvedLanguage)
}, [activeLng, i18next.resolvedLanguage])
useEffect(() => {
if (!lng || i18next.resolvedLanguage === lng) return
i18next.changeLanguage(lng)
}, [lng, i18next])
}
return useTranslation(ns, options)
}
6. Page Examples
Layout & Static Params
Both server and client pages use a layout file to generate static parameters for each language:
import { languages } from '../../i18n/settings'
import { getT } from '../../i18n'
export async function generateStaticParams() {
return languages.map((lng) => ({ lng }))
}
export async function generateMetadata() {
const { t } = await getT('second-page')
return {
title: t('title')
}
}
export default async function Layout({ children }) {
return children
}
Server-Side Page (app/[lng]/server-page/page.js
)
Hereâs how youâd use our server-side helper:
import { getT } from '../../i18n'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
import { Link } from '../components/Link'
export async function generateMetadata() {
const { t } = await getT('server-page')
return { title: t('h1') }
}
export default async function Page() {
const { t } = await getT('server-page')
return (
<>
<main>
<Header heading={t('h1')} />
<Link href="/">
<button type="button">
{t('back-to-home')}
</button>
</Link>
</main>
<Footer path="/server-page" />
</>
)
}
Client-Side Page (app/[lng]/client-page/page.js
)
This is how youâd use the useT
hook on a client-rendered page:
'use client'
import * as React from 'react'
import { useT } from '../../i18n/client'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer/client'
import { Link } from '../components/Link/client'
import { useState } from 'react'
export default function Page() {
const { t } = useT('client-page')
const [counter, setCounter] = useState(0)
return (
<>
<main>
<Header heading={t('h1')} />
<p>{t('counter', { count: counter })}</p>
<div>
<button onClick={() => setCounter(Math.max(0, counter - 1))}>-</button>
<button onClick={() => setCounter(Math.min(10, counter + 1))}>+</button>
</div>
<Link href="/second-client-page">
{t('to-second-client-page')}
</Link>
<Link href="/">
<button type="button">
{t('back-to-home')}
</button>
</Link>
</main>
<Footer path="/client-page" />
</>
)
}
7. Components: Footer and Link
Footer Component
Universal Base (app/[lng]/components/Footer/FooterBase.js
) is unchanged, like in the previous blog post.
Server Side (app/[lng]/components/Footer/index.js
):
import { getT } from '../../../i18n'
import { FooterBase } from './FooterBase'
export const Footer = async ({ path }) => {
const { t, i18n } = await getT('footer')
return <FooterBase t={t} lng={i18n.resolvedLanguage} path={path} />
}
Client Side (app/[lng]/components/Footer/client.js
):
'use client'
import { FooterBase } from './FooterBase'
import { useT } from '../../../i18n/client'
export const Footer = ({ path }) => {
const { t, i18n } = useT('footer')
return <FooterBase t={t} lng={i18n.resolvedLanguage} path={path} />
}
Link Component
Universal Base (app/[lng]/components/Link/LinkBase.js
):
import Link from 'next/link'
export const LinkBase = ({ lng, href = '', children }) => {
return <Link href={`/${lng}/${href}`}>{children}</Link>
}
Server Side (app/[lng]/components/Link/index.js
):
import { getT } from '../../../i18n'
import { LinkBase } from './LinkBase'
export const Link = async ({ href, children }) => {
const { i18n } = await getT()
return <LinkBase lng={i18n.resolvedLanguage} href={href}>{children}</LinkBase>
}
Client Side (app/[lng]/components/Link/client.js
):
'use client'
import { LinkBase } from './LinkBase'
import { useT } from '../../../i18n/client'
export const Link = ({ href, children }) => {
const { i18n } = useT()
return <LinkBase lng={i18n.resolvedLanguage} href={href}>{children}</LinkBase>
}
Bonus

Connect to an awesome translation management system and manage your translations outside of your code.
Let's synchronize the translation files with locize. This can be done on-demand or on the CI-Server or before deploying the app.
What to do to reach this step:
- in locize: signup at https://locize.app/register and login
- in locize: create a new project
- install the locize-cli
(npm i locize-cli)
- in locize: add all your additional languages (this can also be done via API or with using the migrate command of the locize-cli)
Use the locize-cli
Use the locize download
command to always download the published locize translations to your local repository (app/i18n/locales
) before bundling your app. example
Alternatively, you can also use the locize sync
command to synchronize your local repository (app/i18n/locales
) with what is published on locize. example
Wrapping Up
This streamlined setup makes working with multiple languages in Next.js easier than ever. By extracting the language directly from the URL and using custom headers and middleware, you no longer need to pass the locale or translation function manually. This keeps your code clean and DRY!
đ§âđ» For the complete code, check out the GitHub repository and if you prefer TypeScript, you can find the TypeScript version here.
Happy coding!