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!
United States
NORTH AMERICA
Related News
How Brazeβs CTO is rethinking engineering for the agentic area
11h ago
Amazon Employees Are 'Tokenmaxxing' Due To Pressure To Use AI Tools
22h ago
KDE Receives $1.4 Million Investment From Sovereign Tech Fund
2h ago
Instagramβs new βInstantsβ feature combines elements from Snapchat and BeReal
2h ago
Six Claude Code Skills That Close the AI Agent Feedback Loop
2h ago