How to Add i18n to Next.js App Router (2025 Guide)
Complete guide to internationalization in Next.js 14/15 App Router. Set up multi-language support with i18next and automated translations.
Next.js App Router changed everything. File-based routing, server components, streaming—it's a massive upgrade.
But i18n? The App Router doesn't include built-in internationalization like the Pages Router did.
Here's how to add proper multi-language support to your Next.js 14/15 app.
The Challenge
The App Router dropped the built-in next-i18next integration.
If you search for "Next.js App Router i18n," you'll find:
- Outdated tutorials for the Pages Router
- Complex custom implementations with 500+ lines of code
- Middleware hacks that break on Vercel
- Solutions that don't work with server components
Most developers end up either:
- Sticking with the Pages Router (missing out on App Router benefits)
- Building a fragile custom solution that breaks on updates
- Giving up on i18n entirely
You shouldn't have to choose between modern Next.js and internationalization.
The Solution
A clean i18next setup that works with the App Router.
We'll use next-intl for routing and i18next for translation management. This gives you:
- URL-based locale routing (
/en/about,/es/about) - Server and client component support
- Static generation compatibility
- Automated translation with Shipi18n
The setup takes 15 minutes. Let's build it.
Step-by-Step Guide
Step 1: Install dependencies
npm install next-intl
That's it. One package handles routing, server components, and client components.
Step 2: Create the folder structure
Your App Router structure needs locale-based folders:
app/
├── [locale]/
│ ├── layout.tsx
│ ├── page.tsx
│ └── about/
│ └── page.tsx
├── layout.tsx
└── not-found.tsx
messages/
├── en.json
├── es.json
└── fr.json
middleware.ts
i18n.ts
The [locale] dynamic segment handles all language routing automatically.
Step 3: Configure i18n settings
Create i18n.ts in your project root:
// i18n.ts
import { getRequestConfig } from 'next-intl/server'
export const locales = ['en', 'es', 'fr', 'de', 'ja'] as const
export const defaultLocale = 'en' as const
export type Locale = (typeof locales)[number]
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`./messages/${locale}.json`)).default
}))
This defines your supported languages and loads the right translation file.
Step 4: Add the middleware
Create middleware.ts in your project root:
// middleware.ts
import createMiddleware from 'next-intl/middleware'
import { locales, defaultLocale } from './i18n'
export default createMiddleware({
locales,
defaultLocale,
localePrefix: 'as-needed' // Only add prefix for non-default locales
})
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
}
The middleware handles:
- Detecting user's preferred language from browser
- Redirecting to the correct locale
- Setting cookies for language preference
Step 5: Update your root layout
Update app/layout.tsx:
// app/layout.tsx
import { ReactNode } from 'react'
export default function RootLayout({ children }: { children: ReactNode }) {
return children
}
Keep the root layout minimal—the locale layout handles everything.
Step 6: Create the locale layout
Create app/[locale]/layout.tsx:
// app/[locale]/layout.tsx
import { ReactNode } from 'react'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
import { notFound } from 'next/navigation'
import { locales } from '@/i18n'
export function generateStaticParams() {
return locales.map((locale) => ({ locale }))
}
interface Props {
children: ReactNode
params: { locale: string }
}
export default async function LocaleLayout({ children, params: { locale } }: Props) {
// Validate locale
if (!locales.includes(locale as any)) {
notFound()
}
// Load messages for the locale
const messages = await getMessages()
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
This layout:
- Validates the locale exists
- Loads translation messages
- Provides translations to all child components
- Sets the correct
langattribute on<html>
Step 7: Create your translation files
Create messages/en.json:
{
"home": {
"title": "Welcome to our app",
"description": "The best way to build multilingual applications",
"cta": "Get Started"
},
"nav": {
"home": "Home",
"about": "About",
"pricing": "Pricing"
},
"common": {
"loading": "Loading...",
"error": "Something went wrong"
}
}
Create messages/es.json:
{
"home": {
"title": "Bienvenido a nuestra aplicación",
"description": "La mejor manera de crear aplicaciones multilingües",
"cta": "Comenzar"
},
"nav": {
"home": "Inicio",
"about": "Acerca de",
"pricing": "Precios"
},
"common": {
"loading": "Cargando...",
"error": "Algo salió mal"
}
}
Step 8: Use translations in pages
Server components (default in App Router):
// app/[locale]/page.tsx
import { useTranslations } from 'next-intl'
export default function HomePage() {
const t = useTranslations('home')
return (
<main>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
<button>{t('cta')}</button>
</main>
)
}
Client components:
// components/LanguageSwitcher.tsx
'use client'
import { useLocale } from 'next-intl'
import { useRouter, usePathname } from 'next/navigation'
import { locales } from '@/i18n'
export default function LanguageSwitcher() {
const locale = useLocale()
const router = useRouter()
const pathname = usePathname()
const switchLocale = (newLocale: string) => {
// Replace the locale segment in the pathname
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`)
router.push(newPath)
}
return (
<select value={locale} onChange={(e) => switchLocale(e.target.value)}>
{locales.map((loc) => (
<option key={loc} value={loc}>
{loc.toUpperCase()}
</option>
))}
</select>
)
}
Both work seamlessly. Server components get translations at build time. Client components get them from the provider.
Automating Translations
Manually translating JSON files doesn't scale.
When you add a new key to en.json, you need to:
- Copy it to every other locale file
- Translate each one (or send to a translator)
- Verify the JSON is valid
- Test each language
This is where automation saves hours.
Option 1: CLI Translation
Install the Shipi18n CLI:
npm install -g @anthropic-ai/shipi18n-cli
Translate your source file:
shipi18n translate messages/en.json --to es,fr,de,ja --out messages/
All locale files updated in seconds.
Option 2: GitHub Actions Automation
Add this workflow to auto-translate on every push:
# .github/workflows/translate.yml
name: Auto-translate
on:
push:
branches: [main]
paths:
- 'messages/en.json'
jobs:
translate:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Translate messages
uses: Shipi18n/shipi18n-github-action@v1
with:
api-key: ${{ secrets.SHIPI18N_API_KEY }}
source-file: 'messages/en.json'
target-languages: 'es,fr,de,ja'
output-dir: 'messages'
commit-message: 'chore: update translations [skip ci]'
Push English changes. Get translations automatically. No manual work.
Handling Dynamic Content
What about content that includes variables?
{
"greeting": "Hello, {name}!",
"items": "You have {count, plural, =0 {no items} one {1 item} other {# items}}"
}
Use them in components:
const t = useTranslations('common')
// Simple variable
<p>{t('greeting', { name: 'John' })}</p>
// Pluralization
<p>{t('items', { count: 5 })}</p>
Both next-intl and Shipi18n handle ICU message format. Placeholders are preserved during translation.
SEO Considerations
For proper SEO, add alternate links and metadata:
// app/[locale]/layout.tsx
import { locales } from '@/i18n'
export async function generateMetadata({ params: { locale } }: Props) {
const t = await getTranslations({ locale, namespace: 'metadata' })
return {
title: t('title'),
description: t('description'),
alternates: {
canonical: `https://yoursite.com/${locale}`,
languages: Object.fromEntries(
locales.map((l) => [l, `https://yoursite.com/${l}`])
)
}
}
}
This generates proper hreflang tags for search engines.
Comparison: App Router vs Pages Router i18n
| Feature | Pages Router | App Router |
|---|---|---|
| Built-in i18n config | Yes | No |
| Server components | No | Yes |
| Streaming | Limited | Full support |
| Setup complexity | Low | Medium |
| Performance | Good | Better |
| Static generation | Full | Full |
The App Router requires more setup, but delivers better performance and developer experience.
Key Takeaways
-
Use
next-intl— It's the most mature solution for App Router i18n. One package, full support for server and client components. -
Automate translations — Manual JSON management doesn't scale. Use CLI tools or GitHub Actions to keep locale files in sync.
-
Structure matters — The
[locale]folder pattern with middleware gives you clean URLs and automatic language detection.
Next Steps
Ready to add i18n to your Next.js app?
- Get started with Shipi18n — Free tier includes 100 stored translation keys
- Read the next-intl documentation for advanced patterns
- Check out our Next.js example repo for a complete working setup
- Browse our GitHub Action for CI/CD integration
Ship your app in every language.
Ready to automate your translations?
Get started with Shipi18n for free. No credit card required.
Try Shipi18n Free →