From hypervibe
Add internationalization (i18n) to an existing T3/Next.js project using next-intl with sub-path routing. Uses `localePrefix: "as-needed"` so the default locale keeps its URLs unprefixed (preserving existing SEO and backlinks), and only non-default locales get a prefix.
How this skill is triggered — by the user, by Claude, or both
Slash command
/hypervibe:add-i18nThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
- Detect the user's language from their messages and ALWAYS reply in that language (default: English). This applies to every user-facing message: questions, progress, confirmations, summaries, errors.
Adds internationalization with next-intl to the current project. Can be called by /bootstrap or standalone on an existing project.
Before anything else, invoke _check-deps i18n to detect whether next-intl is already in place:
result=$(node "${CLAUDE_SKILL_DIR}/../../scripts/check-deps.mjs" i18n)
i18n_ok=$(echo "$result" | node -e "console.log(JSON.parse(require('fs').readFileSync(0,'utf8')).i18n.ok)")
messages_dir=$(echo "$result" | node -e "console.log(JSON.parse(require('fs').readFileSync(0,'utf8')).i18n.messagesDir || '')")
i18n_ok = true -> re-configuration modenext-intl is already installed (messages folder: $messages_dir). Do NOT re-run the app directory restructuring (risk of breaking existing imports). Show a menu:
🌍 Internationalization is already set up on your project (messages:
$messages_dir)What do you want to do?
- Add a new language (e.g. you had FR/EN and you want to add ES, DE or another) - I create the
messages/<locale>.jsonfile based on the existing structure and automatically translate all the content- Change the default language (the one shown when a visitor arrives with no preference) - I update the next-intl config and the middleware
- Remove an existing language - I drop its messages file and update the list of supported locales
- Start over from scratch (only useful if the config is broken) - ⚠️ very destructive: restructuring the app directory cancels all the existing i18n routes. I strongly advise against it, prefer debugging case by case
- Something else - tell me what you want
Wait for the answer.
Depending on the answer:
| Choice | Action |
|---|---|
| 1 (add locale) | Ask for the locale code (e.g. es, de, it). Read the structure of an existing locale (e.g. messages/fr.json) to copy the structure. Automatically translate the entire file (every key) into the new language, without asking the user anything. Update the next-intl config (src/i18n/routing.ts or equivalent) to include the new locale in locales. The user can review/fix it afterward if they want. |
| 2 (default locale) | Ask for the new default locale. Update defaultLocale in the next-intl config. Remind the user that the unprefixed routes will now be served in this language. |
| 3 (remove locale) | Ask which locale to remove. Warn that if visitors had bookmarked the URL in that language, they will get a 404. Remove messages/<locale>.json and remove the locale from locales in the config. |
| 4 (start over) | Refuse firmly unless the user insists. If they insist: ask them to manually remove next-intl from package.json, the messages/ folder, and the [locale]/ structure of the app, then re-run. |
| 5 (something else) | Ask for clarification. Do not launch the full flow by default. |
At the end, jump straight to the final summary.
i18n_ok = false (not yet configured)Continue normally to Step 1. This is the initial installation flow.
Invoke the _detect-project-root internal skill to get PROJECT_NAME, WEB_DIR, IS_NEXTJS. Abort if IS_NEXTJS=no.
If called standalone, ask the user:
Which languages do you want to support? (e.g.,
fr en es) Which is the default locale? (first in the list if not specified)
NEVER ask the user "empty file or auto-translation?" - the answer is always full auto-translation. Claude takes charge of migrating all existing content (FR or whatever the default language is) into each requested locale. The user can review/fix it afterward if they want, but the site must be fully functional in all languages by the end of the skill - never half-broken.
cd into $WEB_DIR first - otherwise pnpm add creates an orphan package.json at the root of the parent folder.
cd "$WEB_DIR"
pnpm add next-intl
node "${CLAUDE_SKILL_DIR}/../../scripts/setup-i18n.mjs" \
--locales <comma-list> \
--default <default-locale>
(The script automatically detects the structure from $WEB_DIR - no need for --web-dir.)
The script creates in one shot:
src/i18n/routing.ts (array of locales + defaultLocale)src/i18n/navigation.ts (locale-aware Link / useRouter / usePathname / redirect)src/i18n/request.ts (messages loader)src/i18n/metadata.ts (localeAlternates() helper for per-page canonical + hreflang)messages/<locale>.json for each locale (FR/EN/ES/DE/IT/PT have ready-made templates, otherwise a neutral English template)src/app/[locale]/layout.tsx (minimal, metadataBase only - canonical/hreflang are per page, see Step 5bis)src/app/[locale]/[...rest]/page.tsx + src/app/[locale]/not-found.tsx (404 pattern, see Step 4)src/components/language-switcher.tsxIt also patches:
next.config.(ts|mjs|js): wraps the default export with withNextIntl(...)src/app/sitemap.ts (if it exists): expands it to one entry per locale × page with alternates.languagesIt refuses to run if src/i18n/ or src/app/[locale]/ already exist (delete manually to regenerate). If a regex patch fails (T3/next-intl have drifted), the script warns in its output.
If src/middleware.ts already exists (e.g. hostname routing), merge the next-intl logic with the existing one rather than overwriting. Otherwise, create the file:
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};
Move the existing pages (page.tsx, not-found.tsx, the politique-de-confidentialite/ and mentions-legales/ folders if present, and any other application page) from src/app/ to src/app/[locale]/. Do not move api/ (API routes stay at the root, no localization of endpoints).
404 pattern (mandatory - a not-found.tsx under [locale]/ is NOT enough): a not-found.tsx inside [locale]/ only renders for explicit notFound() calls - unknown URLs match no route at all, so Next.js silently serves its default bare 404 in production. The setup-i18n.mjs script has already created the two required pieces on the [locale] side: src/app/[locale]/[...rest]/page.tsx (catch-all that calls notFound()) and a basic src/app/[locale]/not-found.tsx (only if the project had none - if you moved a custom one in, delete the generated one). You must add the third piece yourself after the move: a root src/app/not-found.tsx global fallback for URLs that bypass the middleware. Since the root layout is a passthrough, it must render its own <html>/<body> with inline styles (no Tailwind classes - the CSS pipeline is not guaranteed in this context). Restyle it with the site's palette (background, accent color on the "404", button):
import Link from "next/link";
// Global fallback 404 - outside of locale context.
// The [locale]/[...rest] catch-all handles most cases; this covers
// any edge case that bypasses the middleware.
export default function GlobalNotFound() {
return (
<html lang="fr">
<body style={{ margin: 0, minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", padding: "2rem", fontFamily: "sans-serif", backgroundColor: "<site bg>", color: "<site fg>" }}>
<div>
<p style={{ fontSize: "6rem", fontWeight: 800, lineHeight: 1, margin: 0, color: "<site accent>" }}>404</p>
<h1 style={{ marginTop: "1.5rem", fontSize: "1.5rem" }}>Page introuvable</h1>
<Link href="/" style={{ display: "inline-block", marginTop: "2rem", padding: "0.75rem 1.5rem", fontWeight: 600, textDecoration: "none", backgroundColor: "<site accent>", color: "<contrast>" }}>
Retour à l’accueil
</Link>
</div>
</body>
</html>
);
}
At the end of the skill, verify the pattern: the three files exist ([locale]/[...rest]/page.tsx, [locale]/not-found.tsx, root not-found.tsx), and a request to a bogus URL on the dev server returns the custom 404, not "This page could not be found".
Catch-all conflict guard: if the project already has its own catch-all route that you moved under [locale]/ (e.g. [...slug]/page.tsx for CMS-style pages), Next.js will refuse to build with two different slug names at the same level. In that case delete the generated [locale]/[...rest]/ and make sure the project's own catch-all calls notFound() for unknown slugs - same effect.
Locale-aware navigation (mandatory): in every page/component moved under [locale]/ (and shared components in src/components/), replace the Next.js navigation imports with the locale-aware versions, otherwise a visitor browsing a non-default locale silently falls back to the default locale on every internal link:
import Link from "next/link" -> import { Link } from "~/i18n/navigation" (for internal links; keep next/link for external URLs and in the root not-found.tsx, which lives outside the locale context)useRouter / usePathname / redirect from next/navigation -> same names from ~/i18n/navigation when they target internal routes (usePathname from next/navigation would also return the locale-prefixed path and break active-link logic)Privacy policy note: if the politique-de-confidentialite/page.tsx page is the one generated by /bootstrap (signature: import of SUBPROCESSORS, h1 "Politique de confidentialité", no use of next-intl), the setup-i18n.mjs script has already automatically upgraded it to the i18n version (template + messages/<locale>.json keys + i18n.<locale> blocks in subprocessors.json). So you just have to move it as is, no need to translate it by hand in Step 5.
Legal pages note (intentionally not translated): mentions-legales/page.tsx AND cgv/page.tsx (if present) are French legal documents specific to French law. You move them under [locale]/ like the other pages, but you do NOT translate their content in Step 5. They stay in French even for visitors of other locales. This is the standard practice on French multilingual sites (Doctolib, Decathlon, BlaBlaCar, etc. keep their legal notices and terms in FR across all their versions). Mention this choice explicitly to the user in the final recap.
Non-templated Stripe pages note: the pages generated by /add-stripe (/pricing, /payment/success, /payment/cancel) are custom per project - Claude writes them while integrating the user's real products/prices. No static template, so no automatic upgrade via the manifests. You handle these pages in Step 5 like any other custom page: extract the hardcoded strings to messages/<locale>.json (namespace pricing, paymentSuccess, paymentCancel), translate into each locale, then replace the literals with t("key").
Admin pages note (intentionally not translated): the pages under src/app/admin/ (generated by /add-role, /add-agent-dashboard, the bootstrap's /admin/*, etc.) are a surface reserved for the project's admin/owner - not seen by public visitors. Hypervibe convention: admin pages stay in French regardless of the site's locale, like the legal pages. You move them under [locale]/admin/ during Step 4 but you do not translate their content in Step 5, and you do not extract their strings to messages/. Rationale: (a) audience = 1 person who speaks FR, (b) huge string volume (tables, forms, states), (c) translation ROI close to nil. Mention this choice in the final recap.
Replace the root src/app/layout.tsx with a minimal version:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return children;
}
Augment src/app/[locale]/layout.tsx (created by the script) with the providers that lived in the old root layout:
TRPCReactProvider (if tRPC is configured)className={geist.variable} on the <html> (if Geist)metadata that was in the root layout (title, openGraph, etc.) -> merged into the generateMetadata of [locale]/layout.tsxThe script has already put the essentials: NextIntlClientProvider, setRequestLocale, notFound() check, generateStaticParams, and metadataBase. Do NOT add alternates (canonical/hreflang) to this layout - layout metadata is inherited by every child page, so all pages would canonicalize to the home and Google would treat the whole site as duplicates of it (bug shipped to hyperprompt/hyperarme before this rule). Canonical + hreflang go per page: see Step 5bis.
Absolute rule: by the end of this skill, the site must be fully translated into all requested locales. No empty messages file, no broken page. Never ask the user whether they want to "scaffold only" or "auto-translate" - always translate.
For each page/component moved into src/app/[locale]/ (and any shared component under src/components/, src/app/_components/, etc. that contains hardcoded text in the default language):
title/alt/aria-label/placeholder attributes, metadata.title/description values, contents of <button>, <a>, <p>, <h1-6>, etc.) to messages/<defaultLocale>.json, grouped by logical namespace (common, home, header, footer, <page>, ...).t("key") after importing useTranslations (client component) or getTranslations (server component) with the right namespace.messages/<defaultLocale>.json into each other requested messages/<locale>.json, keeping the same namespace and key structure.Expected translation quality: excellent for FR <-> EN/ES/IT/DE/PT (mastered languages). For other languages, translate as best as possible and mention in the final summary that the user should have it proofread by a native speaker.
Tip: if the string volume is large (>50), proceed file by file (page by page) rather than in one big patch - readable diff, less risk of breaking imports.
For every public page under [locale]/ (skip admin/* and auth pages), add the alternates via the localeAlternates() helper generated by the script in src/i18n/metadata.ts:
import { localeAlternates } from "~/i18n/metadata";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
// ...existing title/description if any...
alternates: localeAlternates(locale, "/exercices"), // home: path = ""
};
}
export const metadata : convert them to generateMetadata as above, spreading the existing fields.path is the route without locale prefix, starting with / (home: empty string).alternates in a layout (see Step 4).Verification (do it): run the dev server and curl one subpage per locale - the <link rel="canonical"> must point to that page's own URL (not the home), and the hreflang links must point to the same path across locales, plus an x-default.
Add i18n to the Stack section. Add a convention:
useTranslations('namespace') in client components, getTranslations('namespace') in server components. Message files in messages/<locale>.json."Tell the user:
localePrefix: "as-needed" : URLs for the default locale stay unprefixed (/about, /contact) - your existing SEO, backlinks, and Search Console data are preserved. Only the other locales get a prefix (/en/about, /en/contact).messages/ - add translations therex-default), and the sitemap lists every locale variantconst t = await getTranslations("common")const t = useTranslations("common")npx claudepluginhub flavien-ia/hypervibe-harness --plugin hypervibeAdds internationalization to Next.js App Router projects using next-intl, with locale routing, translation files, hreflang sitemaps, and bulk translation across 14+ languages.
Implements Next.js 16 internationalization with next-intl or a DIY dictionary approach. Covers locale routing, translations, formatting, SEO, and RTL support.
Guides adding a new language to a Next.js + next-intl app: translates message JSON, updates routing config, middleware, and UI toggle.