Nextjs locale monorepo
Add locale-prefixed i18n routing across a Next.js monorepo (Turborepo / pnpm workspaces) by extracting the engine into a shared workspace package that every app consumes — locale detection + a middleware/proxy factory + a `use client` LocaleProvider/hooks, plus a shared supportedLanguages config. Each app redirects `/` and unprefixed paths to `/<locale>/…` from the toggle's last choice (the NEXT_LOCALE cookie) then the browser's Accept-Language, wiring only a thin middleware + `[locale]` layout. Use when several apps in one repo need consistent `/en-hk/…` `/zh-hk/…` routing, sharing locale logic via a workspace package instead of copy-paste, dual ESM/CJS build of a package that ships a client provider, Turbo build-ordering so the lib builds before the apps, or per-app locale wiring. For a single standalone site, use nextjs-locale-standalone instead.From its SKILL.md
npx -y skills add stealth-engine/skills --skill nextjs-locale-monorepoAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 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 6 commands, including `curl -sI localhost:3000/` and 5 more.
SKILL.md
8.8 KB, ~2.1k tokens by cl100k_base, as published. Nobody here has run it
Next.js locale routing — monorepo (shared package)
The same locale-routing behaviour as nextjs-locale-standalone, but the engine
lives in one workspace package (i18n-routing) that every app imports, so
detection, the cookie name, and the provider/hooks stay identical across apps.
Read that sibling skill for the behaviour spec; this skill is about the
package + wiring. Copy-paste files are in templates/.
The behaviour (shared with the standalone skill)
Unprefixed requests 307-redirect to /<locale>/…, locale chosen by priority:
NEXT_LOCALE cookie (toggle's last choice) → Accept-Language (browser) →
default. The redirect carries Vary: Accept-Language, Cookie (its locale was
negotiated from them); prefixed paths pass through with x-locale + the
NEXT_LOCALE cookie stamped but no such Vary (their locale is fixed by the
URL, so it'd only fragment the cache). The LocaleToggle navigates to /<newLocale>/…; the
middleware is the single writer of the cookie, so the choice persists. The
NEXT_LOCALE cookie defaults to httpOnly: true — it's read server-side
(request.cookies) only, never from client JS, so keep it out of reach of
injected scripts. Set httpOnly: false only if client JS genuinely must read it.
It also defaults to a 1-year maxAge (defaultCookieOptions in
templates/package/src/config.ts) so the toggle's choice survives a browser
restart — without it the cookie is session-only and the "choice persists" promise
breaks. Full explanation + a toggle template: see nextjs-locale-standalone.
Architecture
packages/
i18n-routing/ # the engine — detection, middleware factory, provider/hooks
src/{config,utils,middleware,provider,hooks,client,index}.ts(x)
package.json # exports: "." (server) + "./client" (provider/hooks)
tsup.config.ts # dual CJS/ESM, dts, preserves "use client"
configs/ # shared supportedLanguages (one source of truth)
apps/
web/ marketing/ … # each: a thin middleware.ts + app/[locale]/layout.tsx
Two packages, deliberately:
i18n-routing— the logic. Ships two entry points: the default export is server-safe (config + utils + middleware factory);i18n-routing/clientis the'use client'provider + hooks. Keeping them separate stops the'use client'boundary from poisoning middleware/server imports.configs(or any shared package) — exportssupportedLanguages({ id, title, isDefault? }[]). Apps and the middleware read locales from here, so adding a language is a one-line change in one place.
Building the package (the parts that bite)
Use the templates/package/ files as-is. What matters:
- Dual build with tsup, two entries (
index,client),format: ['cjs','esm'],dts: true,external: ['react','react-dom','next'], and cruciallytreeshake: false— tree-shaking strips the'use client'directive and the provider breaks at runtime. (templates/package/tsup.config.ts.) exportsmap with.and./client, each pointing attypes+import(esm) +require(cjs). (templates/package/package.json.)- Peer deps
next,react,react-dom— never bundle them. - The middleware factory
createI18nMiddleware(config)is pure logic — it returnsNextResponse | undefinedand imports nothing app-specific, so it is unit-testable without a running app.
Wiring each app
-
Depend on both packages in the app's
package.json:"i18n-routing": "workspace:^","configs": "workspace:^". -
Thin
middleware.ts(templates/app-middleware.ts): build the config from the shared languages and delegate. This is also where you compose app-specific middleware (auth, ananon_idcookie, feature-flag gating) around the i18n redirect.import { supportedLanguages } from 'configs/locale'; import { createI18nMiddleware, i18nConfig } from 'i18n-routing'; const i18n = createI18nMiddleware(i18nConfig(supportedLanguages)); export function middleware(req: NextRequest) { return i18n(req) ?? NextResponse.next(); } export const config = { matcher: ['/((?!_next(?:/|$)|api(?:/|$)|.*\\..*).*)'] };Next 16: name this thin app file
proxy.tsand the exportproxy(export function proxy(req)) — the body and the sharedcreateI18nMiddlewarefactory are unchanged; only the app-level file/function names follow the new convention (npx @next/codemod middleware-to-proxy .automates it). One real caveat:proxyis Node.js-only (theruntimeconfig throws), whereasmiddlewarecould run on the edge — locale redirects don't need edge, but if any app's request layer does, keepmiddleware.tsthere. The shared factory is runtime-agnostic, so apps can mix conventions. -
app/[locale]/layout.tsx(templates/app-locale-layout.tsx): validate the locale,generateStaticParamsfromsupportedLanguages, and wrap children inLocaleProviderfromi18n-routing/client(not the root import). This is the root layout — render<html lang={locale}>/<body>here and keep noapp/layout.tsx(every page lives under[locale]). -
components/LocaleToggle.tsx(templates/LocaleToggle.tsx): a'use client'switcher that swaps the first path segment (or prepends one) androuter.pushes — the middleware rewritesNEXT_LOCALEon that navigation, so the choice sticks. It reads the current locale fromi18n-routing/clientand the language list fromconfigs/locale; it's a11y-wired (aria-current, 44×44 touch target, visible focus ring). -
configs/src/locale.ts(templates/configs-locale.ts): the sharedsupportedLanguagessource of truth every app + the middleware read from.
The matcher's .*\..* excludes any path containing a dot from middleware (so
unprefixed dot-bearing routes like /blog/v1.2 never get locale-redirected),
while shouldSkipMiddleware only skips a known-extension allowlist. If you have
real dotted routes, narrow the matcher to known extensions instead of .*\..*.
Turbo / pnpm specifics
-
Build order: apps depend on the built package. Add the dependency in
turbo.jsonso the lib is built first:"your-app#build": { "dependsOn": ["i18n-routing#build", "configs#build"], "outputs": [".next/**"] }, "your-app#dev": { "dependsOn": ["i18n-routing#build", "configs#build"], "persistent": true }Run the package in watch mode (
tsup --watch) during development so app HMR picks up engine edits. -
Type skew across pnpm-hoisted
nextversions: if an app pins a differentnextthan the package's peer range,NextRequesttypes can mismatch and TS complains at thei18n(req)call. The pragmatic fix used in production is a localized cast —i18n(req as any)— with a comment; it's a types-only skew, the runtime shape is identical. Prefer aligningnextversions when you can. -
Don't re-export
'use client'code from the root entry. Importing the provider via the default entry drags a client boundary into server/middleware graphs. Always import hooks/provider fromi18n-routing/client.
Adding a locale (the payoff)
Add one { id, title } to supportedLanguages in configs. Every app's
middleware, generateStaticParams, toggle, and detection pick it up — no per-app
change. That single source of truth is the whole reason to use a package over
copy-paste.
Verify (per app)
curl -sI localhost:3000/→307to/<default>(no trailing slash — matchescreateLocalizedUrl).curl -sI -H 'Accept-Language: zh-HK' localhost:3000/x→307to/zh-hk/x.curl -sI --cookie 'NEXT_LOCALE=zh-hk' localhost:3000/→307to/zh-hk(cookie beats Accept-Language).pnpm --filter i18n-routing buildsucceeds anddist/client.*keeps its"use client"banner.
What ships with it: 13 files
21.5 KB alongside SKILL.md, 9 of them executable
templates/
- app-locale-layout.tsx1.3 KB
- app-middleware.tsruns2.0 KB
- configs-locale.tsruns637 B
- LocaleToggle.tsx2.0 KB
- package/package.json1004 B
- package/src/client.tsruns365 B
- package/src/config.tsruns2.1 KB
- package/src/hooks.tsruns144 B
- package/src/index.tsruns221 B
- package/src/middleware.tsruns4.2 KB
- package/src/provider.tsx1.6 KB
- package/src/utils.tsruns5.5 KB
- package/tsup.config.tsruns587 B