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

SvelteKit file-system routing A nested +layout and +page file map to nested URL segments, a load function returns data, and the page renders with that data. Files map to URLs +layout.svelte shared shell +page.svelte blog/[slug]/ URL /blog/hello load() returns data Rendered data prop
A file's position in src/routes fixes its URL segment; nested +layout and +page files compose, and their load functions feed data into the rendered page.

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 universal load that runs on both server and client; its return value is serialised into the page payload.
  • +page.server.ts — a server-only load for 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.tsload functions whose data merges into every child route.
  • +error.svelte — the boundary rendered when a load throws.
  • +server.ts — an endpoint exporting HTTP method handlers (GET, POST, …) instead of a component.

The directory naming grammar:

  • [id] — required dynamic segment, surfaced as params.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 in src/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>
SvelteKit route files and where each executes Six SvelteKit file conventions with their execution environment: components and universal loads run in both places, server loads and endpoints run only on the server, and error boundaries render wherever the failure happened. SvelteKit route files and where each runs file runs on +page.svelte the component server first, then client +page.ts universal load server and client +page.server.ts server-only load and actions the server only +layout.svelte shared chrome server first, then client +server.ts an endpoint, no UI the server only +error.svelte the error boundary wherever the error occurred
The .server suffix is the whole security boundary — anything without it ships to the browser.

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 = true ship 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 = false on 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.ts so they stream as part of the payload; keep credentialed or heavy queries in +page.server.ts to avoid shipping them. Use streamed promises in a server load to render the shell before slow data resolves.
  • Audit the waterfall. Record a client navigation in the DevTools Performance panel and look for redundant +page.ts requests caused by missing parent() reuse, or layout components re-rendering when only a leaf changed.

Reading a SvelteKit Route Directory

SvelteKit’s conventions are dense enough that a routes directory is genuinely a configuration file — one where a single character changes both the URL and the execution environment. Reading one fluently is a skill worth acquiring deliberately.

The prefix says what the file is. A + marks a framework file rather than an ordinary module, which is why +page.svelte is a route and Card.svelte sitting beside it is not. This is what allows components, helpers and tests to live next to the route that uses them without any of them accidentally becoming routable — a small convenience that removes an entire category of accidental URL.

The suffix says where it runs. .server means server-only: the module is never bundled for the browser, so secrets and database clients are safe inside it. Its absence means universal — the code runs on the server for the first render and in the browser for every subsequent navigation, and its source is shipped. This is a security boundary rather than a performance one, and it is the single most important thing to read correctly.

Brackets say the segment is dynamic. [id] matches one segment, [...rest] matches the remainder, and [[optional]] matches one or zero. Doubling the brackets is easy to miss and changes whether the parent path itself resolves, which is the usual cause of a section index that returns a 404.

Parentheses say the directory is not a URL segment. A group contributes a layout level and nothing else, which is how three unrelated areas get three different frames without three URL prefixes.

An @ says the layout chain stops here. +page@.svelte resets to the root layout; +page@(app).svelte resets to a named ancestor. Neither affects the URL, which is what makes both safe to introduce into an existing site.

Read in that order — prefix, suffix, brackets, parentheses, at-sign — any routes directory becomes legible in a few seconds, including one you have never seen before.

Gotchas & Failure Modes

  • sveltekit:prefetch is dead. Removed in 1.0; use data-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. beforeNavigate and afterNavigate from $app/navigation must run in a +layout.svelte or +page.svelte script block, not in a +layout.ts load — they require a live component instance.
  • Reset boundaries are easy to misfire. A bare +layout@.svelte resets to the root layout; +page@(group).svelte resets 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 with goto() from $app/navigation disables 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 = true that 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.
When to use a universal load and when to use a server load Five cases comparing universal and server-only load functions in SvelteKit: public data suits a universal load, anything involving secrets or a database requires the server variant, and only universal loads can return non-serialisable values. Choosing between universal and server load use +page.ts use +page.server.ts public API data yes — fetch once, reuse on the client unnecessary round trip data needing a secret never — the code ships yes database access never yes returning a class instance yes — no serialisation boundary cannot cross it form actions not available yes
The second and third rows are not preferences — a universal load's source code is shipped to the browser.

A closing note on the trade this file-system model makes. Deriving routes from directories removes an entire configuration file and, with it, a whole class of drift between what the table says and what the components do — a route cannot exist without a file, and a file in the right place is automatically routable. That is a genuine simplification, and it is why the model has spread beyond SvelteKit.

The cost is that the structure is expressed in filenames, so refactoring a URL means moving directories rather than editing a line, and reviewing a routing change means reading a file tree in a diff. Both are manageable, and both are worth knowing about before a large restructure: a rename that looks like a two-file change in a configuration-based router can be a fifty-file move here, and the diff will be considerably harder to read than the change actually is.