Internationalization (i18n)
Build search-engine-friendly, localized applications using native dynamic folder route parameters (src/[lang]/...).
In Dinou, internationalization follows modern web standards: localized pages are structured using folder-based dynamic route parameters (src/[lang]/...). This guarantees explicit, canonical URLs for search engines, eliminates complex server URL rewrites, and natively supports both Static (Pre-rendered) and Dynamic (On-Demand) rendering.1. Overview
Organizing routes under dynamic language folders (such as src/[lang]/about/page.tsx) provides a clean, predictable mental model for both developers and search engine crawlers.
params.lang. No custom route-mapping configurations or Express URL rewriting middlewares needed.getStaticPaths(), or render on-demand at request-time with export const revalidate = 0;./en/about and /es/about), perfectly suited for standard hreflang meta tags and sitemaps.1.1 How it Works
When a request arrives for /es/about, Dinou matches the dynamic route src/[lang]/about. Your page component (page.tsx) automatically receives { params: { lang: 'es' } } via standard React props (typed with PageProps). If defined, server contracts like getProps(params) or validateParams(params) also receive params.lang directly to load localized data or validate the route. For pre-rendering at build time, getStaticPaths can be exported in layout_functions.ts (or page_functions.ts) to declare the supported locales.
2. Complete Implementation Guide
Dinou integrates seamlessly with the industry-standard i18next and react-i18next ecosystem. This setup gives you the best of both worlds: Server Components render translations directly on Node.js using getFixedT() with 0 kB client JavaScript bundle impact, while deeply nested Client Components consume reactive translations anywhere using the useTranslation() hook without prop drilling.
2.1 Installation
npm install i18next react-i18next2.2 Shared Configuration & Server Helpers (src/lib/i18n.ts)
Create a shared configuration file with your translation resources. Export getFixedT(lang) for explicit route language bindings, and getServerT() for zero-prop-drilling in nested Server Components:
// src/lib/i18n.ts
import i18next from "i18next";
import { getContext } from "dinou";
export const resources = {
en: {
translation: {
title: "Interactive i18n Demo",
serverMessage: "Rendered directly on Node.js with getFixedT() (0 kB client JS)!",
serverNested: "Rendered deep in the RSC tree with getServerT() (zero prop drilling)!",
clientMessage: "Translated inside a Client Component using useTranslation()!",
save: "Save Changes",
newsletter: {
placeholder: "[email protected]",
subscribe: "Subscribe",
subscribing: "Subscribing...",
success: "Thank you for subscribing!",
invalidEmail: "Please enter a valid email address.",
},
},
},
es: {
translation: {
title: "Demostración Interactiva de i18n",
serverMessage: "¡Renderizado directamente en Node.js con getFixedT() (0 kB de JS cliente)!",
serverNested: "¡Renderizado profundo en el árbol RSC con getServerT() (cero prop drilling)!",
clientMessage: "¡Traducido dentro de un Client Component usando useTranslation()!",
save: "Guardar Cambios",
newsletter: {
placeholder: "[email protected]",
subscribe: "Suscribirse",
subscribing: "Suscribiendo...",
success: "¡Gracias por suscribirte!",
invalidEmail: "Por favor introduce un email válido.",
},
},
},
} as const;
if (!i18next.isInitialized) {
i18next.init({
resources,
fallbackLng: "en",
interpolation: { escapeValue: false },
});
}
// 1. Explicit Server Helper: returns 't' bound to an explicit language parameter
export function getFixedT(lang: string) {
return i18next.getFixedT(lang);
}
// 2. Zero-Prop-Drilling Server Helper: automatically resolves context.lang from AsyncLocalStorage.
// Deeply nested Server Components can call this anywhere without needing props from parent pages!
export function getServerT() {
const { lang = "en" } = getContext() as { lang?: string };
return getFixedT(lang);
}2.3 Plugin Configuration: Root Redirection & Context Extension (dinou.config.mjs)
In Dinou v7, you can configure both Root URL Redirection (/ ➔ /[lang]) and Context Extension (attaching context.lang for getContext()) using a single Web Standards plugin:
// dinou.config.mjs
import { defineConfig } from "dinou";
export default defineConfig({
plugins: [
{
name: "i18n-plugin",
onRequest(request, context) {
const url = new URL(request.url);
// 1. Root URL Redirection: / -> /[lang]
if (url.pathname === "/") {
const supportedLocales = ["en", "es"];
const defaultLocale = "en";
// Check saved user cookie from context.req.cookies
const cookieLocale = context.req.cookies["locale"];
if (cookieLocale && supportedLocales.includes(cookieLocale)) {
return Response.redirect(new URL(`/${cookieLocale}`, request.url), 307);
}
// Check browser Accept-Language header
const acceptLang = request.headers.get("accept-language") || "";
const browserLocale = supportedLocales.find((l) =>
acceptLang.toLowerCase().includes(l)
);
const targetLocale = browserLocale || defaultLocale;
return Response.redirect(new URL(`/${targetLocale}`, request.url), 307);
}
// 2. Context Extension: Resolve active language for getContext()
const supported = ["en", "es"];
let lang = null;
// A. From URL path (e.g. /es/about, /en/contact)
const segments = url.pathname.split("/").filter(Boolean);
if (segments[0] && supported.includes(segments[0])) {
lang = segments[0];
}
// B. From Referer header (e.g. when triggered from Server Actions or client fetch)
if (!lang && request.headers.get("referer")) {
try {
const refPath = new URL(request.headers.get("referer")).pathname;
const refSegments = refPath.split("/").filter(Boolean);
if (refSegments[0] && supported.includes(refSegments[0])) {
lang = refSegments[0];
}
} catch {}
}
// C. From saved user cookie
if (!lang && context.req.cookies["locale"]) {
const cookieLocale = context.req.cookies["locale"];
if (supported.includes(cookieLocale)) lang = cookieLocale;
}
// Attached directly to context: accessible anywhere via getContext()!
context.lang = lang || "en";
},
},
],
});Why HTTP 307 & AsyncLocalStorage?
• HTTP 307 (Temporary Redirect): Prevents search engines and browsers from permanently caching the redirect, allowing visitors to switch locales dynamically without getting locked in.
• AsyncLocalStorage Propagation: Any custom property attached to context (like context.lang) is preserved across the entire asynchronous execution chain on Node.js and accessible via getContext().
2.4 Client Provider (src/components/i18n-provider.tsx)
Wrap the client tree in an I18nextProvider that synchronizes its active language whenever locale changes:
// src/components/i18n-provider.tsx
"use client";
import { ReactNode, useEffect, useMemo } from "react";
import i18next from "i18next";
import { I18nextProvider } from "react-i18next";
import { resources } from "@/lib/i18n";
export function I18nProvider({
locale,
children,
}: {
locale: string;
children: ReactNode;
}) {
const i18nInstance = useMemo(() => {
const instance = i18next.createInstance();
instance.init({
resources,
lng: locale,
fallbackLng: "en",
interpolation: { escapeValue: false },
});
return instance;
}, [locale]);
useEffect(() => {
if (i18nInstance.language !== locale) {
i18nInstance.changeLanguage(locale);
}
}, [locale, i18nInstance]);
return <I18nextProvider i18n={i18nInstance}>{children}</I18nextProvider>;
}2.5 Root Locale Layout (src/[lang]/layout.tsx)
Instead of manually wrapping <I18nProvider> inside every individual page, mount it once in the localized route segment layout (src/[lang]/layout.tsx). It receives the active language directly via LayoutProps<"/[lang]">:
// src/[lang]/layout.tsx
import { I18nProvider } from "@/components/i18n-provider";
import type { LayoutProps } from "dinou";
export default function LocaleLayout({
children,
params,
}: LayoutProps<"/[lang]">) {
const lang = params.lang || "en";
return (
<I18nProvider locale={lang}>
{children}
</I18nProvider>
);
}Why use a Layout for i18n?
• Zero Boilerplate: Wrap the provider once in layout.tsx; all current and future sub-pages inherit the i18n context automatically without importing the provider.
• State Persistence: Client-side navigation between pages in the same language preserves the provider instance and eliminates redundant context re-mounts.
• Strict Type Safety: LayoutProps<"/[lang]"> infers params.lang with automatic TypeScript autocomplete.
2.6 Translating in Server & Client Components
In Dinou and React 19, you can author pages and components as either Server Components (Node.js) or Client Components (browser). Because src/[lang]/layout.tsx wraps the route subtree in <I18nProvider> and our plugin preserves context.lang in AsyncLocalStorage, translations work seamlessly across all environments:
Server vs. Client Translation Patterns
• Server Component Pages (Zero Client JS): Server components cannot run hooks like useTranslation(). Call getFixedT(params.lang) to translate synchronously on Node.js during SSR, shipping pure HTML with 0 kB client bundle impact.
• Deeply Nested Server Components (Zero Prop Drilling): In other frameworks, nested RSCs require passing lang or t down through every layer. In Dinou, any nested Server Component can simply call getServerT() (the helper we created in step 2.2 in src/lib/i18n.ts) to read the active locale directly from AsyncLocalStorage with zero prop drilling.
• Client Component Pages ("use client"): When an entire page requires client-side state (such as interactive dashboards, forms, or tabs), mark page.tsx with "use client" and call const { t } = useTranslation() directly at the page level.
• Nested Client Components: Any leaf component (buttons, modals, toolbars) can call useTranslation() anywhere in the tree with zero prop drilling.
// src/[lang]/interactive/page.tsx
// Server Component: zero client JS overhead, translations rendered directly on Node.js
import { getFixedT } from "@/lib/i18n";
import { ServerStats } from "@/components/server-stats";
import { FeedbackBox } from "@/components/feedback-box";
import { Link, type PageProps } from "dinou";
export default function InteractivePage({
params,
}: PageProps<"/[lang]/interactive">) {
// 1. Explicit helper bound to route params
const t = getFixedT(params.lang);
return (
<main className="max-w-2xl mx-auto py-12 px-4 space-y-6">
<h1 className="text-3xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("serverMessage")}</p>
{/* Nested Server Component: zero props required! */}
<ServerStats />
{/* Interactive Client Component embedded inside the Server Page */}
<FeedbackBox />
<div className="flex gap-4 pt-4 border-t text-sm">
<Link
href="/en/interactive"
className={params.lang === "en" ? "font-bold underline" : "text-muted-foreground"}
>
English
</Link>
<Link
href="/es/interactive"
className={params.lang === "es" ? "font-bold underline" : "text-muted-foreground"}
>
Español
</Link>
</div>
</main>
);
}// src/components/server-stats.tsx
// Pure Server Component: nested anywhere, zero prop drilling!
import { getServerT } from "@/lib/i18n";
export function ServerStats() {
// Reads context.lang automatically from Node.js AsyncLocalStorage
const t = getServerT();
return (
<div className="p-4 rounded-lg bg-card border text-sm">
<span className="font-semibold text-blue-600 dark:text-blue-400">RSC Subtree:</span>
<p className="text-muted-foreground mt-1">{t("serverNested")}</p>
</div>
);
}// src/[lang]/dashboard/page.tsx
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { PageProps } from "dinou";
// Entire page marked with "use client" for client state and event handlers
export default function DashboardPage({
params,
}: PageProps<"/[lang]/dashboard">) {
// Inherits I18nProvider from src/[lang]/layout.tsx!
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState("overview");
return (
<main className="max-w-2xl mx-auto py-12 px-4 space-y-4">
<h1 className="text-3xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("clientMessage")}</p>
<div className="flex gap-2">
<button
onClick={() => setActiveTab("overview")}
className="px-3 py-1.5 rounded bg-primary text-primary-foreground text-xs font-semibold"
>
{t("save")}
</button>
</div>
</main>
);
}// src/components/feedback-box.tsx
"use client";
import { useTranslation } from "react-i18next";
export function FeedbackBox() {
// Consumes I18nProvider context for reactive client translations anywhere
const { t } = useTranslation();
return (
<div className="p-4 rounded-lg bg-muted border text-sm">
<p>{t("clientMessage")}</p>
<button className="mt-3 px-3 py-1.5 bg-primary text-primary-foreground rounded text-xs font-semibold">
{t("save")}
</button>
</div>
);
}2.7 Language Switcher Component
Below is a reusable language switcher component for your app. It uses Dinou's usePathname() to swap the leading language prefix while preserving the user's active sub-path (e.g. switching from /en/interactive to /es/interactive). It also updates the locale cookie on click so that future visits to the root URL (/) seamlessly remember the user's preferred language via our plugin in dinou.config.mjs:
// src/components/language-switcher.tsx
"use client";
import { usePathname, Link } from "dinou";
const LOCALES = [
{ code: "en", label: "English" },
{ code: "es", label: "Español" },
];
export function LanguageSwitcher() {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
// 1. Safe active locale detection (validating against supported LOCALES)
const currentLocale = LOCALES.some((l) => l.code === segments[0])
? segments[0]
: "en";
// 2. Helper to replace the leading language prefix (e.g. /en/about -> /es/about)
const getLocalizedPath = (targetLocale: string) => {
const nextSegments = [...segments];
if (nextSegments.length > 0 && LOCALES.some((l) => l.code === nextSegments[0])) {
nextSegments[0] = targetLocale;
} else {
nextSegments.unshift(targetLocale);
}
return "/" + nextSegments.join("/");
};
return (
<div className="inline-flex items-center gap-2 p-1 rounded-lg border bg-card text-xs">
{LOCALES.map(({ code, label }) => (
<Link
key={code}
href={getLocalizedPath(code)}
onClick={() => {
// 3. Persist locale cookie so dinou.config.mjs remembers it on root "/" visits
document.cookie = `locale=${code}; path=/; max-age=31536000; SameSite=Lax`;
}}
className={`px-2.5 py-1 rounded-md transition-colors ${
currentLocale === code
? "bg-primary text-primary-foreground font-semibold shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{label}
</Link>
))}
</div>
);
}2.8 Cascading SSG with layout_functions.ts
In Dinou, when a layout introduces a dynamic route segment (such as src/[lang]/layout.tsx), define getStaticPaths directly in src/[lang]/layout_functions.ts.
This activates Cascading Static Generation: Dinou automatically pre-compiles every nested page under src/[lang]/... for each language declared in the layout, eliminating the need to duplicate getStaticPaths across multiple individual page_functions.ts files:
// src/[lang]/layout_functions.ts
import type { LayoutFunctions } from "dinou";
// 1. Cascades down to all child routes (e.g. /[lang]/interactive, /[lang]/about, etc.)
export const getStaticPaths: LayoutFunctions<"/[lang]">["getStaticPaths"] = [
{ lang: "en" },
{ lang: "es" },
];
// 2. Strict language enclave: immediately returns 404 for unlisted locales (e.g. /fr/about)
export const allowISG: LayoutFunctions<"/[lang]">["allowISG"] = false;Strict Language Enclave (allowISG: false)
allowISG = false ensures that only the declared languages (en, es) can be accessed. Any incoming request for an unsupported locale (like /fr/... or /de/...) will immediately return a 404 Not Found at the layout perimeter without attempting on-demand generation or invoking downstream server renderers.2.9 Server Functions (Server Actions) with i18n
Server Functions ("use server") execute in an isolated backend Flight RPC context. In production web applications, having the active locale available on the server is critical for multiple backend operations.
When is lang needed on the Backend? (Real-World Use Cases)
- Transactional Emails & Notifications: When a user signs up for a newsletter, submits a contact request, or triggers a password reset, external email services (Resend, SendGrid, Nodemailer) require the language code to render the email template in the user's language.
- Localized Database Queries & Mutations: Storing the user's preferred language tag in the database (e.g.
db.users.update({ preferredLocale: lang })) or querying language-specific database tables/columns (e.g.db.products.findMany({ where: { locale: lang } })). - Third-Party Payment & API Gateways: Initializing checkout sessions with providers like Stripe or PayPal with the customer's active locale (e.g.
stripe.checkout.sessions.create({ locale: lang })). - Server-Side Document Generation: Compiling localized PDF receipts, invoices, or export reports directly on the server.
Obtaining the Locale: getContext() vs. Explicit Parameter
Dinou provides two flexible ways to handle the active language inside a Server Function:
Zero-Boilerplate Access via getContext()
Because our plugin in dinou.config.mjs attaches context.lang, any Server Function can read it directly from AsyncLocalStorage via const { lang } = getContext(). You don't need to pass lang through form inputs or client function parameters!
Server Action Implementation Example
The Server Function reads the active locale via getContext(), performs the backend task (such as queuing an email), and returns a clean status response. The Client Component translates user feedback locally using useTranslation():
// src/actions/newsletter.ts
"use server";
import { getContext } from "dinou";
export async function subscribeNewsletter(email: string) {
// 1. Read locale directly from request context (zero parameter drilling!)
const { lang = "en" } = getContext() as { lang?: string };
if (!email || !email.includes("@")) {
return { success: false, errorKey: "newsletter.invalidEmail" };
}
// 2. Perform backend localized side effects (e.g. queue email in user's language)
console.log(`[Email Service] Queueing ${lang} welcome email to ${email}`);
// 3. Return clean machine status and translation key for the client
return { success: true, messageKey: "newsletter.success" };
}// src/components/newsletter-form.tsx
"use client";
import { useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { subscribeNewsletter } from "@/actions/newsletter";
export function NewsletterForm() {
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
// Call the action cleanly without passing locale arguments
const res = await subscribeNewsletter(email);
if (res.success) {
setStatus(t(res.messageKey!));
setEmail("");
} else {
setStatus(t(res.errorKey!));
}
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2 max-w-sm">
<div className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("newsletter.placeholder")}
required
className="px-3 py-2 rounded border text-sm flex-1"
/>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm font-semibold"
>
{isPending ? t("newsletter.subscribing") : t("newsletter.subscribe")}
</button>
</div>
{status && <p className="text-xs text-muted-foreground mt-1">{status}</p>}
</form>
);
}// src/[lang]/contact/page.tsx
// Alternatively, pass lang explicitly using React 19's .bind() pattern
import { submitContactForm } from "@/actions/contact";
import type { PageProps } from "dinou";
export default function ContactPage({ params }: PageProps<"/[lang]/contact">) {
// Pre-binds params.lang as the first argument to the Server Action
const submitWithLocale = submitContactForm.bind(null, params.lang);
return (
<form action={submitWithLocale} className="space-y-4">
<input name="email" type="email" required placeholder="[email protected]" />
<textarea name="message" required placeholder="Your message..." />
<button type="submit">Submit</button>
</form>
);
}Advanced Dinou Feature: Returning Rendered JSX Trees
In Dinou, Server Functions are not limited to returning JSON or primitive types. When rendering dynamic server widgets, dialogs, or slots on demand, a Server Function can return fully rendered React component trees (both Server and Client Components) directly across Flight RPC.