# 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.

Pattern: [Enable i18n](https://velastack.dev/patterns/enable-i18n) · Docs: https://docs.velastack.dev/enable/i18n

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](https://wuchale.dev) 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

```sh
$ 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**

```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**

```ts
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
// [!code highlight:1]
import { wuchale } from "wuchale/vite";

export default defineConfig({
  // [!code highlight:1]
  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**

```ts
import { env } from "$env/dynamic/private";
import { handlePocketbase } from "@velastack/pocketbase";
// [!code highlight:6]
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";

// [!code highlight:2]
loadLocales(main.key, main.loadCount, main.loadCatalog, locales);
loadLocales(js.key, js.loadCount, js.loadCatalog, locales);

// [!code highlight:9]
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(
  // [!code highlight:1]
  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**

```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**

```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**

```ts
import { browser } from "$app/environment";
// [!code highlight:4]
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 }) => {
  // [!code highlight:5]
  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**

```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:

```sh
$ 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**

```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

```sh
$ vela dev
```

Open [localhost:5173](http://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 `&lt;0/&gt;` 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/acerca` instead of `/es/about`) are a `url.po` catalog away; see the [Wuchale docs](https://wuchale.dev)
- [Content negotiation](https://velastack.dev/tutorials/sveltekit-markdown-for-ai-agents) composes with the locale reroute, so `/es/about.md` works too
- With [the CMS](https://velastack.dev/tutorials/sveltekit-let-clients-edit-content) enabled afterwards, editors get one field per locale
- [Pattern page: Internationalization](https://velastack.dev/patterns/enable-i18n) · [Docs: vela enable i18n](https://docs.velastack.dev/enable/i18n) · [Docs: vela i18n](https://docs.velastack.dev/i18n)
