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

Shallow routing: a Svelte & SvelteKit guide for developers

Update URL state without navigation.

Expona Admin6 min read

Shallow routing is a technique that lets you update the URL and browser history without triggering a full page navigation or component remount. In SvelteKit, this means you can open a modal, adjust filters, or bookmark application state while keeping the user's context intact. We'll walk through the mechanics of shallow routing and show you when—and when not—to use it.

Understanding pushState and replaceState from $app/navigation

SvelteKit exposes two key functions from the $app/navigation module: pushState and replaceState. These methods let you modify the browser's history stack and URL without forcing a server round-trip or re-running your page load functions.

pushState adds a new entry to the history stack, allowing the back button to return to the previous state. This is useful when you want users to be able to undo an action like opening a modal or switching a view.

replaceState overwrites the current history entry instead of creating a new one. Use this when you want to preserve the back button's original destination but update the current URL and state—for example, when applying a filter that refines the current view without creating a new history branch.

Both functions accept an object that becomes available via page.state, allowing you to store arbitrary data tied to that history entry.

typescript
import { pushState, replaceState } from '$app/navigation';
import { page } from '$app/stores';

// Push a new history entry
pushState('/?modal=user-settings', { modalOpen: true, userId: 42 });

// Replace the current entry
replaceState('/?filter=active', { filterValue: 'active' });

Opening modals with back-button support

One of the most practical use cases for shallow routing is modal management. Instead of managing a boolean like showModal in a component store, you can tie the modal's open state to the URL history. This gives you back-button support almost for free.

Here's a real-world scenario: you're building a user management dashboard. Clicking an "Edit" button opens a modal, and clicking the browser back button should close it. With shallow routing, the back button does exactly that without reloading the page or losing your scroll position.

typescript
// In your page component
import { pushState } from '$app/navigation';
import { page } from '$app/stores';

let userEditModal;

function openUserEditor(userId) {
  pushState(`?edit=${userId}`, { editingUserId: userId });
}

function closeModal() {
  history.back();
}

$: if ($page.state?.editingUserId) {
  userEditModal?.show($page.state.editingUserId);
}

When the user clicks the back button, the history entry changes, the page.state reactive value updates, and your modal close logic runs. No component remount, no navigation, no network request.

Storing data in page.state

The page.state object is your container for shallow routing metadata. Unlike URL query parameters, which must be strings, page.state can hold any JavaScript value: objects, arrays, booleans, numbers.

This is especially valuable when you need to pass complex data through a shallow route transition. For example, in an e-commerce checkout flow, you might store the selected shipping address object in page.state without encoding it into the URL.

typescript
import { pushState } from '$app/navigation';

const address = {
  id: 'addr_123',
  line1: '123 Main St',
  city: 'Portland',
  state: 'OR',
  zip: '97201'
};

// Store the full object in page.state
pushState('/checkout?step=shipping', { 
  selectedAddress: address,
  shippingMethod: 'express'
});

// Access it in your component
import { page } from '$app/stores';

$: if ($page.state?.selectedAddress) {
  console.log(`Shipping to: ${$page.state.selectedAddress.line1}`);
}

Keep in mind that page.state is not persisted to the server. If the user reloads the page, the state is lost. For data that must survive a reload, store it in the URL query string or in a persistent store like a database or localStorage.

Fallback on full reloads

Because shallow routing doesn't persist state to the server, you need a fallback strategy. If a user bookmarks a URL after a shallow route change, or if they reload the page, your page.state will be empty.

The best practice is to encode enough information in the URL query string to reconstruct the state on a full page load. Then, in your +page.server.js or +page.ts, parse those query parameters and rebuild the UI state.

typescript
// +page.server.ts
import { error } from '@sveltekit/core';

export async function load({ url }) {
  const editUserId = url.searchParams.get('edit');
  const filterValue = url.searchParams.get('filter');

  let user = null;
  if (editUserId) {
    user = await db.users.findById(editUserId).catch(() => null);
    if (!user) {
      error(404, 'User not found');
    }
  }

  return {
    user,
    filterValue
  };
}

In your page component, you can then use data from the load function to initialize state on first render, and use shallow routing for client-side transitions within the same page.

typescript
// +page.svelte
import { page } from '$app/stores';
import { pushState } from '$app/navigation';

export let data;

let editingUser = data.user;
let filterValue = data.filterValue;

function applyFilter(newFilter) {
  filterValue = newFilter;
  // Use replaceState to avoid stacking history entries
  replaceState(`?filter=${newFilter}`, { filterValue: newFilter });
}

function editUser(userId) {
  pushState(`?edit=${userId}`, { editingUserId: userId });
}

Common mistakes

  • Forgetting to encode essential state in the URL. If you store data only in page.state and a user reloads the page, that data vanishes. Always put identifying information (IDs, filter keys) in the query string so you can reconstruct the view on reload.
  • Using shallow routing for cross-page navigation. Shallow routing is for updating state within a single page. If you need to navigate to a different page, use normal SvelteKit navigation. Misusing it can confuse users and break your back-button semantics.
  • Over-calling pushState in loops or tight event handlers. Each pushState creates a new history entry. If you're filtering a list by typing in a search box, call pushState on debounce or blur, not on every keystroke. Otherwise, the user's back button will step through every intermediate state.
  • Not handling stale state after navigation. If a user navigates to a different page and then uses the back button to return, your component might have stale data. Always check that page.state matches your expectations before relying on it.
  • Ignoring browser compatibility. pushState and replaceState are well-supported in modern browsers, but if you're supporting older environments, test thoroughly or use a fallback approach.

Key takeaways

  • Use pushState to add a new history entry when opening a modal or switching a view; use replaceState to update the current entry when applying a filter.
  • Store complex data in page.state, but always encode identifying information in the URL query string for reload resilience.
  • Shallow routing keeps your page component mounted and preserves scroll position—useful for modal workflows and light state transitions.
  • Implement a server-side fallback in your load function to reconstruct state from URL parameters when the page is reloaded or bookmarked.
  • Avoid calling pushState in tight loops; batch state updates and trigger history changes on user-initiated events like clicks or form submissions.
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