Routing & Pages
Folders are routes
Section titled “Folders are routes”In SvelteKit, the folder structure under src/routes/ is the URL structure. A +page.svelte file makes its folder a navigable page.
src/routes/├── +page.svelte → /├── about/│ └── +page.svelte → /about├── blog/│ ├── +page.svelte → /blog│ └── [slug]/│ └── +page.svelte → /blog/:slug (dynamic)└── +layout.svelte → wraps every pageflowchart LR f1["routes/+page.svelte"] --> u1["/"] f2["routes/about/+page.svelte"] --> u2["/about"] f3["routes/blog/[slug]/+page.svelte"] --> u3["/blog/:slug"]
Dynamic parameters
Section titled “Dynamic parameters”Square brackets in a folder name capture a URL segment. src/routes/blog/[slug]/+page.svelte matches /blog/hello, and the value is available as params.slug (in a load function — next lesson). Variations:
[slug]— a required parameter.[...rest]— a rest parameter that matches multiple segments (/docs/a/b/c).[[optional]]— an optional parameter that also matches when absent.
Param matchers: validating a segment before it matches
Section titled “Param matchers: validating a segment before it matches”A plain [id] matches any segment. A matcher — a function in src/params/ — lets a route only match when the segment satisfies a condition, so an unrelated static route underneath can win instead:
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => /^\d+$/.test(param);// src/routes/products/[id=integer]/+page.server.jsexport function load({ params }) { return { id: params.id }; // only reached when params.id is all digits}Proving both the matching and the falling-through, against real running routes (/products/[id=integer] and a plain static /products/latest):
$ curl -s http://localhost:5196/products/42 | grep -o '<h1[^<]*</h1>'<h1 data-testid="product">product 42</h1>
$ curl -s http://localhost:5196/products/latest | grep -o '<h1[^<]*</h1>'<h1 data-testid="latest">latest products (non-numeric, matcher rejects it so this static route wins)</h1>/products/42 satisfies integer’s regex and hits the dynamic route; /products/latest doesn’t, so SvelteKit falls through to the plain products/latest/+page.svelte instead of a 404 — matchers let you disambiguate two routes that would otherwise collide on the same segment.
Route groups: organizing without changing the URL
Section titled “Route groups: organizing without changing the URL”Wrapping a folder name in parentheses — (app) — creates a route group: it organizes routes (and can hold a shared +layout.svelte) without adding a segment to the URL.
src/routes/└── (app)/ └── grouped/ └── +page.svelte → /grouped (NOT /app/grouped or /(app)/grouped)Proven against the real route:
$ curl -s http://localhost:5196/grouped | grep -o '<h1[^<]*</h1>'<h1 data-testid="grouped">grouped page, no (app) segment in the URL</h1>The URL is /grouped, not /app/grouped — the group folder is purely organizational (typically used to give a whole section, like every authenticated route, a shared layout without that showing up in every URL).
Layouts wrap pages
Section titled “Layouts wrap pages”A +layout.svelte applies to its folder and every route beneath it — the place for shared chrome like a header and footer. It renders its child route through the children snippet:
<script> let { children } = $props(); // the active child page</script>
<nav>…site navigation…</nav>
<main> {@render children()} <!-- the current page renders here --></main>
<footer>© 2026</footer>Layouts nest: a layout deeper in the tree wraps inside the ones above it, so you can compose a global shell with section-specific shells.
Resetting the layout chain with @
Section titled “Resetting the layout chain with @”Sometimes one page needs to skip the layouts above it entirely — a full-bleed landing page, a print view. Naming a file [email protected] (or [email protected]) resets to the root, skipping every layout in between; +page@(group).svelte resets to a named ancestor instead of the very root. SvelteKit’s own route-file regex confirms the syntax is page(@(.*))? / layout(@(.*))? — @ followed by an optional group name.
<!-- src/routes/(app)/wide/+layout.svelte — the (app) section's shared shell --><script lang="ts"> let { children } = $props();</script><div> <nav>app nav</nav> {@render children()}</div><!-- src/routes/(app)/wide/[email protected] — @ resets to root, skipping (app)'s own layout --><h1>wide page — reset with @, skips (app)/+layout.svelte and the root layout's own markup</h1>Proven by curling the rendered HTML — the (app) layout’s <nav>app nav</nav> is nowhere in the response, only the page’s own <h1>:
$ curl -s http://localhost:5197/wide | grep -o '<h1[^<]*</h1>\|<nav>[^<]*</nav>'<h1 data-testid="wide">wide page — reset with @, skips (app)/+layout.svelte and the root layout's own markup</h1>Error pages and the boundary hierarchy
Section titled “Error pages and the boundary hierarchy”+error.svelte in a folder renders when a load (or a page/layout itself) throws in that subtree — a scoped error boundary that reads the error via page.error (from $app/state). SvelteKit walks up the route tree looking for the nearest +error.svelte, starting at the route that threw.
Proven with two routes: errors/deep throws under a folder that has its own errors/+error.svelte, and errors2/deep2 throws under a folder that does not — so it bubbles all the way to the root src/routes/+error.svelte:
import { error } from '@sveltejs/kit';export function load() { error(500, 'boom from errors/deep');}// src/routes/errors2/deep2/+page.server.js — no scoped +error.svelte anywhere in errors2/import { error } from '@sveltejs/kit';export function load() { error(500, 'boom from errors2/deep2');}$ curl -s http://localhost:5196/errors/deep | grep -o '<h1[^<]*</h1>'<h1 data-testid="err">SCOPED-ERROR errors/+error.svelte: boom from errors/deep</h1>
$ curl -s http://localhost:5196/errors2/deep2 | grep -o '<h1[^<]*</h1>'<h1 data-testid="err">ROOT-ERROR +error.svelte: boom from errors2/deep2</h1>Same kind of error (error(500, ...)), thrown at the same depth — but which +error.svelte renders depends entirely on which ancestor folders actually have one.
- Navigation uses ordinary
<a href="/blog">links; SvelteKit intercepts them for fast client-side navigation (no full reload). For programmatic navigation, callgoto('/blog')from$app/navigation.
<script> import { goto } from '$app/navigation';</script>
<a href="/about">About</a> <!-- client-side nav --><button onclick={() => goto('/dashboard')}>Go</button>Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated routing code, check for:
- Does it assume a
+error.svelte“closest in the folder tree visually” catches an error, without checking that folder is actually an ancestor of the route that threw? Proven above: a sibling subtree’s+error.sveltenever catches anything — only ancestors do, and with none present it bubbles to the root. - Does a
[id]route silently shadow a more specific static route (or vice versa) because no param matcher disambiguates them? - Does
(group)appear as a literal segment in a generated<a href>orgoto()call, as if the parentheses were part of the URL? They never are — proven above with/grouped. - Is
[email protected]used without realizing it skips every layout back to the reset point — including one the page actually still needed (a shared error-reporting<svelte:boundary>, analytics beacon, etc. living in a skipped layout)? - Does navigation use
window.location.href = '/path'(a full reload) wheregoto('/path')(client-side, proven above) was intended?
🐛 Spot the bug
// project layout:// errors/// +error.svelte <- exists here// deep/+page.server.js// errors2/// deep2/+page.server.js <- NO +error.svelte anywhere in errors2/
// src/routes/errors2/deep2/+page.server.jsimport { error } from '@sveltejs/kit';export function load() {error(500, 'something failed in errors2/deep2');// "the errors/+error.svelte will catch this too, it's right there in routes/"}Show the answer
errors/+error.svelte only covers its own folder and folders nested under it — errors2/ is a sibling, not a descendant, so that boundary never sees this error at all. Proven above with real curl output: hitting /errors2/deep2 renders the root src/routes/+error.svelte, not errors/+error.svelte, because SvelteKit walks up from the route that threw looking for the nearest ancestor +error.svelte — and errors2/deep2 has no ancestor with one until the root. The fix is either to add errors2/+error.svelte for scoped handling, or to accept (and design for) the root error page catching it.
Mental Model
Section titled “Mental Model”Routing is a tree, and three things about a request are each resolved by walking that same tree, but stopping at different points: the page is the exact leaf that matched; the layouts are every ancestor from the root down to that leaf, each nesting inside the ones above (unless @ resets the walk early); the error boundary is the nearest ancestor (starting from the leaf and walking back up) that happens to have a +error.svelte. Groups ((name)) and matchers ([x=name]) don’t change this tree shape’s effect on the URL or rendering — they only change how a folder is organized (groups) or whether a segment is even eligible to match (matchers).
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
Param matcher ([id=integer]) | Two routes can share a segment position without colliding (proven above) | An extra file (src/params/x.ts) to maintain per constraint |
Route group (name) | Shared layout/organization with zero URL cost (proven above) | Easy to forget the parens don’t appear in links — a generated href="/(app)/grouped" is simply wrong |
[email protected] reset | Full control to skip unwanted ancestor chrome (proven above) | Skips everything back to the reset point, including anything useful living in those layouts |
Scoped +error.svelte per folder | Contain a failure’s blast radius to just that section | Every folder without one silently relies on some ancestor’s — worth knowing which one, proven above it’s not always the “nearest-looking” one |
Common Misconceptions
Section titled “Common Misconceptions”- “Any
+error.sveltein the same route file, close by, will catch an error.” Proven above: only an ancestor folder’s+error.sveltecatches it — a sibling subtree’s never does, no matter how close it looks in the file tree. - “A route group
(name)adds a/namesegment to the URL.” Proven above:/grouped, not/app/grouped— groups are purely organizational. - “A plain
[id]and a static route at the same position can’t coexist.” A param matcher lets them: proven above,/products/latestfalls through to the static route becauseinteger’s matcher rejects it. - “
[email protected]only skips the immediate parent layout.” Proven above: bare@resets all the way to the root, skipping every layout in between, not just one level.