Client-Side Web Applications

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.

Course diagram
Figure 1. The guide route contains a shared layout, an index page, and a parameterized detail page.

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:

ValueSuitable owner or representation
Whether one disclosure is openThat component
A filter used by two siblingsTheir nearest appropriate common parent
A selected item that should survive a copied URLA route parameter or query string
A persisted server recordThe 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

  1. How does a SvelteKit page route differ from a Hono API endpoint?
  2. Why must a detail page handle changed parameters rather than only its first mount?
  3. What does a layout share, and what does it not automatically persist?
  4. When should state be in a URL rather than only a component?
  5. Why is layout-scoped context different from a mutable module singleton?