← Back to Blog
·Shipi18n Team

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.

nextjsi18napp-routertutorialreact

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:

  1. Sticking with the Pages Router (missing out on App Router benefits)
  2. Building a fragile custom solution that breaks on updates
  3. 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 lang attribute 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:

  1. Copy it to every other locale file
  2. Translate each one (or send to a translator)
  3. Verify the JSON is valid
  4. 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

FeaturePages RouterApp Router
Built-in i18n configYesNo
Server componentsNoYes
StreamingLimitedFull support
Setup complexityLowMedium
PerformanceGoodBetter
Static generationFullFull

The App Router requires more setup, but delivers better performance and developer experience.


Key Takeaways

  1. Use next-intl — It's the most mature solution for App Router i18n. One package, full support for server and client components.

  2. Automate translations — Manual JSON management doesn't scale. Use CLI tools or GitHub Actions to keep locale files in sync.

  3. 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?


Ship your app in every language.

Try Shipi18n Free →

Ready to automate your translations?

Get started with Shipi18n for free. No credit card required.

Try Shipi18n Free →