The first time I added a second language to a project, I stored the actual translated string in component state instead of a translation key. It worked fine until a user switched languages mid-session and half the interface stayed in the old language because the state never knew it needed to update. Small mistake, annoying bug, and it taught me the one rule that makes i18n actually maintainable, translated strings never belong in state, only keys do.
Here is the setup I use now with next-intl.
1. The Setup
npm install next-intl
messages/
├── en.json
└── es.json
// messages/en.json
{
"nav": {
"home": "Home",
"pricing": "Pricing",
"contact": "Contact"
},
"hero": {
"title": "Build faster with Next.js templates",
"subtitle": "Production-ready templates for your next project"
}
}
// messages/es.json
{
"nav": {
"home": "Inicio",
"pricing": "Precios",
"contact": "Contacto"
},
"hero": {
"title": "Construye más rápido con plantillas Next.js",
"subtitle": "Plantillas listas para producción para tu próximo proyecto"
}
}
Nested keys, not flat ones, matter here. nav.home reads clearly and groups related strings together, versus a flat file with dozens of loosely related top-level keys that gets hard to navigate as a project grows.
2. Locale-Based Routing
// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'es'],
defaultLocale: 'en',
});
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
};
app/
├── [locale]/
│ ├── layout.tsx
│ ├── page.tsx
│ └── pricing/
│ └── page.tsx
Every route lives under a [locale] segment, /en/pricing and /es/pricing, and middleware detects the visitor's preferred language from their browser and redirects to the matching locale automatically on first visit.
3. Using Translations in a Server Component
// app/[locale]/page.tsx
import { useTranslations } from 'next-intl';
export default function HomePage() {
const t = useTranslations('hero');
return (
<div>
<h1>{t('title')}</h1>
<p>{t('subtitle')}</p>
</div>
);
}
useTranslations('hero') scopes the translator to the hero object in the messages file, so t('title') reaches hero.title without repeating the full path on every call.
4. Never Store Translated Strings in State
This is the mistake from the intro, and it is easy to make without noticing.
// ❌ Storing the translated string itself
'use client';
const [status, setStatus] = useState('Loading...'); // hardcoded, English only
// ✅ Storing the key, translating at render time
'use client';
import { useTranslations } from 'next-intl';
function StatusDisplay({ statusKey }: { statusKey: 'loading' | 'done' }) {
const t = useTranslations('status');
return <p>{t(statusKey)}</p>;
}
The first version breaks the moment a user switches languages, since the string was already resolved into English and stored, with nothing left to tell it to re-translate. The second version always reads the current locale at render time, so switching languages updates every piece of text immediately, including state that was set before the switch happened.
5. Pluralization and Variables
Hardcoding plural forms breaks in most languages, since pluralization rules differ from English in ways that go beyond just adding an "s."
// messages/en.json
{
"queue": {
"waitingCount": "{count, plural, =0 {No one waiting} =1 {1 person waiting} other {# people waiting}}"
}
}
const t = useTranslations('queue');
t('waitingCount', { count: waitingPatients.length });
ICU message format, the {count, plural, ...} syntax, handles the actual pluralization logic per language, rather than a hardcoded count === 1 ? 'person' : 'people' check that only works correctly for English's specific pluralization rules.
6. Language Switching Without Losing the Current Page
// components/LocaleSwitcher.tsx
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useLocale } from 'next-intl';
export function LocaleSwitcher() {
const router = useRouter();
const pathname = usePathname();
const currentLocale = useLocale();
function switchLocale(newLocale: string) {
const newPath = pathname.replace(`/${currentLocale}`, `/${newLocale}`);
router.push(newPath);
}
return (
<select value={currentLocale} onChange={(e) => switchLocale(e.target.value)}>
<option value="en">English</option>
<option value="es">Español</option>
</select>
);
}
Replacing just the locale segment of the current path, rather than redirecting to the home page, keeps someone on /es/pricing when they switch from Spanish back to /en/pricing, instead of dropping them back to the homepage and losing their place.
7. Metadata Per Locale
SEO metadata needs to be translated too, not just the visible page content, since a title tag in the wrong language for a given locale looks broken to both users and search engines.
// app/[locale]/page.tsx
import { getTranslations } from 'next-intl/server';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'metadata' });
return {
title: t('title'),
description: t('description'),
};
}
getTranslations, the server-side equivalent of useTranslations, works the same way inside generateMetadata, keeping title and description translated per locale rather than defaulting to one language across every version of the page.
8. Hreflang Tags for Search Engines
Without this, Google may not understand that /en/pricing and /es/pricing are the same page in different languages, rather than duplicate content competing against each other.
// app/[locale]/layout.tsx
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return {
alternates: {
languages: {
en: '/en',
es: '/es',
},
},
};
}
This tells search engines these pages are language variants of the same content, which is the correct signal for a genuinely multi-language site, distinct from the canonical URL issue covered in the SEO metadata setup, that one was about duplicate versions of the same-language content, this is about intentionally different-language versions of the same page.
Summary
| Pattern | Handles |
|---|---|
| Nested translation keys per locale | Organized, maintainable translation files |
[locale] route segment + middleware |
Locale-aware routing and automatic detection |
| Keys stored, not translated strings | Language switches updating everything immediately |
| ICU plural format | Pluralization rules that differ from English |
| Locale switcher preserving the current path | Not losing the user's place when they change language |
generateMetadata with getTranslations
|
SEO metadata translated per locale, not just page content |
hreflang via alternates.languages
|
Telling search engines these are language variants, not duplicates |
The rule that actually matters most: state and component logic should only ever hold translation keys, never resolved strings. Everything else in this setup, plurals, routing, metadata, follows naturally once that one boundary is respected consistently.
I use this exact next-intl setup on any project that needs more than one language, keeping translation keys in state and letting the render layer handle the actual translation every time.
Get the templates: https://pixelanas.gumroad.com
Have you built a multi-language Next.js app before? What tripped you up? Drop it below 👇
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)