Routing, Layouts & Shared State
Learning Objectives
- You can create page routes, parameters, and shared layouts.
- You can choose a state owner based on scope and lifetime.
- You can test navigation and direct page access without confusing pages with API routes.
An application needs more than reusable cards. Users need addresses they can open, navigation that survives reloads, and state with a deliberate owner. We will study these related questions in stages: first page addresses, then shared structure, then state lifetime.
Routes identify pages, not database tables
SvelteKit uses route directories beneath client/src/routes. A +page.svelte file makes a page at that location. A normal anchor can navigate to it.
In the practice application (practice/), create the neutral guide structure shown in Figure 1.
The arrows represent directory containment.
In guides/+page.svelte:
<main> <h1>Study guides</h1> <ul> <li><a href="/guides/writing">Writing clearly</a></li> <li><a href="/guides/revision">Planning revision</a></li> </ul></main>The browser page route /guides/writing is not automatically a Hono endpoint. A page may make separate HTTP requests for data. The SvelteKit routing guide describes the route-file convention.
Parameters are current input
In guides/[slug]/+page.svelte, use the page’s parameter input:
<script lang="ts"> let { params }: { params: { slug: string } } = $props(); const guides: Record<string, { title: string; summary: string } | undefined> = { writing: { title: "Writing clearly", summary: "Make one claim at a time." }, revision: { title: "Planning revision", summary: "Return to ideas repeatedly." }, }; let guide = $derived(Object.hasOwn(guides, params.slug) ? guides[params.slug] : undefined);</script>
<main> {#if guide} <h1>{guide.title}</h1> <p>{guide.summary}</p> {:else} <h1>Guide not found</h1> <p>The local guide collection has no matching entry.</p> {/if} <a href="/guides">All guides</a></main>Object.hasOwn checks that the slug names an entry defined in guides. Without
that check, a slug such as toString could match an inherited object property
instead of a guide.
This deliberately uses local neutral data to isolate routing. It does not claim that a client-rendered “not found” heading changes the HTTP status of the original document request.
A route component can be reused during navigation while its parameter changes. guide is therefore derived from the current params.slug, not initialized once and forgotten. For network-backed detail components, a keyed child can explicitly give each ID its own lifecycle:
{#key params.slug} <GuideDetail slug={params.slug} />{/key}That fragment assumes you have implemented GuideDetail; it illustrates component lifetime rather than providing a second complete page. Cancelling the old child’s request on removal prevents an irrelevant result from updating the next detail view.
Share structure with a layout
Put this in guides/+layout.svelte:
<script lang="ts"> import type { Snippet } from "svelte"; let { children }: { children: Snippet } = $props();</script>
<header> <nav aria-label="Guide navigation"> <a href="/">Practice home</a> <a href="/guides">Guides</a> </nav></header>{@render children()}The nested pages provide their own main; the layout supplies navigation around them. A snippet is renderable content supplied by the framework. The layout does not need to duplicate every page’s title and markup.
Shared layout does not automatically mean shared application state. It merely gives us a place where longer-lived state could be owned deliberately.
Decide the lifetime before choosing a mechanism
Consider four different values:
| Value | Suitable owner or representation |
|---|---|
| Whether one disclosure is open | That component |
| A filter used by two siblings | Their nearest appropriate common parent |
| A selected item that should survive a copied URL | A route parameter or query string |
| A persisted server record | The API/database, with a client representation |
Lifting state means giving the common parent ownership and passing values/callbacks down. A globally imported mutable object is not the automatic next step whenever two components need the same value.
State in a preserved layout can survive navigation among its children, but a full page reload creates a new application instance. Do not promise reload persistence unless the value is represented in a URL or actual storage.
Apply both decisions to a different catalogue before introducing context. The sequence asks which values belong in a shared owner and which must be represented in a durable address.
A scoped context when prop passing becomes awkward
For a small example, add a display preference owned by the guide layout. Create client/src/lib/examples/guide-context.ts:
export const guidePreferencesKey = Symbol("guide-preferences");export type GuidePreferences = { compact: boolean };In the layout script, add:
import { setContext } from "svelte";import { guidePreferencesKey } from "#lib/examples/guide-context";
const preferences = $state({ compact: false });setContext(guidePreferencesKey, preferences);Add a labelled checkbox to the layout markup using bind:checked={preferences.compact}. A descendant can read the same object during component initialization:
<script lang="ts"> import { getContext } from "svelte"; import { guidePreferencesKey, type GuidePreferences } from "#lib/examples/guide-context"; const preferences = getContext<GuidePreferences>(guidePreferencesKey);</script>
<p>Guide display: {preferences.compact ? "compact" : "comfortable"}</p>The exported symbol is a stable key, not shared mutable user data. The actual state is created per layout instance. Keep the same object rather than replacing it and leaving consumers holding the old object. See Svelte context and SvelteKit state management.
For two nearby components, props and callbacks may still be clearer. Use context to solve a real ownership problem, not to hide every dependency.
Test the complete navigation path
Add a Playwright test in e2e-tests/tests/guides.spec.ts:
import { expect, test } from "@playwright/test";
test("a guide can be opened directly and after navigation", async ({ page }) => { await page.goto("/guides/writing"); await expect(page.getByRole("heading", { name: "Writing clearly" })).toBeVisible(); await page.getByRole("link", { name: "All guides" }).click(); await page.getByRole("link", { name: "Planning revision" }).click(); await expect(page.getByRole("heading", { name: "Planning revision" })).toBeVisible(); await page.reload(); await expect(page.getByRole("heading", { name: "Planning revision" })).toBeVisible();});Run the Part 1 E2E command from the root of the practice application (practice/), including both
compose.yaml and compose.test.yaml, and use the practice application’s separate
Compose test project name. The test directory is mounted into the runner; --build also
keeps its image and dependencies current. This checks direct access, navigation,
and reload. A component test with a hardcoded prop would not exercise those
boundaries.
Course pages and a shared preference
Let’s apply routing and state scope to a small course catalogue. Its supplied context contract lets a catalogue page and its detail page share a compact-view preference. Navigation should preserve that preference; reloading should begin with the documented default. Follow the exercise’s supplied files rather than copying the marketplace project into the exercise starter.
Navigate between course pages
0 / 20 points
Task
Complete the three supplied files under src/routes/courses/. The grader
supplies the fixed courses array in #lib/course-catalogue and the context key
and type in #lib/course-context. Their source is shown below; import these
supplied modules without submitting replacements.
Each course has a unique code, title, and summary.
src/lib/course-catalogue.ts:
export const courses = [ { code: "CS101", title: "Programming Patterns", summary: "Compose small functions.", }, { code: "CS205", title: "Data in Practice", summary: "Ask clear questions of stored data.", },];src/lib/course-context.ts:
export const coursePreferencesKey = Symbol("course-preferences");export type CoursePreferences = { compact: boolean;};Requirements
/courses has heading Courses and an ordinary title link to each
/courses/<code>. Each detail displays the matching title as its main heading.
In comfortable display mode it also shows the course summary; compact mode hides
that summary without changing the selected course. Unknown codes display
Course not found as their level-one heading. A direct visit,
reload, and navigation between two different codes must work.
The nested layout contains a link named All courses and a checkbox labelled
Compact display. It owns per-layout preference state, initially false, shared
through the supplied context key. Each detail contains a paragraph displaying
exactly Course display: compact or Course display: comfortable. The
preference and corresponding summary visibility persist during client navigation
between these pages, but reset after a full reload. Do not use
storage or module-global mutable state. A client-rendered not-found heading
does not claim to set the document response to HTTP 404.
Submit
Submit only src/routes/courses/+layout.svelte,
src/routes/courses/+page.svelte, and
src/routes/courses/[code]/+page.svelte. Keep these exact relative paths.
The grader supplies the exercise environment and uses Playwright; there is no live
Run preview. Direct navigation, changed parameters, unknown codes, and preference
lifetime with conditional summary output each earn 5 points.
Check Your Understanding
- How does a SvelteKit page route differ from a Hono API endpoint?
- Why must a detail page handle changed parameters rather than only its first mount?
- What does a layout share, and what does it not automatically persist?
- When should state be in a URL rather than only a component?
- Why is layout-scoped context different from a mutable module singleton?