Implement i18n with Claude Code: Practical Next.js Guide
A practical Claude Code workflow for adding Next.js i18n with next-intl, translation checks, pitfalls, and review steps.
Decide the i18n Shape Before Asking Claude Code
i18n, short for internationalization, is not just a pile of translated JSON files. A production implementation touches routing, locale detection, metadata, dates, currency, plural rules, internal links, and checks that prevent missing translations from reaching users. Claude Code is useful here because it can read the existing app, edit several files, run commands, and summarize the remaining risk. It still needs a clear target. If the prompt only says “translate the app,” the result usually looks complete in the first language and fragile everywhere else.
The practical pattern I use is to ask Claude Code for the whole operating loop: the routing foundation, the message files, the screen migration, the key-drift check, and the review notes. A foundation is the small set of files that makes the app behave consistently in every locale. Once that foundation is stable, adding German, Indonesian, or Hindi is mostly content work instead of architecture work.
This guide uses Next.js App Router with next-intl. Keep the official references open while reviewing the generated code: the Claude Code documentation, next-intl routing setup, the Next.js internationalization guide, and MDN’s Intl reference. Those links matter because naming changed around recent Next.js versions, and date or currency formatting should not be hand-rolled.
flowchart LR
A[Requirements] --> B[Routing]
B --> C[Message JSON]
C --> D[Pages and metadata]
D --> E[Translation checks]
E --> F[SEO and release review]
This order keeps the review small. Routing comes first because changing URLs after publication can break search traffic and internal links. Message files come next because they are the contract between writers, developers, and reviewers. Only after those decisions are visible should Claude Code migrate components.
Minimal Next.js App Router Setup with next-intl
The example below uses ja as the default locale and adds en and de. Current Next.js 16 documentation uses proxy.ts for the routing interception file. Older Next.js 15 and earlier projects may still use middleware.ts for the same job. Tell Claude Code to inspect the installed Next.js version before editing, especially in a published site where a wrong filename can make every localized URL fail.
Start by agreeing on the file layout:
src/
app/
[locale]/
layout.tsx
page.tsx
i18n/
navigation.ts
request.ts
routing.ts
messages/
ja.json
en.json
de.json
proxy.ts
Centralize supported locales and localized paths in routing.ts. Keeping this in one place prevents scattered string replacements and makes pull request review much easier.
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['ja', 'en', 'de'],
defaultLocale: 'ja',
pathnames: {
'/': '/',
'/pricing': {
ja: '/pricing',
en: '/pricing',
de: '/preise',
},
'/docs': {
ja: '/docs',
en: '/docs',
de: '/dokumentation',
},
},
});
export type Locale = (typeof routing.locales)[number];
Create navigation wrappers so links and redirects understand the routing configuration. This avoids mixing locale-aware navigation with raw next/link usage.
// src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);
Read the matched locale in request.ts. If the request contains an unsupported locale, fall back to the default locale instead of trying to import a missing file.
// src/i18n/request.ts
import { hasLocale } from 'next-intl';
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
Add the proxy so localized routes are matched while API routes, Next.js internals, Vercel internals, and static files are skipped.
// src/proxy.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)',
};
Finally, validate the locale in the layout and provide messages to the client side. This example also enables static params for the supported locales.
// src/app/[locale]/layout.tsx
import { hasLocale, NextIntlClientProvider } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
type Props = {
children: React.ReactNode;
params: Promise<{ locale: string }>;
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
setRequestLocale(locale);
const messages = (await import(`@/messages/${locale}.json`)).default;
return (
<html lang={locale}>
<body>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
Use Translation Keys in Real Pages
Do not dump every label into common. That looks convenient in week one and becomes a maintenance problem when the app grows. Keep shared navigation and buttons in common, then give each page its own namespace such as HomePage, PricingPage, or DocsPage. The namespace is the group name for related translation keys.
{
"common": {
"language": {
"label": "Display language",
"ja": "日本語",
"en": "English",
"de": "Deutsch"
},
"nav": {
"docs": "Docs",
"pricing": "Pricing"
}
},
"HomePage": {
"title": "Deliver team knowledge in multiple languages",
"lead": "Show {count} articles in the reader's language.",
"cta": "Book a consultation"
}
}
{
"common": {
"language": {
"label": "表示言語",
"ja": "日本語",
"en": "English",
"de": "Deutsch"
},
"nav": {
"docs": "ドキュメント",
"pricing": "料金"
}
},
"HomePage": {
"title": "チームの知識を多言語で届ける",
"lead": "{count}件の記事を、読者の言語に合わせて表示します。",
"cta": "導入相談をする"
}
}
On the page, use getTranslations in Server Components. It is also a good fit for translated metadata because it works naturally with async server code.
// src/app/[locale]/page.tsx
import { getTranslations, setRequestLocale } from 'next-intl/server';
type Props = {
params: Promise<{ locale: string }>;
};
export default async function HomePage({ params }: Props) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: 'HomePage' });
return (
<main>
<h1>{t('title')}</h1>
<p>{t('lead', { count: 42 })}</p>
<a href={`/${locale}/pricing`}>{t('cta')}</a>
</main>
);
}
For language switching, keep the current route and only change the locale. Avoid replacing /${locale} with string operations; a path like /enquiry can be corrupted by an over-eager replacement.
// src/components/LanguageSwitcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation';
const languages = [
{ code: 'ja', label: '日本語' },
{ code: 'en', label: 'English' },
{ code: 'de', label: 'Deutsch' },
] as const;
export function LanguageSwitcher() {
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
return (
<div role="radiogroup" aria-label="Display language">
{languages.map((language) => (
<button
key={language.code}
type="button"
role="radio"
aria-checked={locale === language.code}
onClick={() => router.replace(pathname, { locale: language.code })}
>
{language.label}
</button>
))}
</div>
);
}
Stop Missing Translation Keys in CI
When Claude Code generates or updates message files, reviewers usually catch obvious bad wording but miss key drift. Key drift means one locale has HomePage.cta while another locale does not. The page may still compile if fallback behavior hides the mistake, but the released screen is incomplete.
Add this script as scripts/check-translations.mjs and run it with node scripts/check-translations.mjs.
// scripts/check-translations.mjs
import { readdir, readFile } from 'node:fs/promises';
const messagesDir = new URL('../src/messages/', import.meta.url);
const baseLocale = 'ja';
function flattenKeys(value, prefix = '') {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return [prefix];
}
return Object.entries(value).flatMap(([key, child]) => {
const nextPrefix = prefix ? `${prefix}.${key}` : key;
return flattenKeys(child, nextPrefix);
});
}
async function readMessages(locale) {
const file = new URL(`${locale}.json`, messagesDir);
return JSON.parse(await readFile(file, 'utf8'));
}
const files = await readdir(messagesDir);
const locales = files
.filter((file) => file.endsWith('.json'))
.map((file) => file.replace(/\.json$/, ''));
const baseKeys = new Set(flattenKeys(await readMessages(baseLocale)));
let hasError = false;
for (const locale of locales.filter((item) => item !== baseLocale)) {
const targetKeys = new Set(flattenKeys(await readMessages(locale)));
const missing = [...baseKeys].filter((key) => !targetKeys.has(key));
const extra = [...targetKeys].filter((key) => !baseKeys.has(key));
if (missing.length || extra.length) {
hasError = true;
console.error(`\n${locale}.json has translation key drift`);
if (missing.length) console.error('Missing:', missing.join(', '));
if (extra.length) console.error('Extra:', extra.join(', '));
}
}
if (hasError) {
process.exit(1);
}
console.log(`Translation keys are aligned for ${locales.length} locales.`);
This check does not judge whether the translation is elegant. It only proves that the contract is complete. That is still valuable. Once this runs in CI, human reviewers can spend their attention on calls to action, legal wording, pricing, and cultural nuance instead of manually counting keys.
Three Practical Use Cases
The first use case is a SaaS pricing page. Pricing copy combines currency, billing period, tax wording, and plan names. A literal translation can be wrong even when every key exists. Ask Claude Code to use Intl.NumberFormat or next-intl formatters instead of string concatenation, and review whether the final sentence order matches the local buying convention.
The second use case is a documentation site. Technical terms should not all be translated. Words such as middleware, proxy, locale, and namespace often need a short plain-language explanation on first use, then the original term should remain visible for search and developer recognition. Claude Code can help if you explicitly say, “explain technical terms on first use, then keep the original term.”
The third use case is an admin console. Here, the risk is not SEO but user mistakes. Labels such as “delete,” “disable,” and “revoke invitation” must be precise. The translation system should include button labels, confirmation dialogs, toast messages, and audit-log text so reviewers can inspect the whole action flow.
A fourth common case is a blog or media site. Translating the body is not enough. Title, description, canonical URL, alternate language links, OGP images, and internal links all need a release check. For a content-heavy site, write the rule into CLAUDE.md best practices so Claude Code knows that all locales and updatedDate values must move together.
Pitfalls That Cause Real Bugs
| Pitfall | What breaks | Practical fix |
|---|---|---|
| Deferring URL design | Redirects and indexed pages become messy | Choose prefix, subdomain, or domain routing first |
| Vague key names | title2 and text3 become impossible to review | Name keys by page and meaning, like PricingPage.planName |
| Overusing fallback text | Missing translations are hidden in production | Fail CI when keys are missing |
| Concatenating dates and currency | Word order and separators break by locale | Use Intl or next-intl formatters |
| Forgetting metadata | Search results stay in the default language | Translate generateMetadata too |
| Publishing raw machine translation | CTAs sound unnatural or too aggressive | Human-review CTA, legal, and pricing copy |
The most dangerous prompt is simply “translate all languages.” That instruction does not force Claude Code to preserve URL behavior, update metadata, check keys, or report risks. As with better prompting techniques, the expected output and the verification command should be part of the same request.
A Claude Code Prompt You Can Reuse
Use a prompt that names the files, constraints, and proof you expect:
Add i18n to the existing Next.js App Router project.
Assumptions:
- Use next-intl
- Supported locales are ja, en, de
- Default locale is ja
- Use prefix routing: /ja, /en, /de
- Inspect the Next.js version and choose proxy.ts or middleware.ts appropriately
Tasks:
1. Add src/i18n/routing.ts, navigation.ts, and request.ts
2. Add src/proxy.ts or src/middleware.ts
3. Add locale validation to app/[locale]/layout.tsx
4. Move existing homepage strings into messages/*.json
5. Add scripts/check-translations.mjs
6. Add lint:i18n to package.json
Constraints:
- Do not break existing published URLs without calling it out first
- Name translation keys by screen and meaning
- At the end, report changed files, commands run, and remaining risks
This prompt makes Claude Code act as both implementer and reviewer. It does not stop at generating files. It adds a guardrail, runs the check, and leaves a reviewable summary. If your team repeats this often, put the workflow into CLAUDE.md and pair it with your normal review checklist.
What I Verified in Practice
I tested the key-drift check with a small local message set by deleting HomePage.cta from the English file. The script correctly failed and printed the missing key. It did not, and cannot, decide whether “Book a consultation” is the right phrase for every market. That is the useful split: let Claude Code and CI catch mechanical omissions, then let humans review CTA tone, prices, legal copy, and cultural fit.
For the next step, add your i18n rules to CLAUDE.md and connect the check to CI. The Claude Code review workflow checklist is a good companion for release review. For team rollout or a multilingual content pipeline, the ClaudeCodeLab training and consultation page is the right place to start.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.