SvelteKit Route Groups and Layout Resets
After reading this you will be able to give a set of routes a shared layout without changing their URLs, and to break a single page out of an inherited layout chain — the two directory conventions that make SvelteKit’s file-system routing expressive enough for real applications.
← Back to SvelteKit Routing Conventions
Prerequisites
Core Concept
SvelteKit derives both the URL and the layout chain from the directory structure. That is convenient until the two need to differ — and they need to differ constantly.
Route groups solve the first mismatch: routes that share chrome but not a URL prefix. A directory whose name is wrapped in parentheses, (marketing), contributes a level to the layout chain and nothing to the URL. So src/routes/(marketing)/pricing/+page.svelte serves /pricing, while inheriting src/routes/(marketing)/+layout.svelte. Sibling groups can give completely different chrome to different areas without inventing /marketing and /app prefixes nobody wanted in their URLs.
Layout resets solve the opposite mismatch: a page that must escape chrome it would otherwise inherit. Appending @ plus an ancestor’s name to a file breaks the chain at that ancestor. +page@.svelte resets all the way to the root layout; +page@(app).svelte resets to the (app) group’s layout, skipping everything between. A checkout step that needs the site’s fonts and nothing else, or a full-screen editor inside a dashboard, is exactly this.
The pair is easy to confuse because both are “layout things expressed in filenames”, but they act in opposite directions: a group adds a layout level without a URL segment, and a reset removes layout levels without changing the URL either. Neither ever affects the address bar, which is the property that makes both safe to introduce into an existing site.
Implementation
1. Split areas with sibling groups
src/routes/
├── +layout.svelte ← root: fonts, theme, only
├── (marketing)/
│ ├── +layout.svelte ← marketing header, big footer, CTA
│ ├── +page.svelte → /
│ ├── pricing/+page.svelte → /pricing
│ └── about/+page.svelte → /about
├── (app)/
│ ├── +layout.svelte ← app shell: sidebar, user menu
│ ├── +layout.server.ts ← auth guard for the whole group
│ ├── dashboard/+page.svelte → /dashboard
│ └── settings/+page.svelte → /settings
└── (auth)/
├── +layout.svelte ← centred card, no navigation
├── login/+page.svelte → /login
└── register/+page.svelte → /register
The group’s +layout.server.ts is where the pattern earns its keep — one guard covers every route in the group, and adding a page inherits it automatically:
// src/routes/(app)/+layout.server.ts — SvelteKit 2
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!locals.user) {
// Every route in the (app) group is protected by this one check.
redirect(303, `/login?next=${encodeURIComponent(url.pathname + url.search)}`);
}
return { user: locals.user };
};
2. Reset out of an inherited layout
src/routes/(app)/
├── +layout.svelte ← sidebar + header
├── dashboard/+page.svelte → inherits the sidebar
└── editor/
└── +page@.svelte → /editor with NO (app) layout, root only
Resets come in three strengths, and choosing the right one is most of the skill:
+page.svelte → inherits the full chain
+page@(app).svelte → resets TO the (app) group's layout, skipping anything below it
+page@.svelte → resets to the ROOT layout, skipping everything
A layout can reset too, which is how you give a whole subtree different chrome:
src/routes/(app)/print/+layout@.svelte ← every page under /print escapes the app shell
3. Give the root layout as little as possible
Because a reset lands on the root, the root should contain only what genuinely belongs on every page:
<!-- src/routes/+layout.svelte — SvelteKit 2 / Svelte 5 -->
<script lang="ts">
import "../app.css";
let { children } = $props();
</script>
<svelte:head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</svelte:head>
<!-- No header, no nav: those belong to the groups, so a reset stays useful. -->
{@render children()}
Compare that with a group layout, which carries the actual chrome:
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
import Sidebar from "$lib/Sidebar.svelte";
let { data, children } = $props(); // data.user comes from +layout.server.ts
</script>
<div class="app-shell">
<Sidebar user={data.user} />
<main>{@render children()}</main>
</div>
@ means the root, which is why the root layout should stay minimal.When a group is the wrong answer
Groups are cheap enough that they get over-used, and two anti-patterns show up repeatedly. The first is a group with a single route inside it — the layout could simply live in that route’s own directory, and the extra level makes the tree harder to scan for no benefit. The second is a group created to express a concept rather than a shared layout: (admin) containing routes that share nothing visually and no guard, purely because someone wanted the files grouped. That belongs in $lib or a comment, not in the routing tree, because every group is a real layout boundary the router has to reconcile on navigation.
The signal that a group is earning its place is that its +layout.svelte or +layout.server.ts contains something. If both are absent or trivial, the grouping is organisational rather than structural, and a plain directory naming convention communicates the same thing without adding a level.
Verification
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("a group adds chrome without adding a URL segment", async ({ page }) => {
await page.goto("/pricing");
await expect(page).toHaveURL(/\/pricing$/); // no /marketing/ prefix
await expect(page.locator(".marketing-header")).toBeVisible();
await expect(page.locator(".app-sidebar")).toHaveCount(0);
});
test("a reset escapes the inherited layout but keeps the URL", async ({ page }) => {
await page.goto("/editor");
await expect(page).toHaveURL(/\/editor$/);
await expect(page.locator(".app-sidebar")).toHaveCount(0); // (app) layout skipped
await expect(page.locator("body")).toHaveCSS("font-family", /Inter/); // root kept
});
test("the group's server load guards every route inside it", async ({ page }) => {
await page.goto("/settings"); // anonymous
await expect(page).toHaveURL(/\/login\?next=%2Fsettings/);
});
The fastest structural check needs no browser: npx svelte-kit sync regenerates $types and fails on a route that collides with another. Two groups both defining +page.svelte at the same URL is the collision it catches most often.
Gotchas
- Two groups cannot define the same route.
(marketing)/about/+page.svelteand(app)/about/+page.svelteboth claim/about, and the build fails — correctly, but the message points at the file rather than at the collision. - A reset skips the layout’s
loadtoo. Data the skipped layout provided is gone, so a reset page must fetch what it needs itself. +page@.sveltestill inherits the root layout. If chrome you wanted to escape lives in the root, no reset will remove it — move it into a group instead.- Group names are not URL-visible but are still identifiers.
+page@(app).sveltemust name a group that actually exists as an ancestor, or the build fails. +error.sveltefollows the same chain. A reset page’s errors render in the reset layout, which is usually what you want and occasionally a surprise.- Groups do not create a navigation boundary. Moving between
(marketing)and(app)is an ordinary client-side navigation, and both layouts mount and unmount accordingly.
FAQ
Do route groups affect the URL at all? No. That is their entire purpose: a layout level with no URL segment. If you want a segment, use an ordinary directory.
When should I use a reset rather than a new group? A reset is for one page or one small subtree that is an exception. If several routes need the same different chrome, a group expresses it better and keeps the exception from spreading.
Can I nest groups? Yes — (app)/(settings)/… is valid, and the inner group adds another layout level. Two levels is usually plenty; beyond that the tree becomes hard to read.
How does this compare with Next.js? Nearly identically for groups: Next uses the same parenthesis convention. Next has no direct equivalent of the @ reset, so escaping a layout there means restructuring the tree.
Where should an auth guard live? In the group’s +layout.server.ts. It runs for every route in the group, so a new page is protected by default rather than by remembering.
Related
- SvelteKit Routing Conventions — the file-system rules these two conventions extend.
- SvelteKit Load Functions and Data Routing — the loaders a reset skips along with the layout.
- Index Routes vs Layout Routes — the same distinction expressed in configuration-based routers.