Use Cases (Updated: 6/2/2026)

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.

Implement i18n with Claude Code: Practical Next.js Guide

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

PitfallWhat breaksPractical fix
Deferring URL designRedirects and indexed pages become messyChoose prefix, subdomain, or domain routing first
Vague key namestitle2 and text3 become impossible to reviewName keys by page and meaning, like PricingPage.planName
Overusing fallback textMissing translations are hidden in productionFail CI when keys are missing
Concatenating dates and currencyWord order and separators break by localeUse Intl or next-intl formatters
Forgetting metadataSearch results stay in the default languageTranslate generateMetadata too
Publishing raw machine translationCTAs sound unnatural or too aggressiveHuman-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.

#Claude Code #i18n #internationalization #Next.js #next-intl
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.