Docs/Recipes/Internationalization
GitHub

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 lang attribute — no duplicate content, no client-side detection
  • Caching: locale pages are plain static HTML; ISR and streaming work unchanged
  • Content collections: add a locale field to frontmatter and filter in loaders, or keep parallel collections
  • Dynamic content: src/app/[locale]/blog/[slug]/page.ts follows the same pattern with generateStaticParams returning { locale, slug } for every combination
✎ Edit this page on GitHub