Skip to content

Routing & Pages

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 page
flowchart LR
  f1["routes/+page.svelte"] --> u1["/"]
  f2["routes/about/+page.svelte"] --> u2["/about"]
  f3["routes/blog/[slug]/+page.svelte"] --> u3["/blog/:slug"]
Folders map to URLs

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:

src/params/integer.ts
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => /^\d+$/.test(param);
// src/routes/products/[id=integer]/+page.server.js
export 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).

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:

src/routes/+layout.svelte
<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.

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.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:

src/routes/errors/deep/+page.server.js
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, call goto('/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>

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.svelte never 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> or goto() 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) where goto('/path') (client-side, proven above) was intended?

🐛 Spot the bug

src/routes/
// 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.js
import { 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.

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).

What you getBenefitCost
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] resetFull 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 folderContain a failure’s blast radius to just that sectionEvery folder without one silently relies on some ancestor’s — worth knowing which one, proven above it’s not always the “nearest-looking” one
  • “Any +error.svelte in the same route file, close by, will catch an error.” Proven above: only an ancestor folder’s +error.svelte catches it — a sibling subtree’s never does, no matter how close it looks in the file tree.
  • “A route group (name) adds a /name segment 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/latest falls through to the static route because integer’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.
How is a page route defined in SvelteKit?
What does the folder src/routes/blog/[slug]/ create?
A request comes in for /products/latest. What renders?
src/routes/products/[id=integer]/+page.server.js
src/routes/products/latest/+page.svelte
Does the folder (app) in src/routes/(app)/grouped/ appear in the URL?
What does naming a file [email protected] do?
A load throws inside src/routes/errors2/deep2/, and there is no +error.svelte anywhere under errors2/. What renders?