How to translate a SvelteKit site into Spanish

A language switcher, /es URLs and a Spanish catalog for every string already in your markup. vela enable i18n sets up Wuchale, which extracts text from components instead of asking you to invent message keys, so translating a page means editing a .po file.

3 min read i18nlocalizationtranslationwuchalesveltekit

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

$ vela enable i18n
✔ 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:

wuchale.config.js
// @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:

vite.config.ts
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:

src/hooks.server.ts
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:

src/hooks.ts
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);
src/lib/url.ts
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:

src/routes/+layout.ts
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:

src/lib/components/language-select.svelte
<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:

$ vela i18n extract

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/locales/es.po
#: 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

$ vela dev

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.

Tip
Wuchale extracts text from markup and from string literals in +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

Related tutorials

How to give users API keys for a SvelteKit app

Open your app's data as a REST API and let users create and revoke their own keys. vela enable api serves PocketBase's API under /api; vela enable api-keys adds the keys page, hashed secrets and Bearer-token access, with collection rules still in charge.

3 min read

How to let clients edit a SvelteKit site themselves

An admin bar on your own site where editors change copy in place, upload images and publish, with no redeploy. vela enable cms hosts the CMS inside your app, or points a static site at a hosted one, and you mark up which text is editable.

4 min read

How to add login and signup to a SvelteKit app

Login, signup, password reset, email verification, one-time codes and OAuth buttons, plus a settings page and a protected dashboard. vela enable auth generates all of it on PocketBase, with a server test for every route.

4 min read