SvelteKit Routing Conventions
SvelteKit decides what URL a file serves entirely from where that file sits inside src/routes, then compiles the tree into a manifest that drives both server-rendered first paint and instant client-side transitions. If you arrive expecting a central route table to edit — the way React Router or Vue Router work — the absence of one is disorienting: a misplaced bracket directory or a stray +layout.svelte silently changes which URL resolves, which component shell wraps it, and whether a parameter is even reachable. This page is for engineers building or auditing a SvelteKit 2.x app who need the file-naming rules, the load-and-layout data model, and the deployment adapter story to behave predictably across Chromium 105+, Safari 16.4+, and Firefox 118+.
← Back to Framework-Specific Routing Patterns
The Problem
In a conventional client-side router you declare routes imperatively and a runtime matcher walks them — a model explored in route matching algorithms. SvelteKit replaces that runtime table with the directory structure itself, so mistakes do not throw at startup; they manifest as wrong output. A directory named [id] and a sibling [[id]] create an ambiguous precedence that resolves to whichever the compiler ranks higher, leaving the other unreachable. A +layout.svelte dropped into a route group wraps every descendant in a shell you did not intend. A trailingSlash value that disagrees with your canonical tags emits two crawlable URLs for one page, splitting link equity.
The deployment layer compounds this. The same route tree can be prerendered to static HTML, server-rendered per request, or run on an edge function, and the choice is made per route via export const prerender plus the adapter you configure. Pick wrong and you either ship stale content from a static build or pay per-request server cost on a page that never changes. Because all of this is encoded in file names and a handful of export const declarations rather than a reviewable config object, the failure modes are easy to introduce and hard to spot in review — which is exactly why the conventions are worth internalising rather than copying ad hoc.
There is a subtler problem that bites teams migrating from a runtime router: data fetching is no longer a component concern. In a client-side router you commonly fetch inside a component effect after mount, which means the first paint is empty and crawlers see a shell. SvelteKit inverts this. The load function runs before the component, on the server during the initial request and then on the client for subsequent navigations, and its result is what the component receives as props. Treating load like a component effect — fetching inside onMount instead — discards SSR, reintroduces the empty-shell first paint, and quietly negates the framework’s main reason for existing. The conventions only pay off when data flows through load, layouts compose that data with parent(), and the component stays a pure function of the props it is handed.
Core API & Primitives
SvelteKit reserves a small set of + prefixed file names. Each has a fixed role and a generated ./$types companion that types its data:
+page.svelte— the component rendered at a route’s URL.+page.ts/+page.js— a universalloadthat runs on both server and client; its return value is serialised into the page payload.+page.server.ts— a server-onlyloadfor database access, secrets, and form actions; never shipped to the browser.+layout.svelte— a shell that wraps the route plus all descendants, rendering children through<slot />(or{@render children()}in Svelte 5 runes mode).+layout.ts/+layout.server.ts—loadfunctions whose data merges into every child route.+error.svelte— the boundary rendered when aloadthrows.+server.ts— an endpoint exporting HTTP method handlers (GET,POST, …) instead of a component.
The directory naming grammar:
[id]— required dynamic segment, surfaced asparams.id.[[id]]— optional segment (handling optional dynamic segments covers the matching subtleties shared across frameworks).[...rest]— catch-all that captures the remaining path as one string.[id=integer]— a matcher constraint resolved against a function insrc/params/.(group)— a grouping directory excluded from the URL, used only to share a layout or isolate data.
Load functions receive a strongly typed event. The universal signature:
// @sveltejs/kit v2.x — generated ./$types is project-local
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, fetch, url, parent }) => {
// params.slug is typed from the [slug] directory name
const res = await fetch(`/api/posts/${params.slug}`);
const post = await res.json();
// parent() awaits data from ancestor layout loads, enabling composition
const { user } = await parent();
return { post, canEdit: post.authorId === user?.id };
};
Per-route rendering and adapter behaviour are controlled by page options exported from any +page/+layout module: prerender, ssr, csr, and trailingSlash. These exports are inherited down the tree, so setting export const ssr = false on a layout disables server rendering for the whole subtree unless a child overrides it. Global defaults and the deployment target live in svelte.config.js.
One precedence rule deserves emphasis because it has no analogue in a config-object router: when two directories could match the same URL, SvelteKit ranks candidates deterministically — more specific static segments beat dynamic ones, required parameters beat optional, and rest parameters lose to everything. The ranking is computed at build time and is stable, but it is invisible in the source tree, so a [slug] directory placed beside a literal about directory will never shadow /about, while two equally specific dynamic siblings produce a build-time error rather than a silent winner. Knowing the order lets you reason about reachability without running the app.
Step-by-Step Implementation
Prerequisite: a SvelteKit 2.x project (npm create svelte@latest) with TypeScript enabled and Node 18+.
Step 1: Configure the adapter and global routing policy
The adapter translates the compiled output into a deployable artefact. adapter-auto detects common platforms; pin a specific adapter once you know the target. Set trailingSlash once, globally, so canonical URLs never diverge.
// svelte.config.js — @sveltejs/kit v2.x, @sveltejs/adapter-auto v3.x
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import type { Config } from '@sveltejs/kit';
const config: Config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
// one canonical form prevents duplicate-URL indexing
trailingSlash: 'always',
alias: { $lib: './src/lib' }
}
};
export default config;
Step 2: Create a route with a layout and grouped section
Group authenticated pages under (app) so they share a shell without adding /app to the URL. The layout renders shared chrome and a slot for children.
// src/routes/(app)/+layout.svelte — Svelte 5, SvelteKit v2.x
<script lang="ts">
import type { LayoutData } from './$types';
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
</script>
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
<span>Signed in as {data.user.name}</span>
</nav>
{@render children()}
Step 3: Load data on the server for protected routes
A +layout.server.ts runs only on the server, so it can read cookies and throw a redirect before any child renders. Its return value merges into every descendant via parent().
// src/routes/(app)/+layout.server.ts — @sveltejs/kit v2.x
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => {
const session = cookies.get('session');
if (!session) {
// 303 redirect short-circuits before any child load runs
throw redirect(303, '/login');
}
const user = await getUserFromSession(session);
return { user };
};
Step 4: Add a dynamic segment with a matcher constraint
A param matcher rejects non-conforming values at the routing layer, so /posts/abc falls through to a 404 instead of reaching load with a bad id. This is SvelteKit’s typed equivalent of the broader dynamic route segment techniques.
// src/params/integer.ts — @sveltejs/kit v2.x
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (value): boolean => /^\d+$/.test(value);
// directory src/routes/(app)/posts/[id=integer]/+page.svelte now only
// matches numeric ids; everything else hits the fallback route
Step 5: Tune navigation and prerender the static parts
Mark genuinely static routes for prerendering and enable hover preloading so the next route’s code and data are in flight before the click commits. SvelteKit drives the browser’s history internally — see pushState & replaceState usage for the underlying primitives it wraps.
// src/routes/about/+page.ts — @sveltejs/kit v2.x
export const prerender = true; // emitted as static HTML at build time
// src/app.html — opt the whole document into hover preloading
// <body data-sveltekit-preload-data="hover"> ... </body>
Verification & Testing
Confirm the compiled route tree actually matches your intent by driving a real browser. Playwright asserts that grouped layouts, dynamic params, and redirects behave end to end.
// tests/routing.spec.ts — @playwright/test v1.4x
import { test, expect } from '@playwright/test';
test('matcher rejects non-numeric ids', async ({ page }) => {
const res = await page.goto('/posts/not-a-number');
expect(res?.status()).toBe(404);
});
test('client navigation keeps the grouped layout mounted', async ({ page }) => {
await page.goto('/dashboard');
const nav = page.getByRole('navigation');
await expect(nav).toBeVisible();
// soft navigation: nav node is reused, not re-created on route change
const handleBefore = await nav.elementHandle();
await page.getByRole('link', { name: 'Settings' }).click();
await expect(page).toHaveURL('/settings/');
const stillThere = await handleBefore?.evaluate((n) => n.isConnected);
expect(stillThere).toBe(true);
});
For a quick manual check, open Chrome DevTools, enable “Highlight ad frames” off and watch the Network panel filter on Fetch/XHR: a correctly preloaded link issues its +page.ts data request on hover, before the navigation, with no full-document request.
Performance Tuning
- Prerender what does not change. Routes with
export const prerender = trueship as static HTML with near-zero TTFB and no per-request server cost. Reserve SSR for genuinely volatile data. - Disable CSR for static-content pages. Setting
export const csr = falseon a marketing or docs route drops the client bundle for that page, improving LCP and INP where interactivity is unnecessary. - Preload at the right granularity.
data-sveltekit-preload-data="hover"warms data on intent;"tap"defers to pointer-down to save bandwidth on touch devices and high-fan-out indexes. - Split loads correctly. Put fetches that the browser can repeat in
+page.tsso they stream as part of the payload; keep credentialed or heavy queries in+page.server.tsto avoid shipping them. Use streamed promises in a serverloadto render the shell before slow data resolves. - Audit the waterfall. Record a client navigation in the DevTools Performance panel and look for redundant
+page.tsrequests caused by missingparent()reuse, or layout components re-rendering when only a leaf changed.
Gotchas & Failure Modes
sveltekit:prefetchis dead. Removed in 1.0; usedata-sveltekit-preload-data. The old attribute fails silently with no warning.- Optional segments use double brackets. It is
[[id]], never[id?]. The question-mark form is not valid and the directory simply will not match as intended. - Navigation hooks need a component context.
beforeNavigateandafterNavigatefrom$app/navigationmust run in a+layout.svelteor+page.sveltescript block, not in a+layout.tsload— they require a live component instance. - Reset boundaries are easy to misfire. A bare
+layout@.svelteresets to the root layout;+page@(group).svelteresets to a named ancestor. Drop one in the wrong place and an entire subtree loses or gains a shell unexpectedly. goto()is not a drop-in for<a>. Replacing semantic links withgoto()from$app/navigationdisables preloading, harms accessibility, and removes the crawlable href. Reserve it for post-action redirects.- Prerender plus dynamic data is a trap. A route marked
prerender = truethat reads request-time cookies or query params will be baked at build time and serve the same snapshot to everyone — exactly the staleness the static path is supposed to be a deliberate trade for, not an accident.
Related
- Framework-Specific Routing Patterns — the parent overview comparing routing models across major frontend frameworks.
- Next.js App Router vs Pages Router — the closest file-system routing analogue, with its own layout and server-component conventions.
- Vue Router Configuration — a config-object router for contrast with SvelteKit’s file-driven approach.
- React Router Implementation — declarative, runtime route matching as the counterpoint to compile-time file conventions.