Internationalization
This recipe adds locale switching with no extra dependencies. Each locale lives at its own URL (/en, /es), so every page is plain static HTML that CDNs can cache independently — the most SEO-friendly pattern.
# 1. Locale configuration
// src/app/lib/i18n.ts
export const LOCALES = ["en", "es"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en";
export const translations = {
en: {
nav: { home: "Home", blog: "Blog", contact: "Contact" },
home: { title: "Welcome", cta: "Get started" },
},
es: {
nav: { home: "Inicio", blog: "Blog", contact: "Contacto" },
home: { title: "Bienvenido", cta: "Empezar" },
},
} as const;
export type Translation = (typeof translations)["en"];
export const localeParams = () => LOCALES.map((locale) => ({ locale }));# 2. Locale routes
Locale lives in the URL as a dynamic segment, and pages declare the locales they generate statically:
src/app/[locale]/
├── layout.ts
├── layout.data.ts
├── page.ts → /en, /es
├── blog/
│ ├── page.ts → /en/blog, /es/blog
│ └── page.data.ts
└── contact/
└── page.ts → /en/contact, /es/contact// src/app/[locale]/page.ts
import { html } from "@deijose/nix-js";
import type { PageProps, GenerateStaticParams } from "@deijose/nix-js-kit";
import { load } from "./page.data";
import { localeParams, localized } from "../lib/i18n";
export const generateStaticParams: GenerateStaticParams = localeParams;
export default function HomePage({ data, params }: PageProps<typeof load>) {
return html`
<h1>${data.t.home.title}</h1>
<a class="btn" href=${localized("/docs/getting-started/introduction", data.locale)}>
${data.t.home.cta}
</a>
`;
}Every dynamic page under [locale] exports the same generateStaticParams — import the shared localeParams helper so there is no duplication.
# 3. Locale-aware loader
// src/app/[locale]/page.data.ts
import type { PageDataLoad } from "@deijose/nix-js-kit";
import { translations, isLocale } from "../lib/i18n";
export const load: PageDataLoad = async ({ params }) => {
const locale = isLocale(params.locale) ? params.locale : "en";
return { locale, t: translations[locale] };
};# 4. Shared layout with locale switcher
// src/app/[locale]/layout.data.ts
import type { PageDataLoad } from "@deijose/nix-js-kit";
import { translations, isLocale } from "../lib/i18n";
export const load: PageDataLoad = async ({ params }) => {
const locale = isLocale(params.locale) ? params.locale : "en";
return {
locale,
t: translations[locale],
// Correct <html lang> per locale
htmlAttributes: { lang: locale },
};
};// src/app/[locale]/layout.ts
import { html } from "@deijose/nix-js";
import type { LayoutProps } from "@deijose/nix-js-kit";
import { island } from "@deijose/nix-js-kit";
import LocaleSwitcher from "../islands/LocaleSwitcher";
export default function LocaleLayout({ children }: LayoutProps) {
return html`
${island("LocaleSwitcher", LocaleSwitcher, {}, "load")}
${children}
`;
}The switcher is an island because it needs the current pathname — available in the browser but not during server rendering:
// src/islands/LocaleSwitcher.ts
import { html } from "@deijose/nix-js";
import { LOCALES } from "../app/lib/i18n";
function LocaleSwitcher() {
const [, locale, ...rest] = location.pathname.split("/");
const current = LOCALES.includes(locale as (typeof LOCALES)[number]) ? locale : "en";
const path = `/${rest.join("/")}`;
return html`
<nav class="locale-switcher" aria-label="Language">
${LOCALES.map((l) =>
l === current
? html`<span class="current">${l}</span>`
: html`<a href=${`/${l}${path}`}>${l}</a>`,
)}
</nav>
`;
}
export default LocaleSwitcher;On /en/blog, switching to Spanish navigates to /es/blog — the rest of the path is preserved.
# 5. Redirect middleware (optional)
A tiny middleware sends / to the default locale, honoring the browser's language preference:
// src/middleware.ts
import type { Middleware } from "@deijose/nix-js-kit";
import { LOCALES, DEFAULT_LOCALE } from "./src/app/lib/i18n";
export default ((request, ctx) => {
const url = new URL(request.url);
if (url.pathname !== "/") return ctx.next();
const preferred = request.headers.get("Accept-Language")?.split(",")[0]?.slice(0, 2);
const locale = LOCALES.includes(preferred as (typeof LOCALES)[number])
? preferred
: DEFAULT_LOCALE;
return new Response(null, { status: 302, headers: { Location: `/${locale}` } });
}) satisfies Middleware;# 6. Translation typing
translations is a const object, so data.t is fully typed — writing data.t.home.title in a template is checked against both dictionaries. Adding a locale to LOCALES without translating every key fails type-checking, keeping dictionaries in sync.
# 7. Sitemap
// scripts/gen-sitemap.ts
import { generateSitemap } from "@deijose/nix-js-kit/seo";
await generateSitemap({
siteUrl: "https://example.com",
outDir: "dist",
urls: ["/", "/en", "/es", "/en/blog", "/es/blog", "/en/contact", "/es/contact"],
});# Notes
- SEO: every locale is a distinct URL with its own
langattribute — no duplicate content, no client-side detection - Caching: locale pages are plain static HTML; ISR and streaming work unchanged
- Content collections: add a
localefield to frontmatter and filter in loaders, or keep parallel collections - Dynamic content:
src/app/[locale]/blog/[slug]/page.tsfollows the same pattern withgenerateStaticParamsreturning{ locale, slug }for every combination