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
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
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',
})
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|.*\..*).*)'],
}
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 }
})
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)
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"
}
}
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)
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>
)
}
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>
}
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>
}
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>
)
}
<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
getTranslationsin Server Components,useTranslationsin 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)