Internationalisation (i18n)

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

How it works

NukeJS has no built-in i18n API. Instead, internationalisation is wired up with three pieces that each do one job:

A [locale] dynamic segment in app/pages/ makes the language part of the URL (/en/about, /fr/about). A useI18n() hook reads that segment via useRequest()and returns the matching JSON translations. Middleware detects the browser's preferred language and redirects bare requests to the right locale prefix. 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. Locale-aware pages live under app/pages/[locale]/:

my-app/
├── app/
│   ├── components/
│   │   └── LangSwitcher.tsx     # "use client" switcher component
│   └── pages/
│       └── [locale]/
│           ├── index.tsx        # → /en  or  /fr
│           └── about.tsx        # → /en/about  or  /fr/about
├── lib/
│   └── useI18n.ts               # locale hook
├── locales/
│   ├── en.json                  # English strings (source of truth)
│   └── fr.json                  # French strings  (must match en.json shape)
└── middleware.ts                # auto-redirect / → detected locale

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.

locales/en.jsonjson
{
  "meta": { "lang": "en", "dir": "ltr" },

  "welcome":     "Welcome to NukeJS",
  "hello":       "Hello, World!",
  "greeting":    "Good to see you",
  "description": "A minimal, opinionated full-stack React framework.",
  "tagline":     "Server-render everything. Hydrate only what moves.",

  "nav": {
    "home":  "Home",
    "about": "About",
    "blog":  "Blog",
    "docs":  "Docs"
  },

  "actions": {
    "getStarted": "Get started",
    "readDocs":   "Read the docs",
    "switchLang": "Switch language",
    "learnMore":  "Learn more"
  },

  "home": {
    "headline":    "React. Weaponized.",
    "subheadline": "Zero JS by default — ship only what the user actually needs."
  },

  "about": {
    "title": "About",
    "body":  "NukeJS ships zero JavaScript by default. Every page is server-rendered HTML. Only components marked 'use client' ever touch the browser bundle."
  },

  "errors": {
    "notFound":    "Page not found",
    "serverError": "Something went wrong"
  }
}
locales/fr.jsonjson
{
  "meta": { "lang": "fr", "dir": "ltr" },

  "welcome":     "Bienvenue sur NukeJS",
  "hello":       "Bonjour, le monde !",
  "greeting":    "Ravi de vous voir",
  "description": "Un framework React full-stack minimal et opinionné.",
  "tagline":     "Tout est rendu côté serveur. Seul ce qui bouge est hydraté.",

  "nav": {
    "home":  "Accueil",
    "about": "À propos",
    "blog":  "Blog",
    "docs":  "Documentation"
  },

  "actions": {
    "getStarted": "Commencer",
    "readDocs":   "Lire la documentation",
    "switchLang": "Changer de langue",
    "learnMore":  "En savoir plus"
  },

  "home": {
    "headline":    "React. Militarisé.",
    "subheadline": "Zéro JS par défaut — n'envoyez que ce dont l'utilisateur a besoin."
  },

  "about": {
    "title": "À propos",
    "body":  "NukeJS n'envoie aucun JavaScript par défaut. Chaque page est rendue en HTML côté serveur. Seuls les composants marqués « use client » sont inclus dans le bundle navigateur."
  },

  "errors": {
    "notFound":    "Page introuvable",
    "serverError": "Une erreur est survenue"
  }
}

The useI18n() hook

Create lib/useI18n.ts. The hook reads the [locale] route segment via useRequest(), resolves it to a supported locale, 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.

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 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 }
}
ℹ️
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

Place pages inside app/pages/[locale]/. The dynamic segment is passed as a prop and useI18n() picks it up automatically throughuseRequest() — no prop drilling required. The same file handles every locale: /en/about and /fr/aboutboth render app/pages/[locale]/about.tsx.

app/pages/[locale]/index.tsxtypescript
import { useHtml } from "nukejs"
import { useI18n } from "../../lib/useI18n"
import LangSwitcher from "../../components/LangSwitcher"

export default function Home() {
    const { t, locale } = useI18n()

    useHtml({
        title: t.welcome,
        htmlAttrs: { lang: t.meta.lang, dir: t.meta.dir },
    })

    return (
        <main>
            <h1>{t.hello}</h1>
            <p>{t.greeting}</p>

            <p>{t.home.headline}</p>
            <p>{t.home.subheadline}</p>

            <nav>
                <a href={`/${locale}`}>{t.nav.home}</a>
                <a href={`/${locale}/about`}>{t.nav.about}</a>
            </nav>

            <LangSwitcher current={locale} />
        </main>
    )
}
app/pages/[locale]/about.tsxtypescript
import { useHtml } from "nukejs"
import { useI18n } from "../../lib/useI18n"

