Nextjs locale standalone
Add locale-prefixed i18n routing to a single (non-monorepo) Next.js App Router site — a middleware/proxy that redirects `/` and any unprefixed path to `/<locale>/…` using the locale toggle's last choice (the NEXT_LOCALE cookie) then the browser's Accept-Language, a `[locale]` layout with a LocaleProvider + hooks, and a LocaleToggle that persists the choice. Use when adding bilingual/multilingual routing to a standalone Next.js site, building `/en-hk/…` `/zh-hk/…` URL namespaces, redirecting the root to a default-or-remembered locale, persisting a language switch across visits, or detecting browser language in middleware. For a monorepo that shares this logic across several apps via a workspace package, use nextjs-locale-monorepo instead.From its SKILL.md
npx -y skills add stealth-engine/skills --skill nextjs-locale-standaloneAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
3 things to look at
- reads credentialsReads from 1 credential source: `NEXT_LOCALE`.
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
- runs commandsInstructs the agent to run 3 commands, including `curl -sI localhost:3000/` and 2 more.
SKILL.md
8.8 KB, ~2.2k tokens by cl100k_base, as published. Nobody here has run it
Next.js locale routing — standalone site
Locale-prefixed routing for one Next.js App Router app, no shared package. Every
page lives under /<locale>/…; a proxy (middleware) sends unprefixed requests to
the right locale. Drop-in copy-paste files are in templates/.
Sibling skill: nextjs-locale-monorepo — the same behaviour factored into a shared workspace package for multi-app repos. Keep the two in sync.
The behaviour (the spec both skills implement)
- URLs are locale-namespaced:
/en-hk/about,/zh-hk/about. One locale is the default. - The proxy redirects unprefixed paths. A request to
/about(or/) with no known locale prefix is 307-redirected to/<locale>/about, preserving the query string. (URL hashes are client-side only — they never reach the server, so the proxy can't and needn't carry them; the LocaleToggle, running in the browser, does preserve the hash.) The locale is resolved by this priority:NEXT_LOCALEcookie — the toggle's last choice (if the visitor has ever switched).Accept-Language— the browser's preferred language (exact match, then language-only, e.g.zh→zh-hk).- Default locale.
- Already-prefixed paths pass through (
NextResponse.next()), and the proxy stamps the response:x-localeheader and a (re)write ofNEXT_LOCALEwhen it differs. It does not addVary: Accept-Language, Cookiehere — on a prefixed path the locale is fixed by the URL, so varying on those inputs would fragment the CDN cache for nothing. Only the negotiated redirect sets thatVary. - The toggle persists via that cookie. The LocaleToggle just navigates to
/<newLocale>/…; the proxy, seeing a prefixed path, writesNEXT_LOCALE— so the next time the visitor lands on/, step 1 sends them back to that choice. (The toggle doesn't set the cookie itself; the proxy is the single writer.)
Files to create
| File | Role |
|---|---|
lib/i18n.ts | supportedLanguages, locales, defaultLocale, cookie name, and pure detection helpers (no Next imports) |
proxy.ts (Next 16) / middleware.ts (≤15) | the redirect + stamp logic |
app/[locale]/layout.tsx | validates the locale, generateStaticParams, renders <html lang>, wraps in LocaleProvider |
app/locale-provider.tsx | 'use client' context + useCurrentLocale / useIsLocale |
components/LocaleToggle.tsx | switches locale by rewriting the first path segment |
Copy them from templates/ and adjust supportedLanguages.
Wiring steps
- Define locales in
lib/i18n.ts({ id, title, isDefault? }[]). - Move pages under
app/[locale]/. In the App Router the root layout — the topmostlayout.tsx— is the one that must render<html>+<body>, and Next errors if it doesn't. So makeapp/[locale]/layout.tsxbe that root layout and have noapp/layout.tsxat all — valid as long as every page lives under[locale]. (app/globals.css,app/global-error.tsx,app/api/*,app/icon.pngstay atapp/.) Don't keep a pass-throughapp/layout.tsxthat returns barechildren: it would be the root layout, and Next rejects a root layout without<html>/<body>. You also can't read the[locale]param up there, so there's no reason to keep it — delete it and let[locale]be root. - Add the proxy at the project root and the matcher (below).
- Add the toggle somewhere in the layout/nav.
Next 16: proxy.ts vs middleware.ts
Next 16 renamed the convention: the file is proxy.ts and the export is
export function proxy(...). On Next ≤15 it's middleware.ts /
export function middleware(...) — the body of this template is identical,
only the file and function names change. proxy is the go-forward direction —
middleware is deprecated; Next 16 still runs a middleware.ts but logs a
deprecation warning and is positioning it as the edge-only escape hatch (below),
not the default. So on Next 16+ default to proxy.ts; reach for
middleware.ts only on Next ≤15 or when you specifically need the edge runtime.
The template ships as proxy.ts. Migrate an existing
file with npx @next/codemod middleware-to-proxy . (it also renames config flags
like skipMiddlewareUrlNormalize → skipProxyUrlNormalize and types
NextMiddleware → NextProxy).
The rename is not purely cosmetic — proxy is Node.js-only. proxy.ts
defaults to the Node.js runtime and you cannot change it — setting the
runtime config in a proxy file throws. (Middleware historically ran on the
edge runtime; Node support went stable in 15.5, and 16 made Node the locked
default.) For this locale logic that's a non-issue — redirects, rewrites, and
cookie stamping don't need edge. But if you need the edge runtime, keep
middleware.ts (Next will add edge guidance for proxy in a later release).
Conceptually Next now frames this feature as a network boundary / gateway, to
be used sparingly (redirects, rewrites, header/cookie stamping, light gating) —
not a place for app logic. Don't trust it as the only auth gate: a matcher
change can silently drop coverage (including Server Functions), so verify auth in
the route/Server Function too.
The matcher excludes assets and API so they never redirect:
export const config = { matcher: ['/((?!_next(?:/|$)|api(?:/|$)|.*\\..*).*)'] };
The (?:/|$) after _next and api anchors them to a path segment, so real
pages like /apiary still get locale-prefixed (a bare api would skip them).
Also guard inside the function (shouldSkip) for /_next, /api,
/.well-known, /favicon, and anything with a file extension — the matcher and
the guard are belt-and-suspenders.
Gotchas
- Cookie defaults to
httpOnly: true— nothing readsNEXT_LOCALEfrom client JS (the toggle relies on the proxy rewriting it; the server reads it viarequest.cookies), so keep it out of reach of injected scripts. SethttpOnly: falseonly if client code/analytics genuinely must read the active locale.sameSite: 'lax',secureon HTTPS, 1-yearmaxAge. - Set
Vary: Accept-Language, Cookieon the negotiated redirect (the proxy does) so a CDN never serves one visitor's locale to another. Prefixed pass-through responses skip it — their locale is fixed by the URL, so the extraVarywould only fragment the cache. - Language-only fallback matters: a browser sending
zh-TWorzhshould resolve to yourzh-hk. The matcher inlib/i18n.tstries exact, then the primary subtag. - Don't redirect-loop: only redirect when
extractLocaleFromPathreturns null. Prefixed paths must pass through. - Invalid locales still 404, but via a prefixed path. The proxy can't tell an
unsupported first segment (
/xx/about) from a normal page path (/products/x) — both have a non-locale first segment — so it prefixes both:/xx/about→/en-hk/xx/about, which 404s because no such page route exists. So a bad locale still yields a 404, just under a/<default>/…URL rather than as a bare/xx/…. Don't expect the proxy to leave/xx/…untouched. The layout'snotFound()(below) is the backstop for the cases the proxy doesn't intercept. - Locale validity in the layout: keep
notFound()for an unknown[locale]as defense-in-depth — it catches an invalid locale that reaches[locale]directly (un-proxied render, an excluded matcher path) so it 404s instead of rendering with a boguslang.
Verify
curl -sI localhost:3000/→307to/<default>(no trailing slash; no cookie, no Accept-Language).curl -sI -H 'Accept-Language: zh-HK' localhost:3000/about→307to/zh-hk/about.curl -sI --cookie 'NEXT_LOCALE=zh-hk' localhost:3000/→307to/zh-hkeven with an EnglishAccept-Language(cookie wins).- Visiting
/zh-hk/xsetsNEXT_LOCALE=zh-hkin the responseSet-Cookie.
What ships with it: 5 files
11.3 KB alongside SKILL.md, 2 of them executable
templates/
- app-locale-layout.tsx1.3 KB
- app-locale-provider.tsx834 B
- lib-i18n.tsruns3.5 KB
- LocaleToggle.tsx2.1 KB
- proxy.tsruns3.5 KB