Internationalisation (i18n)

Add multi-language support with locale-based routing, a tiny useI18n() hook, and plain JSON translation files — no third-party library required.

🚀
Prefer to start from a working project? Everything on this page mirrors i18n-shadcn-nukejs, an official starter with shadcn/ui and this exact i18n setup wired up out of the box. Scaffold it with 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 guard

Translation 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.

app/locales/en.jsonjson
{
  "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."
  }
}
app/locales/fr.jsonjson
{
  "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.

app/lib/useI18n.tstypescript
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[]
ℹ️
Type safety is automatic TypeScript infers 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():

app/pages/index.tsxtypescript
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>
    )
}
app/pages/[locale]/index.tsxtypescript
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>
    )
}
Set 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:

app/components/LangSwitcher.tsxtypescript
"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:

middleware.tstypescript
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.
}
⚠️
The 404 guard needs an allowlist as you add pagesapp/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:

server/[locale]/greet.tstypescript
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.json
app/lib/useI18n.tstypescript
import de from "../locales/de.json"

const translations = { en, fr, de } as const  // add de here
app/components/LangSwitcher.tsxtypescript
const 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
]
middleware.tstypescript
const PREFIXED_LOCALES = ["fr", "de"]  // add de here
ℹ️
TypeScript catches missing keys immediately If de.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

FilePurpose
app/locales/en.jsonEnglish strings — source of truth for TypeScript types
app/locales/fr.jsonFrench strings — must match en.json shape exactly
app/lib/useI18n.tsHook — reads [locale] param, returns { t, locale }
app/pages/index.tsxDefault locale's homepage — served unprefixed at /
app/pages/[locale]/index.tsxEvery other locale's homepage — served at /fr, /de, etc.
app/components/LangSwitcher.tsxClient component — swaps locale prefix, navigates client-side
middleware.tsRedirects /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.