DEV Community

yone3
yone3

Posted on

How I Added i18n to My Next.js App Router Project

Hey everyone!

I recently added multilingual support (Japanese + English) to my side project unsolved β€” a silly joke app that takes your everyday problems and generates a sci-fi story about how "humanity's collective wisdom" solved it. πŸ˜„

Since the concept is kind of universal (everyone has problems, right?), I wanted English-speaking users to enjoy it too. So I went ahead and implemented i18n.

In this post, I'll walk through how I did it using next-intl β€” which turned out to be a great fit for Next.js App Router.

Why next-intl?

Next.js does have its own i18n config, but it doesn't play super nicely with App Router. After some research, next-intl seemed like the most App Router-friendly option out there, with solid TypeScript support to boot.

Installation is just:

npm install next-intl
Enter fullscreen mode Exit fullscreen mode

File Structure Overview

Here's what the project looks like after setup:

src/
β”œβ”€β”€ middleware.ts           # Locale detection & redirect
β”œβ”€β”€ i18n/
β”‚   β”œβ”€β”€ routing.ts         # Supported locales & default
β”‚   β”œβ”€β”€ request.ts         # Server-side message loading
β”‚   └── navigation.ts      # Locale-aware Link/useRouter
β”œβ”€β”€ app/
β”‚   └── [locale]/          # All pages live here
β”‚       β”œβ”€β”€ layout.tsx
β”‚       β”œβ”€β”€ page.tsx
β”‚       └── LocaleSwitcher.tsx
└── messages/
    β”œβ”€β”€ ja.json
    └── en.json
Enter fullscreen mode Exit fullscreen mode

Let's go through each piece.

Define Your Locales ( src/i18n/routing.ts )

import { defineRouting } from 'next-intl/routing'

export const routing = defineRouting({
  locales: ['ja', 'en'],
  defaultLocale: 'ja',
  localePrefix: 'as-needed',
})
Enter fullscreen mode Exit fullscreen mode

With localePrefix: 'as-needed', the default language (Japanese) uses / and English uses /en/. Change it to always if you want /ja/ and /en/ for all locales.

Middleware ( src/middleware.ts )

import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'

export default createMiddleware(routing)

export const config = {
  matcher: ['/((?!api|_next|_vercel|.*\..*).*)'],
}
Enter fullscreen mode Exit fullscreen mode

This middleware reads the browser's language setting and automatically redirects to the right locale. Visitors with English browsers get sent to /en/ β€” all handled by this one file.

Server-side Request Config ( src/i18n/request.ts )

import { getRequestConfig } from 'next-intl/server'
import { routing } from './routing'

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale
  if (!locale || !(routing.locales as readonly string[]).includes(locale)) {
    locale = routing.defaultLocale
  }

  const messages =
    locale === 'en'
      ? (await import('../../messages/en.json')).default
      : (await import('../../messages/ja.json')).default

  return { locale, messages }
})
Enter fullscreen mode Exit fullscreen mode

This loads the right translation file per request. Invalid locales fall back to the default β€” no worries about weird URLs.

next.config.ts

import type { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin'

const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')

const nextConfig: NextConfig = {}

export default withNextIntl(nextConfig)
Enter fullscreen mode Exit fullscreen mode

Translation Files

messages/en.json

{
  "metadata": {
    "title": "Humanity Never Gives Up",
    "description": "Solve your problems with the collective wisdom of humanity"
  },
  "header": {
    "title": "Humanity Never Gives Up",
    "sub": "You saved the world"
  },
  "input": {
    "nameLabel": "Your Name",
    "submitBtn": "Request a Solution"
  }
}
Enter fullscreen mode Exit fullscreen mode

Keys are nested and accessed with dot notation like t('header.title'). Simple and TypeScript-friendly.

Navigation Utilities ( src/i18n/navigation.ts )

import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'

export const { Link, redirect, usePathname, useRouter } = createNavigation(routing)
Enter fullscreen mode Exit fullscreen mode

Use this Link instead of Next.js's built-in one β€” locale is carried over automatically.

The [locale] Layout ( src/app/[locale]/layout.tsx )

All pages go under app/[locale]/. This is the key structural change.

import { NextIntlClientProvider } from 'next-intl'
import { getMessages, getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
import { routing } from '@/i18n/routing'

type Props = {
  children: React.ReactNode
  params: Promise<{ locale: string }>
}

export async function generateMetadata({ params }: Props) {
  const { locale } = await params
  const t = await getTranslations({ locale, namespace: 'metadata' })
  return {
    title: t('title'),
    description: t('description'),
  }
}

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }))
}

export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params
  if (!routing.locales.includes(locale as 'ja' | 'en')) notFound()

  const messages = await getMessages()

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

NextIntlClientProvider passes translations down to Client Components too. Any unsupported locale hits notFound() and returns a 404.

Using Translations

In a Server Component, use getTranslations (async):

import { getTranslations } from 'next-intl/server'

export default async function Page() {
  const t = await getTranslations('header')
  return <h1>{t('title')}</h1>
}
Enter fullscreen mode Exit fullscreen mode

In a Client Component, use the useTranslations hook:

'use client'

import { useTranslations } from 'next-intl'

export default function SubmitButton() {
  const t = useTranslations('input')
  return <button>{t('submitBtn')}</button>
}
Enter fullscreen mode Exit fullscreen mode

Language Switcher Component

Here's the actual LocaleSwitcher from unsolved:

'use client'

import { useState } from 'react'
import { useLocale } from 'next-intl'
import { Link, usePathname } from '@/i18n/navigation'
import { routing } from '@/i18n/routing'

export default function LocaleSwitcher() {
  const locale = useLocale()
  const pathname = usePathname()
  const [open, setOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setOpen(o => !o)}>
        🌐 {locale.toUpperCase()}
      </button>
      {open && (
        <div>
          {routing.locales.map(l => (
            <Link key={l} href={pathname} locale={l} onClick={() => setOpen(false)}>
              {l.toUpperCase()}
            </Link>
          ))}
        </div>
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

<Link href={pathname} locale={l}> keeps you on the same page and just swaps the locale β€” next-intl handles the URL conversion automatically.

Wrapping Up

  • next-intl works great with Next.js App Router and is easy to set up
  • The middleware handles automatic locale detection and redirects
  • Use getTranslations in Server Components, useTranslations in Client Components
  • Once the config is in place, adding new translations is just editing JSON files

You can see all of this running live at unsolved. It's a little joke app β€” type in a problem, and it generates a ridiculous sci-fi story about how AI saved the day. Hit the 🌐 button in the top right to switch languages!

Feel free to leave a comment if you have any questions!

Top comments (0)