Frameworks
Astro
Prerendered pages per locale, or on-demand pages that follow Accept-Language, through one middleware.
An Astro app that prerenders /de/ and /en/, then a variant that picks the language per
request. There is no Astro entry: a middleware wraps each render in runWithLocale from
loclizr/server.
-
Create the app
Section titled “Create the app”Terminal window npm create astro@latest app -- --template minimal --install --no-git --yescd appnpm i loclizrnpm i -D @astrojs/check typescriptnpx loclizr initinitseedslocales/en.json. Add the German catalog beside it:locales/de.json {"nav": {"home": "Startseite","cart": "Warenkorb"},"cart": {"greeting": "Hallo {name}, dein Warenkorb ist bereit","items": "{count, plural, =0 {Dein Warenkorb ist leer} one {# Artikel in deinem Warenkorb} other {# Artikel in deinem Warenkorb}}"}} -
Add the scripts
Section titled “Add the scripts”package.json "scripts": {"predev": "loclizr build --no-fail","dev": "astro dev","prebuild": "loclizr build","build": "astro build","preview": "astro preview","pretypecheck": "loclizr build","typecheck": "astro check","astro": "astro"},The three hooks
initprinted, plustypecheckrunningastro check. -
Add a route per locale
Section titled “Add a route per locale”src/pages/[lang]/index.astro ---import * as m from '../../loclizr/messages'import CartBadge from '../../components/CartBadge.astro'export function getStaticPaths() {return m.locales.map((lang) => ({ params: { lang } }))}---<html lang={m.getLocale()}><head><meta charset="utf-8" /><title>{m.nav_cart()}</title></head><body><h1>{m.cart_greeting({ name: 'Ada' })}</h1><CartBadge count={3} /></body></html>src/components/CartBadge.astro ---import * as m from '../loclizr/messages'const { count } = Astro.props---<p>{m.cart_items({ count })}</p> -
Scope each render with middleware
Section titled “Scope each render with middleware”src/middleware.ts import { defineMiddleware } from 'astro:middleware'import { runWithLocale } from 'loclizr/server'export const onRequest = defineMiddleware((context, next) => {const { lang } = context.paramsreturn lang === undefined ? next() : runWithLocale(lang, next)})Middleware runs for every prerendered page at build time, and its scope covers each child component.
npm run buildwrites:dist/de/index.html <!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><title>Warenkorb</title></head><body><h1>Hallo Ada, dein Warenkorb ist bereit</h1><p>3 Artikel in deinem Warenkorb</p></body></html>dist/en/index.html <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Cart</title></head><body><h1>Hi Ada, your cart is ready</h1><p>3 items in your cart</p></body></html>
On-demand rendering
Section titled “On-demand rendering”This replaces the static setup: one page at / picks the language per request. Step 2 replaces
src/middleware.ts and step 3 deletes src/pages/[lang]/.
-
Add the Node adapter
Section titled “Add the Node adapter”Terminal window npx astro add node --yesastro addwrites the adapter; addoutput: 'server'yourself.astro.config.mjs // @ts-checkimport { defineConfig } from 'astro/config';import node from '@astrojs/node';// https://astro.build/configexport default defineConfig({output: 'server',adapter: node({mode: 'standalone'})}); -
Replace the middleware
Section titled “Replace the middleware”src/middleware.ts import { defineMiddleware } from 'astro:middleware'import { localeFromRequest, runWithLocale } from 'loclizr/server'import * as m from './loclizr/messages'const options = { locales: m.locales, sourceLocale: m.sourceLocale, cookie: m.cookie }export const onRequest = defineMiddleware(async (context, next) => {const locale = localeFromRequest(context.request, options)const response = await runWithLocale(locale, next)response.headers.set('Content-Language', locale)response.headers.append('Vary', 'Accept-Language, Cookie')return response})localeFromRequestreads the configured cookie (m.cookie), thenAccept-Language, then the source locale. -
Serve the page from
Section titled “Serve the page from /”/Delete
src/pages/[lang]/, then replacesrc/pages/index.astrowith the same page minusgetStaticPaths:src/pages/index.astro ---import * as m from '../loclizr/messages'import CartBadge from '../components/CartBadge.astro'---<html lang={m.getLocale()}><head><meta charset="utf-8" /><title>{m.nav_cart()}</title></head><body><h1>{m.cart_greeting({ name: 'Ada' })}</h1><CartBadge count={3} /></body></html> -
Build and run
Section titled “Build and run”Terminal window npm run buildPORT=8080 node dist/server/entry.mjsPORToverrides the adapter’s default of 4321.curl -si -H 'Accept-Language: de-CH,de;q=0.9' http://localhost:8080/ HTTP/1.1 200 OKcontent-language: decontent-type: text/htmlvary: Accept-Language, CookieDate: Sat, 10 Oct 2026 18:51:04 GMTConnection: keep-aliveKeep-Alive: timeout=5Transfer-Encoding: chunked<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><title>Warenkorb</title></head><body><h1>Hallo Ada, dein Warenkorb ist bereit</h1><p>3 Artikel in deinem Warenkorb</p></body></html>curl -si -H 'Accept-Language: en-US' -H 'Cookie: locale=de' http://localhost:8080/ HTTP/1.1 200 OKcontent-language: decontent-type: text/htmlvary: Accept-Language, CookieDate: Sat, 10 Oct 2026 18:51:05 GMTConnection: keep-aliveKeep-Alive: timeout=5Transfer-Encoding: chunked<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><title>Warenkorb</title></head><body><h1>Hallo Ada, dein Warenkorb ist bereit</h1><p>3 Artikel in deinem Warenkorb</p></body></html>The cookie beats
Accept-Language. Keep localized responses out of shared caches unless the cache keys on the locale cookie, as Server rendering explains.
Islands
Section titled “Islands”Wire the island like any client app: React, Svelte,
Vue or Solid. A server-rendered Vue island must not subscribe
during the server render, or each render leaves a listener on the process-wide store; the
Vue composable subscribes in onMounted, which runs only in
the browser.
With on-demand rendering, render <html lang> from m.getLocale(), as above. The client store
reads the cookie, then <html lang>, and the server negotiated from the same cookie or
Accept-Language, so an island hydrates in the server’s language.
On the static [lang] route the URL picks the language, but the client store still reads the
cookie before <html lang>. Once a visitor has the cookie:
- An island on
/en/hydrates in the cookie’s language, and React reports a hydration mismatch. - The store rewrites
<html lang>to the cookie’s language. - A
setLocaleswitcher updates only the islands; Astro-rendered text stays in the URL’s language.
The locale comes from the cookie by design (Limits).
On this route, switch language by linking to /{tag}/, and sync the cookie to the URL’s locale
before any island hydrates:
<html lang={m.getLocale()}> <head> <meta charset="utf-8" /> <script is:inline define:vars={{ lang: m.getLocale(), cookie: m.cookie }}> document.cookie = `${cookie}=${lang}; path=/; max-age=31536000; SameSite=Lax` </script> <title>{m.nav_cart()}</title> </head>With it, /en/ hydrates in English whatever cookie the visitor brought, and /de/ writes de.