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.js
src/i18n/routing.js
src/i18n/i18n.js
src/i18n/languages.js
(single source of truth)routes/[lang=lang]
(root endpoints untouched){...rest}
in slug mappings/playground/api
and /playground/i18n
pnpm 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.