export default function About() {
    const { t } = useI18n()

    useHtml({
        title: t.about.title,
        htmlAttrs: { lang: t.meta.lang, dir: t.meta.dir },
    })

    return (
        <main>
            <h1>{t.about.title}</h1>
            <p>{t.about.body}</p>
            <a href="./about">{t.actions.learnMore}</a>
        </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 replaces only the locale segment in the current URL, preserving the rest of the path, then calls router.push() for a client-side transition:

app/components/LangSwitcher.tsxtypescript
"use client"
import { useRouter } from "nukejs"
import type { Locale } from "../lib/useI18n"

const LOCALES: { code: Locale; label: string }[] = [
    { code: "en", label: "English" },
    { code: "fr", label: "Français" },
]

export default function LangSwitcher({ current }: { current: Locale }) {
    const router = useRouter()

    function switchTo(next: Locale) {
        // /en/about  →  /fr/about
        const newPath = window.location.pathname.replace(
            /^\/[a-z]{2}/,
            `/${next}`,
        )
        router.push(newPath)
    }

    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>
    )
}

Redirecting to the detected locale

Use middleware.ts to redirect any request without a locale prefix. The Accept-Language header tells you which language the browser prefers, so users land on the right locale automatically without any cookie or session:

middleware.tstypescript
import type { IncomingMessage, ServerResponse } from "http"
import type { Locale } from "./lib/useI18n"

const SUPPORTED: Locale[] = ["en", "fr"]
const DEFAULT_LOCALE: Locale = "en"

function detectLocale(req: IncomingMessage): Locale {
    // Accept-Language: fr-FR,fr;q=0.9,en;q=0.8
    const accept = req.headers["accept-language"] ?? ""
    const preferred = accept.split(",")[0].split("-")[0].toLowerCase() as Locale
    return SUPPORTED.includes(preferred) ? preferred : DEFAULT_LOCALE
}

export default async function middleware(
    req: IncomingMessage,
    res: ServerResponse,
) {
    const url = req.url ?? "/"

    // Skip static assets, client bundles, and API routes
    if (url.startsWith("/__") || url.startsWith("/api") || url.includes(".")) {
        return
    }

    // Already has a supported locale prefix — let routing handle it
    const hasLocale = SUPPORTED.some(
        l => url === `/${l}` || url.startsWith(`/${l}/`),
    )
    if (hasLocale) return

    // Redirect to the browser's preferred locale
    const locale = detectLocale(req)
    res.statusCode = 302
    res.setHeader("Location", `/${locale}${url === "/" ? "" : url}`)
    res.end()
}
⚠️
The redirect fires before the 404 page Middleware runs before every request. If it calls res.end(), NukeJS stops immediately — the page router never runs. Always guard with url.includes(".") to avoid intercepting static file requests.

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.greeting,
        direction: t.meta.dir,
    })
}

GET /en/greet responds with:

{ "locale": "en", "message": "Good to see you", "direction": "ltr" }

GET /fr/greet responds with:

{ "locale": "fr", "message": "Ravi de vous voir", "direction": "ltr" }

Adding a new locale

Adding a third language takes four steps and no new dependencies:

# 1. Copy en.json and translate every value
cp locales/en.json locales/de.json
lib/useI18n.tstypescript
import de from "../locales/de.json"

const translations = { en, fr, de } as const  // add de here
middleware.tstypescript
const SUPPORTED: Locale[] = ["en", "fr", "de"]  // add de here
app/components/LangSwitcher.tsxtypescript
const LOCALES = [
    { code: "en", label: "English"  },
    { code: "fr", label: "Français" },
    { code: "de", label: "Deutsch"  },  // 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
locales/en.jsonEnglish strings — source of truth for TypeScript types
locales/fr.jsonFrench strings — must match en.json shape exactly
lib/useI18n.tsHook — reads [locale] param, returns { t, locale }
app/pages/[locale]/All locale-aware pages live here (/en/*, /fr/*)
app/components/LangSwitcher.tsxClient component — swaps locale prefix, navigates client-side
middleware.tsReads Accept-Language, redirects / to detected locale
useHtml(({ htmlAttrs: { lang, dir } }))Writes correct lang and dir to <html> on every page