Add localization and locale-aware routing using Wuchale. Ships with en and es locales, a LanguageSelect dropdown, server-side locale resolution in hooks.server.ts, and a reroute hook that maps localized URLs to their canonical paths. Locale catalogs are loaded per request with runWithLocale, and the public root layout gains a language switcher in the navbar. wuchale.config.js, vite.config.ts, and the $locales alias are all configured for you.
import { deLocalizeDefault } from "wuchale/url";
import { matchUrl } from "$locales/main.url";
import { locales } from "$locales/data";
const rerouteDeLocalize = (url: string) => {
const [upath, locale] = deLocalizeDefault(url, locales);
const { path } = matchUrl(upath, locale);
return path ?? url;
};
export const reroute = ({ url }) => rerouteDeLocalize(url.pathname);
<script lang="ts">
import * as Select from "$lib/components/ui/select/index.js";
import { locales, type Locale } from "$locales/data.js";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { deLocalizeDefault } from "wuchale/url";
import { defaultLocale, translateUrl } from "$lib/url";
let locale: Locale = $derived.by(() => {
const [_, locale] = deLocalizeDefault(page.url.pathname, locales);
return locale ?? defaultLocale;
});
function localeDisplayName(code: Locale): string {
const names = new Intl.DisplayNames([code], { type: "language" });
const raw = names.of(code);
if (!raw) return code;
return raw.charAt(0).toLocaleUpperCase(code) + raw.slice(1);
}
const localeNames: Record<string, string> = locales.reduce(
(acc, loc) => {
acc[loc] = localeDisplayName(loc);
return acc;
},
{} as Record<string, string>,
);
const handleValueChange = (value: string) => {
const translatedUrl = translateUrl(
page.url.pathname,
locale,
value as Locale,
);
goto(translatedUrl, { invalidateAll: true });
};
let { class: className }: { class?: string } = $props();
</script>
<Select.Root type="single" value={locale} onValueChange={handleValueChange}>
<Select.Trigger class={className}>{localeNames[locale]}</Select.Trigger>
<Select.Content>
{#each locales as locale}
<Select.Item value={locale}>{localeNames[locale]}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
import { deLocalizeDefault, fillParams } from "wuchale/url";
import type { Locale } from "../locales/data";
import { matchUrl } from "../locales/main.url";
import { locales } from "../locales/data";
export const defaultLocale = "en";
export function localize(path: string, locale: Locale) {
if (locale === defaultLocale) {
return path;
}
return `/${locale}${path}`;
}
export function translateUrl(
url: string,
fromLocale: Locale,
toLocale: Locale,
) {
const [pathOnly] = deLocalizeDefault(url, locales);
const result = matchUrl(pathOnly, fromLocale);
if (result.path !== null) {
const targetPath = fillParams(result.params, result.altPatterns[toLocale]);
return localize(targetPath, toLocale);
}
return localize(url, toLocale);
}
// @ts-check
import { adapter as svelte } from "@wuchale/svelte";
import { adapter as js } from "wuchale/adapter-vanilla";
import { defineConfig } from "wuchale";
export default defineConfig({
locales: ["en", "es"],
adapters: {
main: svelte({
loader: "sveltekit",
url: { localize: true, patterns: ["/"] },
}),
js: js({
loader: "vite",
files: [
"src/**/+{page,layout}.{js,ts}",
"src/**/+{page,layout}.server.{js,ts}",
],
}),
},
});
<!doctype html>
<html lang="%sveltekit.lang%">
<head>
<meta charset="utf-8" />
</head>
<body>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
import { env } from "$env/dynamic/private";
import { handlePocketbase } from "@velastack/pocketbase";
import { sequence } from "@sveltejs/kit/hooks";
import { runWithLocale, loadLocales } from "wuchale/load-utils/server";
import { getLocale } from "$locales/main.url";
import { locales } from "$locales/data";
import * as main from "$locales/main.loader.server.svelte.js";
import * as js from "$locales/js.loader.server.js";
loadLocales(main.key, main.loadIDs, main.loadCatalog, locales);
loadLocales(js.key, js.loadIDs, js.loadCatalog, locales);
const handleWuchale = async ({ event, resolve }: any) => {
const locale = getLocale(event.url);
return await runWithLocale(locale, () =>
resolve(event, {
transformPageChunk: ({ html }: { html: string }) =>
html.replace("%sveltekit.lang%", locale),
}),
);
};
export const handle = sequence(
handleWuchale,
handlePocketbase({
pocketbaseUrl: env.POCKETBASE_URL,
superuserEmail: env.POCKETBASE_SUPERUSER_EMAIL,
superuserPassword: env.POCKETBASE_SUPERUSER_PASSWORD,
}),
);
<script lang="ts">
import { toggleMode } from "mode-watcher";
import * as Navbar from "$lib/components/ui/navbar";
import { Button } from "$lib/components/ui/button";
import LanguageSelect from "$lib/components/language-select.svelte";
let { children, data } = $props();
</script>
<div class="min-h-dvh grid grid-rows-[max-content_1fr_max-content] root-layout">
<Navbar.Root>
<Navbar.Brand href="/">
<Navbar.Logo src="/favicon.svg" alt="Logo" />
</Navbar.Brand>
<Navbar.MobileToggle />
<Navbar.List>
<Navbar.Item>
<Button
class="w-full justify-start md:justify-center md:w-auto"
href="/"
variant="ghost"
>
Home
</Button>
</Navbar.Item>
<Navbar.Item>
<LanguageSelect />
</Navbar.Item>
</Navbar.List>
</Navbar.Root>
{@render children?.()}
<footer class="border-t border-border py-4">
<div
class="mx-auto flex max-w-7xl px-4 flex-col items-center justify-between gap-3 sm:flex-row"
>
<p class="text-muted-foreground text-sm">
© {new Date().getFullYear()}
{data.meta.appName}. All rights reserved.
</p>
<nav class="flex items-center gap-4 text-sm">
<Button onclick={toggleMode} variant="ghost" size="icon">
<Navbar.Mode />
</Button>
<a href="/privacy" class="underline-offset-4 hover:underline">Privacy</a
>
<a href="/terms" class="underline-offset-4 hover:underline">Terms</a>
</nav>
</div>
</footer>
</div>
<style lang="postcss">
@reference "tailwindcss";
.root-layout :global(section[data-role="content"]) {
@apply container mx-auto max-w-7xl px-4 py-8 w-full;
}
</style>
import { browser } from "$app/environment";
import { loadLocale } from "wuchale/load-utils";
import { getLocale } from "$locales/main.url";
import "$locales/main.loader.svelte";
import "$locales/js.loader";
export const load = async ({ url, data }) => {
const locale = getLocale(url);
if (browser) {
await loadLocale(locale);
}
return data;
};
import adapter from "@sveltejs/adapter-auto";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
const config = {
preprocess: vitePreprocess(),
kit: {
adapter,
alias: {
$locales: "src/locales",
},
},
};
export default config;
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import { wuchale } from "wuchale/vite";
export default defineConfig({
plugins: [wuchale(), tailwindcss(), sveltekit()],
});