Next.js Localization Made Easy: How to Use next-intl with Automated Workflows
next-intl is one of the go-to i18n libraries for Next.js, especially if you’re using the App Router. It gives you a clean way to handle translated messages, locale-based routing, and things like date, number, and plural formatting without a bunch of extra setup.
In this guide, I’ll walk through a practical next-intl setup for a Next.js App Router project. You’ll learn how to configure locale routing, structure message files, use translations in both server and client components, and automate translation updates with a pull-request workflow in LocoLingua.
What next-intl Does Well
next-intl is built for Next.js. It handles locale routing, loading messages, ICU-style plurals and formatting, and it works in both Server and Client Components. You also get navigation helpers that play nicely with the App Router instead of fighting it.
Teams reach for it because the pieces fit together the way a typical App Router app is already organized—routing config, proxy or middleware for locale negotiation, request-time message loading, and navigation that understands locale prefixes. That usually means less random if (locale) logic sprinkled all over the app.
If you want translated UI and sane formatting without stacking a bunch of extra abstractions on top, it’s a pretty sensible default.
Setting Up next-intl in Your Next.js Project
You’re going to shuffle the app directory a bit and add a handful of config files. Together they handle locale routing and which JSON file gets loaded for a request.
Installing next-intl
From your project root (App Router assumed):
npm install next-intl
Then wire the plugin in next.config.ts:
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin({
experimental: {
messages: {
path: './messages',
locales: 'infer',
format: 'json',
precompile: true,
},
},
})
const nextConfig = {}
export default withNextIntl(nextConfig)
Turning on precompile pushes ICU parsing to build time, which trims runtime work and can shrink what you ship for client-side translations—worth flipping on if you’re using messages in the browser.
If your request config isn’t in the default spot, createNextIntlPlugin() accepts a path so you can keep i18n files wherever your team prefers.
Creating your project structure
Add a messages folder (usually at the repo root) with one JSON per locale—en.json, de.json, and so on. That layout plays well with tools like LocoLingua: export, translate, drop the files back in.
Move pages and layouts under app/[locale]/... so the locale is always a segment in the URL and in your layout props. Rough shape:
├── messages
│ ├── en.json
│ └── de.json
├── next.config.ts
└── src
├── i18n
│ ├── routing.ts
│ ├── navigation.ts
│ └── request.ts
├── proxy.ts
└── app
└── [locale]
├── layout.tsx
└── page.tsx
navigation.ts is where you wrap Next’s navigation APIs so links and the router respect the active locale—handy for language switchers and anything that needs to stay on the same path in another language.
Adding locale configuration files
routing.ts lists supported locales and the default:
import { defineRouting } from 'next-intl/routing'
export const routing = defineRouting({
locales: ['en', 'de'],
defaultLocale: 'en',
})
Middleware and your navigation helpers both read that config. request.ts is what runs in the server context to pick messages for the current request:
import { getRequestConfig } from 'next-intl/server'
import { hasLocale } from 'next-intl'
import { routing } from './routing'
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
}
})
Here you’re answering two questions for each render: which locale are we in, and which JSON do we load? Checking hasLocale before the dynamic import avoids weird 404s when someone hits /zz or a stale bookmark.
Adding locale-aware navigation helpers
In src/i18n/navigation.ts:
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing)
That gives you Link / useRouter / etc. that already know your locale prefix—nice when you’re building a switcher and don’t want to hand-roll the same path logic in five components.
Your real layout belongs in app/[locale]/layout.tsx, not a root layout that pretends the locale segment doesn’t exist. That’s where you validate the locale, opt into static generation if you want it, and wrap children with NextIntlClientProvider.
Setting up middleware for locale routing
Use proxy.ts under src on Next.js 16 (older versions still use middleware.ts—same idea, different filename).
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'
export default createMiddleware(routing)
export const config = {
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)',
}
The proxy negotiates locale and handles redirects/rewrites; the matcher keeps it off APIs, framework internals, and static assets. If you have trpc or another prefix that should never get a locale prepended, exclude it here.
Under the hood it picks a locale from the URL prefix, cookie, Accept-Language, or your default—so most visitors land in something reasonable without you wiring that by hand.
Implementing Translations in the App Router
Your translation files are really the backbone of the whole setup. Once the plumbing works, how you organize the JSON is what makes this manageable later.
Creating translation JSON files
Each locale gets a file under messages/. Inside, group keys by namespace so nav, home, checkout, whatever don’t turn into one flat mess.
{
"HomePage": {
"title": "Welcome to our site",
"description": "Find amazing content"
},
"Navigation": {
"home": "Home",
"about": "About"
}
}
One file per locale is fine at first. Bigger apps often split by feature and merge in request.ts so no single file becomes impossible to diff. LocoLingua fits that model: JSON in the repo, updates back as PRs.
Setting up the locale layout
Inside [locale], confirm the param is real before you render:
import { NextIntlClientProvider, hasLocale } from 'next-intl'
import { setRequestLocale } from 'next-intl/server'
import { notFound } from 'next/navigation'
import { routing } from '@/i18n/routing'
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }))
}
export default async function LocaleLayout({ children, params }) {
const { locale } = await params
if (!hasLocale(routing.locales, locale)) {
notFound()
}
setRequestLocale(locale)
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
)
}
For static rendering, generateStaticParams enumerates locales at build time; setRequestLocale makes sure next-intl sees the right locale during that pass—not a step to skip if you care about SSG for localized routes.
Using translations in server components
On the server, async components often use getTranslations:
import { getTranslations } from 'next-intl/server'
export default async function AboutPage() {
const t = await getTranslations('AboutPage')
return <h1>{t('title')}</h1>
}
Sync server components can still call useTranslations with a namespace and drill into nested keys the same way—same mental model as the rest of the tree.
Using translations in client components
Mark the file 'use client', then useTranslations works as long as NextIntlClientProvider wraps the tree above you. If you’re deep in the tree, you either pass messages down from a parent provider or fetch strings in a parent Server Component and pass props—both are valid; pick whichever keeps the client bundle smaller.
For interactive bits, I often compute labels in a server parent and pass plain strings into client children so the heavy lifting stays on the server.
Building a language switcher
A switcher is a small client component. This assumes navigation.ts already exports useRouter / usePathname from createNavigation(routing) so you’re not guessing localized paths by hand.
'use client'
import { usePathname, useRouter } from '@/i18n/navigation'
import { useLocale } from 'next-intl'
export default function LanguageSwitcher() {
const locale = useLocale()
const router = useRouter()
const pathname = usePathname()
const switchLocale = (newLocale) => {
if (newLocale !== locale) {
router.replace(pathname, { locale: newLocale })
}
}
return (
<select value={locale} onChange={(e) => switchLocale(e.target.value)}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
)
}
router.replace keeps the same route; only the locale prefix changes.
Handling Dynamic Content and Formatting
Static strings are the easy part. Real apps usually need dynamic values too, like usernames, item counts, prices, and formatted dates. next-intl uses ICU-style messages so you’re not writing bespoke plural branches in JSX for every language.
Interpolation with placeholders
Use {name}-style placeholders in JSON and pass values when you call t:
const t = useTranslations()
t('greeting', { name: 'Jane' }) // "Hello, Jane!"
Same pattern in every locale file; translators see the placeholder, not implementation details.
Handling pluralization rules
English is simple; other languages aren’t. ICU handles that in the string instead of in your component:
{
"cartItems": "{count, plural, =0 {Your cart is empty} one {You have 1 item} other {You have {count} items}}"
}
Call t('cartItems', { count: itemCount }) and next-intl picks the right branch. Tags like zero, one, few, many, other map through Intl.PluralRules for the active locale. Ordinals work too with selectordinal when you need “1st / 2nd / 3rd”.
Formatting dates and times
useFormatter keeps date output consistent:
const format = useFormatter()
format.dateTime(new Date(), { year: 'numeric', month: 'short', day: 'numeric' })
Relative times (format.relativeTime) and embedded dates in messages ({orderDate, date, medium}) use the same tooling.
Formatting numbers and currency
Locales disagree on commas, dots, and where the currency symbol goes. The formatter wraps that:
format.number(499.9, { style: 'currency', currency: 'USD' }) // "USD 499.90"
You can also embed {price, number, ::currency/USD} inside a message when the copy and the number ship together.
Automating Translation Updates with LocoLingua
Once your app starts growing, translation work gets annoying to manage by hand. LocoLingua hooks into your repo, finds your locale files, and opens PRs with updated translations.
Exporting translation files to LocoLingua
Connect GitHub to get started. Their docs say format detection is automatic; GitLab and Bitbucket are on the roadmap if that’s your stack.
After connect, it picks up something like your messages/ folder, maps source vs. target languages, and you choose where translations should land.
Managing translations in LocoLingua
Pick source and targets. They market it as context-aware—less “dictionary swap,” more keeping product language consistent.
Forced translations are useful when a brand name or internal term must stay locked across locales.
Importing updated translations
Updates show up as pull requests, so review looks like any other code change: diff, comment, merge. Translations land in-repo in the same JSON shape next-intl expects, so you’re not locked into a black box—you still own the files.
Fitting LocoLingua into your review workflow
Because everything comes back as a PR, localization stays in the same GitHub rhythm as the rest of the team. No mystery exports on someone’s laptop.
Keeping translations in sync
When source strings change, LocoLingua can pick that up, refresh targets, and drop removed keys so you don’t accumulate dead entries across ten locale files.
That’s the kind of housekeeping I’d rather not do manually every sprint.
Best Practices and Common Patterns
Once you’re past the toy demo, namespaces, incomplete locales, bundle size, and SEO start to matter.
Translation organization by namespace
Group keys by feature or route—Navigation, HomePage, Checkout—instead of one endless flat list. Nested objects map cleanly to namespaces in next-intl and make reviews less painful as the file grows.
Missing translation handling
Assume gaps will exist. Keep a solid default locale, fall back safely in request.ts when the locale param is nonsense, and don’t render optional copy without checking if the key exists. English-as-source with graceful fallback beats pretending every file is 100% complete on day one.
Bundle size optimization
If clients load messages, keep precompile on in next.config.ts—next-intl’s own write-up calls out a meaningful runtime savings. Default bias: do translation work on the server unless the component truly needs interactivity.
SEO aspects of localized content
Give each language a stable URL (next-intl’s routing story helps here). Line up metadata, canonicals, and sitemap entries with those URLs so crawlers see distinct language surfaces, instead of looking like duplicate content.
Conclusion
next-intl is a straight shot to locale routing, shared messages, and formatting inside the App Router. After routing, request.ts, and the [locale] layout are in place, the rest feels like normal React—just with t('...') instead of hard-coded strings.
The hard part usually isn’t rendering copy; it’s keeping files honest as the product moves. A Git-native flow helps. LocoLingua’s PR-based loop means translation work shows up where engineers already look, instead of vanishing into a separate tool.
Ship a second locale, prove the routing and JSON layout hold up, then worry about scaling the process.