Internationalisation (i18n)
Add multi-language support with locale-based routing, a tiny useI18n() hook, and plain JSON translation files — no third-party library required.
npx degit unenterprise/i18n-shadcn-nukejs.How it works
NukeJS has no built-in i18n API. Instead, internationalisation is wired up with three pieces that each do one job:
The default locale is served unprefixed at / from app/pages/index.tsx, while every other locale gets a [locale] prefix segment (/fr, /fr/about) served from app/pages/[locale]/index.tsx. A useI18n() hook reads the resolved locale via useRequest() and returns the matching JSON translations. middleware.ts keeps exactly one canonical URL per page — redirecting /en to / — and 404s any unknown single-segment path instead of silently falling back to English. The whole setup is server-only — no runtime i18n overhead reaches the browser.
Project structure
Add a locales/ folder for JSON files and a lib/ folder for the hook. Only non-default locales live under a [locale] segment — the default locale's page sits directly in app/pages/:
my-app/
├── app/
│ ├── components/
│ │ └── LangSwitcher.tsx # "use client" switcher component
│ ├── lib/
│ │ └── useI18n.ts # locale hook
│ ├── locales/
│ │ ├── en.json # English strings (source of truth)
│ │ └── fr.json # French strings (must match en.json shape)
│ └── pages/
│ ├── index.tsx # default locale ("en") → "/"
│ └── [locale]/
│ └── index.tsx # every other locale → "/fr", "/de", ...
└── middleware.ts # /en → "/" redirect + unknown-locale 404 guardTranslation files
Every locale file shares the exact same shape. en.json is the source of truth — TypeScript infers the Translations type from it, so a missing or misspelled key in any other locale file is a compile error.
{
"meta": { "lang": "en", "dir": "ltr" },
"site": {
"title": "NukeJS — Your next project",
"description": "NukeJS has got you. A minimal, opinionated full-stack React framework with SSR, HMR, file-based routing, and API routes out of the box."
},
"nav": {
"docs": "Docs",
"github": "GitHub"
},
"hero": {
"eyebrow": "React. Weaponized.",
"headline": "Your next project",
"slug": "NukeJS has got you",
"body": "Server-render everything, hydrate only what moves. File-based routing, API routes, and zero-config deploys — ready before you finish your coffee.",
"primaryCta": "Get started",
"secondaryCta": "Read the docs"
},
"actions": {
"switchLang": "Switch language"
},
"footer": {
"tagline": "Built with NukeJS."
}
}{
"meta": { "lang": "fr", "dir": "ltr" },
"site": {
"title": "NukeJS — Votre prochain projet",
"description": "NukeJS s'occupe de tout. Un framework React full-stack minimal et opinionné, avec SSR, HMR, routage par fichiers et routes API prêts à l'emploi."
},
"nav": {
"docs": "Documentation",
"github": "GitHub"
},
"hero": {
"eyebrow": "React. Militarisé.",
"headline": "Votre prochain projet",
"slug": "NukeJS s'occupe de tout",
"body": "Tout est rendu côté serveur, seul ce qui bouge est hydraté. Routage par fichiers, routes API, et déploiements sans configuration — prêts avant la fin de votre café.",
"primaryCta": "Commencer",
"secondaryCta": "Lire la documentation"
},
"actions": {
"switchLang": "Changer de langue"
},
"footer": {
"tagline": "Construit avec NukeJS."
}
}The useI18n() hook
Create app/lib/useI18n.ts. The hook reads the [locale] route segment via useRequest(), resolves it to a supported locale (falling back to "en" for anything unrecognised), and returns the matching translation object alongside the resolved locale string. Because useRequest() is server-only, the entire lookup happens at render time — zero bytes reach the browser.
import { useRequest } from "nukejs"
import en from "../locales/en.json"
import fr from "../locales/fr.json"
// ─── Types ────────────────────────────────────────────────────────────
const translations = { en, fr } as const
export type Locale = keyof typeof translations
export type Translations = typeof en // fr.json must match this shape exactly
// ─── Locale resolver ──────────────────────────────────────────────────
function resolveLocale(param: string | string[] | undefined): Locale {
if (!param) return "en"
const tag = (Array.isArray(param) ? param[0] : param)
.trim()
.toLowerCase() as Locale
return tag in translations ? tag : "en"
}
// ─── Hook ─────────────────────────────────────────────────────────────
export function useI18n(): { t: Translations; locale: Locale } {
const { params } = useRequest()
const locale = resolveLocale(params.locale as string | undefined)
return { t: translations[locale], locale }
}
export const SUPPORTED_LOCALES: Locale[] = Object.keys(translations) as Locale[]Translations from en.json. If fr.json is missing a key or has a different structure, the build fails before anything ships.Locale-based routing
Unlike a scheme where every locale — including the default — lives under a [locale] prefix, this pattern keeps the default locale unprefixed for a cleaner, more SEO-friendly / instead of /en. That means the homepage exists as two sibling files that both call useI18n():
import { useHtml } from "nukejs"
import { useI18n } from "../lib/useI18n"
import LangSwitcher from "../components/LangSwitcher"
// Default locale ("en") — served unprefixed at "/".
export default function Home() {
const { t } = useI18n()
useHtml({
title: t.site.title,
htmlAttrs: { lang: t.meta.lang, dir: t.meta.dir },
})
return (
<main>
<h1>{t.hero.headline}</h1>
<p>{t.hero.body}</p>
<LangSwitcher current="en" />
</main>
)
}import { useHtml } from "nukejs"
import { useI18n } from "../../lib/useI18n"
import LangSwitcher from "../../components/LangSwitcher"
// Reached only for *non-default* locales (e.g. "/fr"). Requests to
// "/en" are permanently redirected to "/" by middleware.ts before
// routing ever sees them — see "Canonicalizing the default locale" below.
export default function Home() {
const { t, locale } = useI18n()
useHtml({
title: t.site.title,
htmlAttrs: { lang: t.meta.lang, dir: t.meta.dir },
})
return (
<main>
<h1>{t.hero.headline}</h1>
<p>{t.hero.body}</p>
<LangSwitcher current={locale} />
</main>
)
}lang and dir on every page Passing htmlAttrs: { lang: t.meta.lang, dir: t.meta.dir } through useHtml() writes the correct attributes to the <html> tag on every server render. Screen readers, browser translation prompts, and search engines all rely on this.Language switcher
The switcher is a client component so it can react to clicks without a full page reload. It strips off any existing locale prefix, then re-applies the target locale's prefix — or no prefix at all, if the target is the default locale — before calling router.push() for a client-side transition:
"use client"
import { useRouter } from "nukejs"
import type { Locale } from "../lib/useI18n"
const DEFAULT_LOCALE: Locale = "en"
// Locales that keep a URL prefix. The default locale is served unprefixed.
const PREFIXED_LOCALES: Locale[] = ["fr"]
const LOCALES: { code: Locale; label: string }[] = [
{ code: "en", label: "English" },
{ code: "fr", label: "Français" },
]
const PREFIX_PATTERN = new RegExp(`^/(${PREFIXED_LOCALES.join("|")})(?=/|$)`)
export default function LangSwitcher({ current }: { current: Locale }) {
const router = useRouter()
function switchTo(next: Locale) {
// Strip any existing prefixed-locale segment, then re-apply the
// target locale's prefix (none for the default locale).
// e.g. "/fr/about" -> "/about" -> "/about" (en) or "/fr/about" (fr)
const stripped = window.location.pathname.replace(PREFIX_PATTERN, "") || "/"
const target = next === DEFAULT_LOCALE
? stripped
: `/${next}${stripped === "/" ? "" : stripped}`
router.push(target)
}
return (
<div>
{LOCALES.map(({ code, label }) => (
<button
key={code}
onClick={() => switchTo(code)}
disabled={code === current}
aria-current={code === current ? "true" : undefined}
aria-label={`Switch to ${label}`}
>
{label}
</button>
))}
</div>
)
}Canonicalizing the default locale
Use middleware.ts to keep exactly one canonical URL per page. It runs before every request, so it's the right place to permanently redirect /en to /, and to reject any unrecognised single-segment path with a real 404 instead of letting useI18n() silently fall back to English:
import type { IncomingMessage, ServerResponse } from "http"
// The default locale is served unprefixed at "/" (e.g. "/", "/about").
// Any other supported locale keeps its prefix (e.g. "/fr", "/fr/about").
const DEFAULT_LOCALE = "en"
// Locales that keep a URL prefix. Must stay in sync with the `translations`
// keys in lib/useI18n.ts (everything except DEFAULT_LOCALE).
const PREFIXED_LOCALES = ["fr"]
export default async function middleware(
req: IncomingMessage,
res: ServerResponse,
) {
const rawUrl = req.url ?? "/"
const queryIndex = rawUrl.indexOf("?")
const pathname = queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex)
const query = queryIndex === -1 ? "" : rawUrl.slice(queryIndex)
// Skip framework internals, API routes, and static assets (anything
// with a file extension, e.g. /favicon.ico, /styles.css).
if (
pathname.startsWith("/__") ||
pathname.startsWith("/api") ||
/\.[a-zA-Z0-9]+$/.test(pathname)
) {
return
}
// Canonicalize the default locale: `/en` and `/en/...` permanently
// redirect to their unprefixed equivalent (`/`, `/...`). This keeps a
// single canonical URL per page for SEO and avoids duplicate-content
// issues between `/` and `/en`.
if (pathname === `/${DEFAULT_LOCALE}` || pathname.startsWith(`/${DEFAULT_LOCALE}/`)) {
const rest = pathname.slice(`/${DEFAULT_LOCALE}`.length) || "/"
res.statusCode = 301
res.setHeader("Location", rest + query)
res.end()
return
}
// `app/pages/[locale]/index.tsx` structurally matches ANY single path
// segment, so `/anything` would otherwise render with params.locale =
// "anything" and useI18n() would silently fall back to English instead
// of 404ing. Reject single-segment paths that aren't a real prefixed
// locale here, before routing ever sees them.
const segments = pathname.split("/").filter(Boolean)
if (segments.length === 1 && !PREFIXED_LOCALES.includes(segments[0])) {
res.statusCode = 404
res.setHeader("Content-Type", "text/plain; charset=utf-8")
res.end("Not Found")
return
}
// Everything else — unprefixed paths (served as the default locale) and
// prefixed non-default locales (e.g. `/fr`) — passes through to routing.
}app/pages/[locale]/index.tsx structurally matches any single path segment. With only a homepage, rejecting unrecognised single-segment paths is safe. The moment you add another top-level unprefixed page (e.g. app/pages/about.tsx), add its slug to an allowlist in the guard above — otherwise it will 404 too.Translations in API routes
Server-side handlers in server/ don't use the hook. Import the locale files directly and select the right one from req.params:
import type { ApiRequest, ApiResponse } from "nukejs"
import en from "../../locales/en.json"
import fr from "../../locales/fr.json"
const translations = { en, fr } as const
type Locale = keyof typeof translations
export async function GET(req: ApiRequest, res: ApiResponse) {
const raw = req.params.locale as string
const locale: Locale = raw in translations ? (raw as Locale) : "en"
const t = translations[locale]
res.json({
locale,
message: t.hero.slug,
direction: t.meta.dir,
})
}GET /greet (default locale) responds with:
{ "locale": "en", "message": "NukeJS has got you", "direction": "ltr" }GET /fr/greet responds with:
{ "locale": "fr", "message": "NukeJS s'occupe de tout", "direction": "ltr" }Adding a new locale
Adding a third language takes four steps and no new dependencies. New locales always keep a URL prefix — only the original default locale stays unprefixed:
# 1. Copy en.json and translate every value
cp app/locales/en.json app/locales/de.jsonimport de from "../locales/de.json"
const translations = { en, fr, de } as const // add de hereconst PREFIXED_LOCALES: Locale[] = ["fr", "de"] // add de here (never the default)
const LOCALES: { code: Locale; label: string }[] = [
{ code: "en", label: "English" },
{ code: "fr", label: "Français" },
{ code: "de", label: "Deutsch" }, // add de here
]const PREFIXED_LOCALES = ["fr", "de"] // add de herede.json is missing a key that exists in en.json, the assignment const translations = { en, fr, de } as const will produce a type error before the build finishes.Quick reference
| File | Purpose |
|---|---|
app/locales/en.json | English strings — source of truth for TypeScript types |
app/locales/fr.json | French strings — must match en.json shape exactly |
app/lib/useI18n.ts | Hook — reads [locale] param, returns { t, locale } |
app/pages/index.tsx | Default locale's homepage — served unprefixed at / |
app/pages/[locale]/index.tsx | Every other locale's homepage — served at /fr, /de, etc. |
app/components/LangSwitcher.tsx | Client component — swaps locale prefix, navigates client-side |
middleware.ts | Redirects /en → /, 404s unknown locale prefixes |
useHtml(({ htmlAttrs: { lang, dir } })) | Writes correct lang and dir to <html> on every page |
See the full working source in github.com/unenterprise/i18n-shadcn-nukejs.