The usual price of a second language is paid up front: every string in every component becomes t("some.key"), and a JSON file per locale has to be kept in step with the markup by hand. Wuchale takes the other route. It reads your components at build time, extracts the text as it is, and compiles a catalog per locale. You keep writing <h1>Welcome back</h1>; the Spanish lives in a .po file a translator can edit. vela enable i18n wires it into SvelteKit with English and Spanish, locale-aware URLs and a switcher in the navbar.
Prerequisites
Any SvelteKit project. This pattern has no dependencies: it works on a static site as well as one with a backend, and in a plain npx sv create project that vela did not create. The output below is from a vela project; in a plain one there is no navbar to put the switcher in, so it is a native <select> rendered above the page content in src/routes/+layout.svelte. Everything else is the same.
Run the command
✔ Installed wuchale, @wuchale/svelte ✔ Created 4 files, modified 6 wuchale.config.js src/hooks.ts src/lib/url.ts src/lib/components/language-select.svelte src/hooks.server.ts src/routes/+layout.ts src/routes/(public)/root-layout.svelte vite.config.ts svelte.config.js src/app.html
What was generated
The locales
wuchale.config.js lists the locales; the first one is the source language and is served without a prefix. Two adapters are configured: one for Svelte components, one for strings in +page.ts and +page.server.ts files, such as page titles:
// @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"],
// `vela test:server` stubs every +page.svelte. In the default `refs` dev mode
// the extractor would take those stubs at face value, drop the pages'
// references from the .po files and recompile the catalogs while requests
// are in flight. Under TEST wuchale neither transforms nor writes anything.
...(process.env.TEST === "true" ? { dev: false } : {}),
adapters: {
main: svelte({
loader: "sveltekit",
url: { localize: "src/lib/url.ts", patterns: ["/"] },
}),
js: js({
loader: "vite",
files: [
"src/**/+{page,layout}.{js,ts}",
"src/**/+{page,layout}.server.{js,ts}",
],
}),
},
});
Extraction
The Vite plugin does the extraction, so it has to run before SvelteKit’s:
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()],
});
The server
Each request reads its locale from the URL, runs inside it so every string renders in the right language, and stamps lang on the <html> element:
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.loadCount, main.loadCatalog, locales);
loadLocales(js.key, js.loadCount, 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,
}),
);
URLs
/es/about is the same route as /about. The reroute hook strips the locale prefix before SvelteKit matches the route; src/lib/url.ts adds it back when building links, leaving the default locale unprefixed:
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);
import { deLocalizeDefault, stringifyPattern } from "wuchale/url";
import type { Locale } from "../locales/data";
import { matchUrl } from "../locales/main.url";
import { locales } from "../locales/data";
// wuchale treats locales[0] as the source locale, so it is the one served unprefixed
export const defaultLocale: Locale = locales[0];
/**
* Localizer for wuchale's `url.localize` config. Compiled URLs in components
* are passed through this, so the default locale stays unprefixed.
*/
export function localize(path: string, locale: Locale) {
if (locale === defaultLocale) {
return path;
}
// matches wuchale's localizeDefault, which drops the trailing slash
const localized = `/${locale}${path}`;
return localized.endsWith("/") ? localized.slice(0, -1) : localized;
}
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 = stringifyPattern(
result.altPatterns[toLocale],
result.params,
);
return localize(targetPath, toLocale);
}
return localize(pathOnly, toLocale);
}
The client
The root +layout.ts loads the catalog for the current locale in the browser, so client-side navigation renders translated text too:
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;
};
The switcher
A select in the navbar lists the locales by their own names (“English”, “Español”). Changing it translates the current URL and navigates:
<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>
Extract and translate
Extraction runs whenever the dev server or a build runs. To run it on its own:
It writes one catalog per locale under src/locales/. The source strings are already in en.po; es.po is where the Spanish goes, one entry per string with the file it came from:
#: src/routes/+error.svelte
msgid "Oops! Something went wrong"
msgstr "¡Ups! Algo salió mal"
#: src/routes/(public)/+page.svelte
msgid "Get Started"
msgstr "Empezar"Fill in the msgstr lines and restart the dev server. An entry left empty falls back to English, so a half-translated site still works. vela i18n status shows how much is left, and vela i18n clean drops entries whose source string no longer exists.
Try it
Open localhost:5173 and pick Español in the navbar. The URL becomes /es, the <html lang> is es, and every string with a translation switches. Add a new heading to any component, restart, and the entry appears in both catalogs.
+page and +layout files. Text with markup inside it is one entry, with placeholders like <0/> standing in for the elements, so a translator can move a link or a bold span within the sentence.Make it yours
To add a locale, add it to the locales array, restart, and translate the new .po file. The switcher, the URL prefix and the server hook read the list, so nothing else changes.
Going further
- Translated page paths (
/es/acercainstead of/es/about) are aurl.pocatalog away; see the Wuchale docs - Content negotiation composes with the locale reroute, so
/es/about.mdworks too - With the CMS enabled afterwards, editors get one field per locale
- Pattern page: Internationalization · Docs: vela enable i18n · Docs: vela i18n


