Openclaudia-skills i18n
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".
git clone https://github.com/OpenClaudia/openclaudia-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/OpenClaudia/openclaudia-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/i18n" ~/.claude/skills/openclaudia-openclaudia-skills-i18n && rm -rf "$T"
skills/i18n/SKILL.mdInternationalize 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 (
) — must be 13+ with App Routerpackage.json - Check if i18n is already partially set up (look for
,next-intl
,next-i18next
routes)[locale] - 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
src/i18n/config.tsexport 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
src/i18n/routing.tsimport { 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
src/i18n/navigation.tsimport { createNavigation } from 'next-intl/navigation' import { routing } from './routing' export const { Link, redirect, usePathname, useRouter } = createNavigation(routing)
src/i18n/request.ts
src/i18n/request.tsimport { 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
[locale]Move all page content under
src/app/[locale]/:
-
Create
with:src/app/[locale]/layout.tsx
returning all localesgenerateStaticParams()
callsetRequestLocale(locale)
wrapping children<NextIntlClientProvider><html lang={locale} dir={rtlLocales.includes(locale) ? 'rtl' : 'ltr'}>- Hreflang
tags in<link>
for all locales +<head>x-default
-
Move existing pages into
src/app/[locale]/ -
Each page should call
for static generationsetRequestLocale(locale)
Step 7: Extract Strings into Translation Files
-
Create
with all user-facing strings organized by section:src/messages/en.json{ "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
, translates all strings, writesen.json{locale}.json - Codex prompt should include:
- The full
content (or path to read it)en.json - 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}
unchanged{name} - 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:
— check that all static pages generate correctlynpm run build - 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
conflicts with dynamicpublic/sitemap.xml
in dev mode — delete the static one or rename itsrc/app/sitemap.ts- Middleware matcher must exclude
,_next
,api
,sitemap
, and static asset pathsrobots
is critical — it keeps default locale URLs clean for SEO continuitylocalePrefix: 'as-needed'
prevents unwanted redirects that break SEO and confuse userslocaleDetection: false- 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