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.

Groups add layout levels; resets remove them Two directory trees. A parenthesised group directory wraps pricing and about in a marketing layout while their URLs stay at the root. A page with an at-suffix inside a dashboard resets out of the dashboard layout back to the root layout, keeping its URL unchanged. Group — adds a layout, not a segment routes/ (marketing)/ +layout.svelte pricing/+page.svelte about/+page.svelte URLs: /pricing and /about no "/marketing" anywhere in the address bar Reset — removes layouts, not segments routes/ dashboard/ +layout.svelte (sidebar) reports/+page.svelte editor/+page@.svelte URL stays /dashboard/editor but the sidebar layout is skipped entirely Neither convention ever changes a URL — which is what makes both safe to add to an existing site.
Groups and resets act in opposite directions on the layout chain, and in neither case does the address bar change.

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>
Which layouts each reset strength keeps Three rows showing the same layout chain of root, app group and section. A plain page keeps all three. An at-app-group reset keeps root and the app group. A bare at reset keeps only the root layout. The URL is identical in all three cases. Same URL, three layout chains filename root (app) section +page.svelte kept kept kept +page@(app).svelte kept kept skipped +page@.svelte kept skipped skipped
The suffix names the ancestor to reset to; a bare @ 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.svelte and (app)/about/+page.svelte both 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 load too. Data the skipped layout provided is gone, so a reset page must fetch what it needs itself.
  • +page@.svelte still 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).svelte must name a group that actually exists as an ancestor, or the build fails.
  • +error.svelte follows 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.
What each SvelteKit filename convention affects Five filename patterns classified by whether they change the URL and whether they change the layout chain. Only ordinary directories change the URL; parentheses and at-suffixes change only the layout chain. Reading a SvelteKit filename affects the URL? affects the layout chain? routes/about/+page.svelte yes — adds /about inherits everything above routes/(app)/+layout.svelte no adds one level routes/x/+page@.svelte no resets to the root layout routes/x/+page@(app).svelte no resets to the (app) layout routes/[slug]/+page.svelte yes — a dynamic segment inherits everything above
Nothing in the middle three rows touches the address bar — which is what makes both conventions safe to introduce late.

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.