Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 085 / 100

Page state in SvelteKit: real-world lessons and examples

Read URL and route info.

Expona Admin6 min read

Introduction

By day 85 of building with SvelteKit, you've likely reached a point where understanding page context becomes crucial to shipping features efficiently. Page state in SvelteKit provides real-world lessons and examples that separate developers who struggle with routing and URL state from those who ship polished, reactive navigation features. We'll walk through the practical patterns that work in production, from reading URL parameters to keeping your UI in sync with route changes.

The $app/state Store and What It Gives You

SvelteKit exports a page store from $app/stores that contains the current page's metadata. This isn't a prop you pass down—it's a reactive store you subscribe to, which means any component in your tree can access it without prop drilling.

The page store holds several critical pieces of information:

  • url – the full URL object with pathname, search params, hash, and more
  • params – route parameters extracted from your [slug] segments
  • route – metadata about the matched route
  • status – the HTTP status code for the page
  • error – error details if something went wrong during load
  • data – resolved data from your page's load function

In a real e-commerce project, we relied on the page store to handle product filtering without prop threading through five component levels. Instead of passing a categoryId down from layout to sidebar to filter control, we just read $page.params.categoryId directly where needed.

typescript
import { page } from '$app/stores';

// In any component
$: categoryId = $page.params.categoryId;

The $ prefix in Svelte subscribes automatically, so changes trigger reactivity. No manual subscription cleanup required.

URL and Route Parameters in Practice

When you define a route like src/routes/products/[id]/+page.svelte, SvelteKit extracts id into $page.params.id. This is straightforward for simple cases, but real-world scenarios often need multiple parameters, nested segments, and query strings.

Consider a travel booking application with routes like /trips/[tripId]/travelers/[travelerId]. Both parameters appear in $page.params:

typescript
import { page } from '$app/stores';

export let data;

$: tripId = $page.params.tripId;
$: travelerId = $page.params.travelerId;

$: {
  if (tripId && travelerId) {
    console.log(`Viewing traveler ${travelerId} on trip ${tripId}`);
  }
}

Query parameters live in $page.url.searchParams. If a user navigates to /products?sort=price&filter=sale, you access them like this:

typescript
$: sortBy = $page.url.searchParams.get('sort') ?? 'relevance';
$: filterSale = $page.url.searchParams.getAll('filter');

We found this pattern especially useful in a SaaS CRM where users filter leads by status, tag, and date range. Instead of managing filter state in component data, we kept it in the URL, making links shareable and browser back-button compatible for free.

The $page.url object also gives you pathname, hostname, origin, and hash. In a ride-hailing dashboard, we used pathname to highlight the active sidebar item without additional state management:

typescript
$: isRidesPage = $page.url.pathname.startsWith('/rides');

HTTP Status and Error Handling

The $page.status property reflects the HTTP response code. In most load flows you return 200, but error pages surface 404 or 500. You can use this in your component to render different UI conditionally:

typescript
import { page } from '$app/stores';

{#if $page.status === 404}
  <div>This page wasn't found. Would you like to search?</div>
{:else if $page.status === 500}
  <div>Something went wrong on our end. Please try again.</div>
{/if}

When the error property is populated, $page.error holds details about what failed. In a real trading platform, we displayed user-facing error messages from our API errors directly:

typescript
$: if ($page.error) {
  console.error('Page error:', $page.error.message);
  showToast($page.error.message, 'error');
}

Making Components Reactive with Page State

The power of $page comes from reactivity. Any time the route or URL changes, subscribed components re-run their reactive blocks. This is where you'll handle side effects tied to navigation.

In a travel booking flow, when users moved between steps, we needed to refetch data for the current segment. Instead of waiting for explicit prop changes, we watched $page.params:

typescript
import { page } from '$app/stores';

let bookingDetails;
let loading = false;

$: if ($page.params.bookingId) {
  loading = true;
  fetch(`/api/bookings/${$page.params.bookingId}`)
    .then(r => r.json())
    .then(data => {
      bookingDetails = data;
      loading = false;
    });
}

The reactive block runs whenever $page.params.bookingId changes, keeping your UI synchronized with the URL automatically.

For more complex scenarios, use $page.url.searchParams to track filter or pagination state. In an e-commerce site, we synced the page number with the URL without extra store setup:

typescript
$: page = Number($page.url.searchParams.get('page')) || 1;
$: if (page) {
  loadProducts(page);
}

A common UI pattern is highlighting the active navigation link. Instead of passing down a separate "active" prop, derive it from $page.url.pathname:

typescript
import { page } from '$app/stores';

let navItems = [
  { label: 'Dashboard', href: '/dashboard' },
  { label: 'Settings', href: '/settings' },
  { label: 'Billing', href: '/billing' }
];

$: isActive = (href) => $page.url.pathname === href;

Then in your template:

typescript
{#each navItems as item}
  <a href="{item.href}">
    {item.label}
  </a>
{/each}

For nested routes or partial matches, use startsWith. In a CRM with sections like /leads/active, /leads/archived, you'd highlight "Leads" if any leads subpage was active:

typescript
$: isLeadsSection = $page.url.pathname.startsWith('/leads');

This approach scales better than manually tracking active state because the URL is the source of truth. When a user hits the back button or bookmarks a link, the active state is correct without extra logic.

Common Mistakes

Forgetting the $ prefix in component scripts. Without it, page.params.id is stale. Always use $page to subscribe reactively.

Over-fetching on every param change. If you have a slow API call tied to $page.params, rapid navigation can queue multiple requests. Add debouncing or abort previous requests before firing new ones.

Trusting search params without validation. Query strings come from user input. Always validate and sanitize—don't assume $page.url.searchParams.get('userId') is a valid number or exists at all.

Using $page.data as a replacement for stores. The data property is for load-function results, not global application state. Keep authentication, theme, or user preferences in proper stores like Svelte stores or context.

Ignoring status codes in production. If your error page doesn't set the correct status code in your load function, search engines see a 200. Always return the proper status via error() or by setting it in your +page.server.js load function.

Key Takeaways

  • The $page store gives you reactive access to url, params, status, error, and data from anywhere in your component tree without prop drilling.
  • Route parameters in $page.params and query strings in $page.url.searchParams keep your UI state in the URL, enabling shareable links and natural back-button behavior.
  • Reactive blocks that depend on $page properties re-run automatically when navigation occurs, synchronizing your component without manual subscription management.
  • Use $page.url.pathname to build active navigation links and sidebar highlighting, eliminating the need for separate active state.
  • Always validate search params and check error conditions; don't assume URL data is safe or present.
  • Keep $page.data for load-function results only—use proper stores for global app state like auth or theme.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles