Keywords: sveltekit • svelte • i18n • internationalization • multilingual • url localization • seo • tailwind css
URL‑driven localization for SvelteKit (v2.38+). Clean slugs, strict mapping under routes/[lang=lang], zero cookies, and a single source of truth for languages.
src/i18n/routes.jssrc/i18n/routing.jssrc/i18n/i18n.jssrc/i18n/languages.js (single source of truth)routes/[lang=lang] (root endpoints untouched){...rest} in slug mappings/playground/api and /playground/i18npnpm install
pnpm dev
Create .env (or use .env.example):
# Copy environment file
cp .env.example .env
# Edit .env
PUBLIC_DEFAULT_LOCALE=en # en | sl | de (language only, e.g. en from en-US)
PUBLIC_PREFIX_DEFAULT_LOCALE=false# true → default language uses URL prefix
PUBLIC_DEFAULT_LOCALE) lives at root (/) by default./<lang>/… (e.g., /sl/…, /de/…).PUBLIC_PREFIX_DEFAULT_LOCALE=true to put the default language under a prefix as well (/<DEFAULT_LANG>/…).routes/[lang=lang] branch is localized. Root‑level endpoints (e.g., /robots.txt, /favicon.ico) are never redirected or blocked by i18n logic.Redirects/canonicalization:
/<DEFAULT_LANG>/… → redirects to unprefixed (/…)./ → redirects to /<DEFAULT_LANG>.src/i18n/languages.js
LANGUAGES: metadata (code → { label, locales }).SUPPORTED_LANGS: derived list of supported codes. Edit here to add/remove languages.src/i18n/routes.js
ROUTE_SLUGS: data‑only slug map./team, /news/{slug}, /terms/nest).en).src/i18n/routing.js
DEFAULT_LANG, PREFIX_DEFAULT, PREFIX_RULE (no prefix for default unless env says so).normalizePath(path): "/" root, strips trailing slash, ensures leading slash.toLocalized(canonicalPath, lang): canonical → localized, preserves {slug} values and remainders.toCanonical(localizedPath, lang): localized → canonical, preserves placeholders.isValidLocalizedPath(localizedPath, lang): strict validation inside prefixed branch.switchLanguageUrl(currentHref, fromLang?, toLang): language switcher preserving query/hash.src/i18n/i18n.js
src/locales/<lang>/*.json into DICTS.makeT(lang) → t(key, vars?) with dot‑notation and {var} interpolation.setTContext(lang), useT(), and t() convenience (reads context).translatePath(canonicalPath): localized + prefixed path using context lang.translatePathFor(path, lang): same as above, but explicit lang (safe in event handlers).languages (array) for UI (Navbar).src/hooks.server.js
lang from the first path segment only if the URL starts with / <supportedLang> /./ <lang> / … using isValidLocalizedPath.locals.lang, locals.intlLocale, locals.t, and locals.i18n.%html-lang% in app.html via transformPageChunk.src/hooks.js
[lang=lang] pages.src/routes/+layout.server.js
{ lang } to the client.src/routes/+layout.svelte
setTContext(data.lang) so t() works across the app.src/routes/+error.svelte
t() and translatePath("/")).Canonical vs localized:
ROUTE_SLUGS keys; mapping is per language./news/{slug} ↔ /sl/novice/{slug}/rest/{...rest}/last ↔ /sl/poljubno/{...rest}/zadnje/terms/nest ↔ /sl/pogoji-uporabe-storitev/gnezdo.Matching precedence:
/rest/dynamic or /rest/dynamic/{...rest} override generic /rest/{...rest}.Default language (prefix OFF):
/en/strani or /strani with en default), it 404s.Non‑default languages:
/<lang>/…./de/strani/enostavna → 404 when a localized mapping exists for German).<script>
import { t } from '$i18n/i18n';
</script>
<h1>{t('home.h1')}</h1>
{@html t('home.title', { NAME: 'Nik' })}
<script>
import { translatePath, translatePathFor, switchLanguageUrl } from '$i18n/i18n';
import { page } from '$app/state'; // already used in this project
</script>
<!-- use context language -->
<a href={translatePath('/terms/nest')}>Terms</a>
<!-- explicit language (safe in event handlers) -->
<a onclick={() => goto(translatePathFor('/news/abc', 'sl'))}>SL article</a>
<!-- language switcher (preserves query + hash) -->
<a href={switchLanguageUrl('sl', `${page.url.pathname}${page.url.search}${page.url.hash}`)}>SL</a>
Make t() available during SSR and keep it in sync on navigation:
<script>
import { page } from '$app/state';
import { setTContext } from '$i18n/i18n';
// SSR / first render
setTContext(page.data.lang);
// Keep in sync (Svelte 5 runes)
$effect(() => {
const lang = page.data.lang;
if (lang) setTContext(lang);
});
</script>
The language hook sets helpers on locals for all endpoints and server loads:
// types are declared in src/app.d.ts (App.Locals)
// { lang: string; intlLocale: string; t: (key,vars?) => string; i18n: { lang, intlLocale } }
import { json } from "@sveltejs/kit";
/** @type {import('./$types').RequestHandler} */
export function GET({ locals, params }) {
const title = locals.t("blog_h1");
return json({ lang: locals.lang, intlLocale: locals.intlLocale, title });
}
src/i18n/languages.js and add your language code, label and locales.src/locales/<lang>/*.json (any number of JSON files; they are deep‑merged).src/i18n/routes.js (only where they differ from canonical).src/routes/[lang=lang]/… as needed.src/routes/[lang=lang]/… (e.g., /team, /news/[slug]).ROUTE_SLUGS:export const ROUTE_SLUGS = {
sl: {
"/pages": "/strani",
"/pages/{slug}": "/strani/{slug}",
},
// de: { … }
};
translatePath('/team') in your links; language prefix and localized slugs are applied automatically./playground/api (under any language)/server/simple, GET/POST /server/rest/[...rest], and GET /server/translated./playground/i18n (under any language)normalizePath, toCanonical, toLocalized, isValidLocalizedPath, and prefixed output live.DEFAULT_LANG and PREFIX_DEFAULT for quick checks.pnpm dev # start dev server
pnpm build # build for production
pnpm preview # preview built app
Adapter: the project uses @sveltejs/adapter-auto by default.
src/i18n/routes.js is data‑only: do not import Svelte or add logic there.Accept-Language detection — language comes from the URL only.routes/[lang=lang] are localized; root endpoints remain reachable.{slug}) are preserved across canonical/localized conversion.