Skip to content
loclizr

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.

  1. Terminal window
    npm create astro@latest app -- --template minimal --install --no-git --yes
    cd app
    npm i loclizr
    npm i -D @astrojs/check typescript
    npx loclizr init

    init seeds locales/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}}"
    }
    }
  2. 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 init printed, plus typecheck running astro check.

  3. 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>
  4. src/middleware.ts
    import { defineMiddleware } from 'astro:middleware'
    import { runWithLocale } from 'loclizr/server'
    export const onRequest = defineMiddleware((context, next) => {
    const { lang } = context.params
    return lang === undefined ? next() : runWithLocale(lang, next)
    })

    Middleware runs for every prerendered page at build time, and its scope covers each child component. npm run build writes:

    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>

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]/.

  1. Terminal window
    npx astro add node --yes

    astro add writes the adapter; add output: 'server' yourself.

    astro.config.mjs
    // @ts-check
    import { defineConfig } from 'astro/config';
    import node from '@astrojs/node';
    // https://astro.build/config
    export default defineConfig({
    output: 'server',
    adapter: node({
    mode: 'standalone'
    })
    });
  2. 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
    })

    localeFromRequest reads the configured cookie (m.cookie), then Accept-Language, then the source locale.

  3. Delete src/pages/[lang]/, then replace src/pages/index.astro with the same page minus getStaticPaths:

    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>
  4. Terminal window
    npm run build
    PORT=8080 node dist/server/entry.mjs

    PORT overrides the adapter’s default of 4321.

    curl -si -H 'Accept-Language: de-CH,de;q=0.9' http://localhost:8080/
    HTTP/1.1 200 OK
    content-language: de
    content-type: text/html
    vary: Accept-Language, Cookie
    Date: Sat, 10 Oct 2026 18:51:04 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    Transfer-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 OK
    content-language: de
    content-type: text/html
    vary: Accept-Language, Cookie
    Date: Sat, 10 Oct 2026 18:51:05 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    Transfer-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.

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 setLocale switcher 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:

src/pages/[lang]/index.astro
<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.