I18n skill
Add full internationalization (i18n) to a Next.js project using next-intl.
by OpenClaudia·MIT license·★ 705 Stars on the repo·GitHub ↗
npx degit OpenClaudia/openclaudia-skills/skills/i18n#main ~/.claude/skills/i18nChecked ·commit main
Files of I18n
Show the full text273 lines
Internationalize a Next.js Project
Add complete internationalization to a Next.js (App Router) project using next-intl v4. This skill handles routing, translation files, sitemap hreflang, and bulk translation across all locales.
Step 1: Assess the Project
- Check the Next.js version (
package.json) — must be 13+ with App Router - Check if i18n is already partially set up (look for
next-intl,next-i18next,[locale]routes) - Identify all pages/routes that need translation
- Identify all user-facing strings (hardcoded text in components)
- Ask the user which locales to support (default recommendation: en, es, fr, de, pt, ja, ar, zh, zh-tw, id, vi, ms, ru, hi)
Step 2: Install Dependencies
npm install next-intl
Step 3: Create i18n Configuration Files
Create 4 files under src/i18n/:
src/i18n/config.ts
export const locales = ['en', 'es', 'fr', 'de', 'pt', 'ja', 'ar', 'zh', 'zh-tw', 'id', 'vi', 'ms', 'ru', 'hi'] as const
export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'en'
export const localeNames: Record<Locale, string> = {
en: 'English',
es: 'Espanol',
fr: 'Francais',
de: 'Deutsch',
pt: 'Portugues',
ja: '日本語',
ar: 'العربية',
zh: '简体中文',
'zh-tw': '繁體中文',
id: 'Bahasa Indonesia',
vi: 'Tieng Viet',
ms: 'Bahasa Melayu',
ru: 'Русский',
hi: 'हिन्दी',
}
export const rtlLocales: Locale[] = ['ar']
src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing'
import { defaultLocale, locales } from './config'
export const routing = defineRouting({
locales,
defaultLocale,
localePrefix: 'as-needed', // English URLs stay clean, other locales get /es/, /fr/, etc.
})
src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing)
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.includes(locale as any)) {
locale = routing.defaultLocale
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
}
})
Step 4: Create Middleware
Create src/middleware.ts:
import createMiddleware from 'next-intl/middleware'
import { routing } from '@/i18n/routing'
export default createMiddleware({
...routing,
localeDetection: false, // Don't auto-redirect based on Accept-Language
})
export const config = {
matcher: ['/((?!_next|api|images|fonts|favicon|sitemap|robots).*)'],
}
Key decision: localeDetection: false prevents auto-redirecting users based on browser language. This keeps English URLs stable for SEO. Users can manually switch languages via a language selector.
Step 5: Update next.config
Wrap the existing config with createNextIntlPlugin:
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
// ... existing config ...
export default withNextIntl(nextConfig)
Step 6: Add [locale] Dynamic Route
Move all page content under src/app/[locale]/:
Create
src/app/[locale]/layout.tsxwith:generateStaticParams()returning all localessetRequestLocale(locale)call<NextIntlClientProvider>wrapping children<html lang={locale} dir={rtlLocales.includes(locale) ? 'rtl' : 'ltr'}>- Hreflang
<link>tags in<head>for all locales +x-default
Move existing pages into
src/app/[locale]/Each page should call
setRequestLocale(locale)for static generation
Step 7: Extract Strings into Translation Files
Create
src/messages/en.jsonwith all user-facing strings organized by section:{ "common": { "signIn": "Sign In", ... }, "tools": { "tool-slug": { "title": "...", "description": "..." } }, "faq": { "tool-slug": [{ "question": "...", "answer": "..." }] } }Replace all hardcoded strings in components with
useTranslations():const t = useTranslations('common') return <button>{t('signIn')}</button>For server components, use
getTranslations():const t = await getTranslations('common')
Step 8: Translate to All Locales
For each non-English locale, create src/messages/{locale}.json with the same structure as en.json.
Translation Strategy
Use parallel Codex agents via the codex-tasks skill to save Claude credits:
- Launch one Codex task per locale (up to 7 in parallel) using
/codex-tasks - Each task reads
en.json, translates all strings, writes{locale}.json - Codex prompt should include:
- The full
en.jsoncontent (or path to read it) - Target language name and locale code
- Instructions:
- Translate naturally, not literally
- Keep technical terms in English (PowerPoint, PDF, API, etc.)
- Preserve JSON structure exactly (same keys, same nesting)
- Preserve interpolation variables like
{count},{name}unchanged - Write the result to
src/messages/{locale}.json
- The full
- After Codex tasks complete, verify the results using the verification script below — Codex output quality varies and must be checked
Verification
After translation, run a verification script to catch issues:
import json
locales = ['es', 'fr', 'de', 'pt', 'ja', 'ar', 'zh', 'zh-tw', 'id', 'vi', 'ms', 'ru', 'hi']
english_words = ['the ', 'and ', 'you ', 'your ', 'our ', 'this ', 'that ', 'with ', 'from ', 'will ']
with open('src/messages/en.json') as f:
en = json.load(f)
for loc in locales:
with open(f'src/messages/{loc}.json') as f:
data = json.load(f)
# Check: missing sections
missing = [s for s in en if s not in data]
# Check: residual English content
eng_count = 0
def check(d):
nonlocal eng_count # won't work in inline script; use list trick
if isinstance(d, dict):
for v in d.values(): check(v)
elif isinstance(d, str):
if sum(1 for w in english_words if w in d.lower()) >= 3:
eng_count += 1
check(data)
status = 'OK' if not missing and eng_count == 0 else 'ISSUES'
print(f'{loc}: {status} (missing={len(missing)}, english={eng_count})')
Step 9: Update Sitemap with Hreflang
Update src/app/sitemap.ts to include hreflang alternates:
import { MetadataRoute } from 'next'
import { locales } from '@/i18n/config'
const baseUrl = 'https://www.example.com'
function buildAlternates(path: string): Record<string, string> {
const alternates: Record<string, string> = {}
for (const locale of locales) {
const prefix = locale === 'en' ? '' : `/${locale}`
alternates[locale] = `${baseUrl}${prefix}${path}`
}
return alternates
}
export default function sitemap(): MetadataRoute.Sitemap {
return pages.map((path) => ({
url: `${baseUrl}${path}`,
lastModified: new Date(),
alternates: { languages: buildAlternates(path) },
}))
}
Important: Generate one canonical URL per page with hreflang alternates, NOT one URL per locale. This prevents duplicate content in search results.
Step 10: Add Language Selector (Optional)
Add a language switcher component that uses useRouter and usePathname from @/i18n/navigation to switch locales while preserving the current path.
Step 11: Verify
- Build the project:
npm run build— check that all static pages generate correctly - Test English URLs have no prefix:
https://example.com/tools - Test locale URLs have prefix:
https://example.com/es/tools - Verify sitemap has hreflang alternates
- Check RTL rendering for Arabic
- Run the translation verification script from Step 8
Common Pitfalls
public/sitemap.xmlconflicts with dynamicsrc/app/sitemap.tsin dev mode — delete the static one or rename it- Middleware matcher must exclude
_next,api,sitemap,robots, and static asset paths localePrefix: 'as-needed'is critical — it keeps default locale URLs clean for SEO continuitylocaleDetection: falseprevents unwanted redirects that break SEO and confuse users- Large translation files (5000+ lines per locale) can make git pushes fail — use
git config http.postBuffer 524288000 - Verify translations thoroughly — automated translation often produces mixed-language output; always verify with the English word detection script after Codex tasks complete
Locale Count Reference
- 14 locales x N pages = 14N static pages at build time
- Each locale JSON file is typically 2-5x the size of en.json (CJK characters, verbose languages)
- Build time increases linearly with locale count
| 1 | |
| 2 | name i18n |
| 3 | description Add full internationalization (i18n) to a Next.js project using next-intl. Supports 14+ languages, SEO-friendly locale routing, hreflang sitemaps, and bulk translation. Use when the user asks to "internationalize", "add i18n", "add translations", "multi-language", "localize", "add language support", or "translate my site". |
| 4 | user_invocable true |
| 5 | |
| 6 | |
| 7 | # Internationalize a Next.js Project |
| 8 | |
| 9 | Add complete internationalization to a Next.js (App Router) project using **next-intl v4**. This skill handles routing, translation files, sitemap hreflang, and bulk translation across all locales. |
| 10 | |
| 11 | ## Step 1: Assess the Project |
| 12 | |
| 13 | Check the Next.js version (`package.json`) — must be 13+ with App Router |
| 14 | Check if i18n is already partially set up (look for `next-intl`, `next-i18next`, `[locale]` routes) |
| 15 | Identify all pages/routes that need translation |
| 16 | Identify all user-facing strings (hardcoded text in components) |
| 17 | Ask the user which locales to support (default recommendation: en, es, fr, de, pt, ja, ar, zh, zh-tw, id, vi, ms, ru, hi) |
| 18 | |
| 19 | ## Step 2: Install Dependencies |
| 20 | |
| 21 | |
| 22 | npm install next-intl |
| 23 | |
| 24 | |
| 25 | ## Step 3: Create i18n Configuration Files |
| 26 | |
| 27 | Create 4 files under `src/i18n/`: |
| 28 | |
| 29 | ### `src/i18n/config.ts` |
| 30 | |
| 31 | export const locales = ['en', 'es', 'fr', 'de', 'pt', 'ja', 'ar', 'zh', 'zh-tw', 'id', 'vi', 'ms', 'ru', 'hi'] as const |
| 32 | |
| 33 | export type Locale = (typeof locales)[number] |
| 34 | export const defaultLocale: Locale = 'en' |
| 35 | |
| 36 | export const localeNames: Record<Locale, string> = { |
| 37 | en: 'English', |
| 38 | es: 'Espanol', |
| 39 | fr: 'Francais', |
| 40 | de: 'Deutsch', |
| 41 | pt: 'Portugues', |
| 42 | ja: '日本語', |
| 43 | ar: 'العربية', |
| 44 | zh: '简体中文', |
| 45 | 'zh-tw': '繁體中文', |
| 46 | id: 'Bahasa Indonesia', |
| 47 | vi: 'Tieng Viet', |
| 48 | ms: 'Bahasa Melayu', |
| 49 | ru: 'Русский', |
| 50 | hi: 'हिन्दी', |
| 51 | } |
| 52 | |
| 53 | export const rtlLocales: Locale[] = ['ar'] |
| 54 | |
| 55 | |
| 56 | ### `src/i18n/routing.ts` |
| 57 | |
| 58 | import { defineRouting } from 'next-intl/routing' |
| 59 | import { defaultLocale, locales } from './config' |
| 60 | |
| 61 | export const routing = defineRouting({ |
| 62 | locales, |
| 63 | defaultLocale, |
| 64 | localePrefix: 'as-needed', // English URLs stay clean, other locales get /es/, /fr/, etc. |
| 65 | }) |
| 66 | |
| 67 | |
| 68 | ### `src/i18n/navigation.ts` |
| 69 | |
| 70 | import { createNavigation } from 'next-intl/navigation' |
| 71 | import { routing } from './routing' |
| 72 | |
| 73 | export const { Link, redirect, usePathname, useRouter } = createNavigation(routing) |
| 74 | |
| 75 | |
| 76 | ### `src/i18n/request.ts` |
| 77 | |
| 78 | import { getRequestConfig } from 'next-intl/server' |
| 79 | import { routing } from './routing' |
| 80 | |
| 81 | export default getRequestConfig(async ({ requestLocale }) => { |
| 82 | let locale = await requestLocale |
| 83 | if (!locale || !routing.locales.includes(locale as any)) { |
| 84 | locale = routing.defaultLocale |
| 85 | } |
| 86 | return { |
| 87 | locale, |
| 88 | messages: (await import(`../messages/${locale}.json`)).default, |
| 89 | } |
| 90 | }) |
| 91 | |
| 92 | |
| 93 | ## Step 4: Create Middleware |
| 94 | |
| 95 | Create `src/middleware.ts`: |
| 96 | |
| 97 | import createMiddleware from 'next-intl/middleware' |
| 98 | import { routing } from '@/i18n/routing' |
| 99 | |
| 100 | export default createMiddleware({ |
| 101 | ...routing, |
| 102 | localeDetection: false, // Don't auto-redirect based on Accept-Language |
| 103 | }) |
| 104 | |
| 105 | export const config = { |
| 106 | matcher: ['/((?!_next|api|images|fonts|favicon|sitemap|robots).*)'], |
| 107 | } |
| 108 | |
| 109 | |
| 110 | **Key decision**: `localeDetection: false` prevents auto-redirecting users based on browser language. This keeps English URLs stable for SEO. Users can manually switch languages via a language selector. |
| 111 | |
| 112 | ## Step 5: Update next.config |
| 113 | |
| 114 | Wrap the existing config with `createNextIntlPlugin`: |
| 115 | |
| 116 | |
| 117 | import createNextIntlPlugin from 'next-intl/plugin' |
| 118 | const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts') |
| 119 | |
| 120 | // ... existing config ... |
| 121 | export default withNextIntl(nextConfig) |
| 122 | |
| 123 | |
| 124 | ## Step 6: Add `[locale]` Dynamic Route |
| 125 | |
| 126 | Move all page content under `src/app/[locale]/`: |
| 127 | |
| 128 | Create `src/app/[locale]/layout.tsx` with: |
| 129 | `generateStaticParams()` returning all locales |
| 130 | `setRequestLocale(locale)` call |
| 131 | `<NextIntlClientProvider>` wrapping children |
| 132 | `<html lang={locale} dir={rtlLocales.includes(locale) ? 'rtl' : 'ltr'}>` |
| 133 | Hreflang `<link>` tags in `<head>` for all locales + `x-default` |
| 134 | |
| 135 | Move existing pages into `src/app/[locale]/` |
| 136 | Each page should call `setRequestLocale(locale)` for static generation |
| 137 | |
| 138 | ## Step 7: Extract Strings into Translation Files |
| 139 | |
| 140 | Create `src/messages/en.json` with all user-facing strings organized by section: |
| 141 | |
| 142 | { |
| 143 | "common": { "signIn": "Sign In", ... }, |
| 144 | "tools": { "tool-slug": { "title": "...", "description": "..." } }, |
| 145 | "faq": { "tool-slug": [{ "question": "...", "answer": "..." }] } |
| 146 | } |
| 147 | |
| 148 | |
| 149 | Replace all hardcoded strings in components with `useTranslations()`: |
| 150 | |
| 151 | const t = useTranslations('common') |
| 152 | return <button>{t('signIn')}</button> |
| 153 | |
| 154 | |
| 155 | For server components, use `getTranslations()`: |
| 156 | |
| 157 | const t = await getTranslations('common') |
| 158 | |
| 159 | |
| 160 | ## Step 8: Translate to All Locales |
| 161 | |
| 162 | For each non-English locale, create `src/messages/{locale}.json` with the same structure as `en.json`. |
| 163 | |
| 164 | ### Translation Strategy |
| 165 | |
| 166 | Use **parallel Codex agents** via the `codex-tasks` skill to save Claude credits: |
| 167 | |
| 168 | Launch one Codex task per locale (up to 7 in parallel) using `/codex-tasks` |
| 169 | Each task reads `en.json`, translates all strings, writes `{locale}.json` |
| 170 | Codex prompt should include: |
| 171 | The full `en.json` content (or path to read it) |
| 172 | Target language name and locale code |
| 173 | Instructions: |
| 174 | Translate naturally, not literally |
| 175 | Keep technical terms in English (PowerPoint, PDF, API, etc.) |
| 176 | Preserve JSON structure exactly (same keys, same nesting) |
| 177 | Preserve interpolation variables like `{count}`, `{name}` unchanged |
| 178 | Write the result to `src/messages/{locale}.json` |
| 179 | After Codex tasks complete, **verify the results** using the verification script below — Codex output quality varies and must be checked |
| 180 | |
| 181 | ### Verification |
| 182 | |
| 183 | After translation, run a verification script to catch issues: |
| 184 | |
| 185 | |
| 186 | import json |
| 187 | |
| 188 | locales = ['es', 'fr', 'de', 'pt', 'ja', 'ar', 'zh', 'zh-tw', 'id', 'vi', 'ms', 'ru', 'hi'] |
| 189 | english_words = ['the ', 'and ', 'you ', 'your ', 'our ', 'this ', 'that ', 'with ', 'from ', 'will '] |
| 190 | |
| 191 | with open('src/messages/en.json') as f: |
| 192 | en = json.load(f) |
| 193 | |
| 194 | for loc in locales: |
| 195 | with open(f'src/messages/{loc}.json') as f: |
| 196 | data = json.load(f) |
| 197 | |
| 198 | # Check: missing sections |
| 199 | missing = [s for s in en if s not in data] |
| 200 | |
| 201 | # Check: residual English content |
| 202 | eng_count = 0 |
| 203 | def check(d): |
| 204 | nonlocal eng_count # won't work in inline script; use list trick |
| 205 | if isinstance(d, dict): |
| 206 | for v in d.values(): check(v) |
| 207 | elif isinstance(d, str): |
| 208 | if sum(1 for w in english_words if w in d.lower()) >= 3: |
| 209 | eng_count += 1 |
| 210 | check(data) |
| 211 | |
| 212 | status = 'OK' if not missing and eng_count == 0 else 'ISSUES' |
| 213 | print(f'{loc}: {status} (missing={len(missing)}, english={eng_count})') |
| 214 | |
| 215 | |
| 216 | ## Step 9: Update Sitemap with Hreflang |
| 217 | |
| 218 | Update `src/app/sitemap.ts` to include hreflang alternates: |
| 219 | |
| 220 | |
| 221 | import { MetadataRoute } from 'next' |
| 222 | import { locales } from '@/i18n/config' |
| 223 | |
| 224 | const baseUrl = 'https://www.example.com' |
| 225 | |
| 226 | function buildAlternates(path: string): Record<string, string> { |
| 227 | const alternates: Record<string, string> = {} |
| 228 | for (const locale of locales) { |
| 229 | const prefix = locale === 'en' ? '' : `/${locale}` |
| 230 | alternates[locale] = `${baseUrl}${prefix}${path}` |
| 231 | } |
| 232 | return alternates |
| 233 | } |
| 234 | |
| 235 | export default function sitemap(): MetadataRoute.Sitemap { |
| 236 | return pages.map((path) => ({ |
| 237 | url: `${baseUrl}${path}`, |
| 238 | lastModified: new Date(), |
| 239 | alternates: { languages: buildAlternates(path) }, |
| 240 | })) |
| 241 | } |
| 242 | |
| 243 | |
| 244 | **Important**: Generate one canonical URL per page with hreflang alternates, NOT one URL per locale. This prevents duplicate content in search results. |
| 245 | |
| 246 | ## Step 10: Add Language Selector (Optional) |
| 247 | |
| 248 | Add a language switcher component that uses `useRouter` and `usePathname` from `@/i18n/navigation` to switch locales while preserving the current path. |
| 249 | |
| 250 | ## Step 11: Verify |
| 251 | |
| 252 | Build the project: `npm run build` — check that all static pages generate correctly |
| 253 | Test English URLs have no prefix: `https://example.com/tools` |
| 254 | Test locale URLs have prefix: `https://example.com/es/tools` |
| 255 | Verify sitemap has hreflang alternates |
| 256 | Check RTL rendering for Arabic |
| 257 | Run the translation verification script from Step 8 |
| 258 | |
| 259 | ## Common Pitfalls |
| 260 | |
| 261 | **`public/sitemap.xml`** conflicts with dynamic `src/app/sitemap.ts` in dev mode — delete the static one or rename it |
| 262 | **Middleware matcher** must exclude `_next`, `api`, `sitemap`, `robots`, and static asset paths |
| 263 | **`localePrefix: 'as-needed'`** is critical — it keeps default locale URLs clean for SEO continuity |
| 264 | **`localeDetection: false`** prevents unwanted redirects that break SEO and confuse users |
| 265 | **Large translation files** (5000+ lines per locale) can make git pushes fail — use `git config http.postBuffer 524288000` |
| 266 | **Verify translations thoroughly** — automated translation often produces mixed-language output; always verify with the English word detection script after Codex tasks complete |
| 267 | |
| 268 | ## Locale Count Reference |
| 269 | |
| 270 | 14 locales x N pages = 14N static pages at build time |
| 271 | Each locale JSON file is typically 2-5x the size of en.json (CJK characters, verbose languages) |
| 272 | Build time increases linearly with locale count |
| 273 |
Discussion
Alternatives
Browse more free Claude skills or everything in Marketing.