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.
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.
// 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.
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.
// +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.
// +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.stateand 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
pushStatecreates a new history entry. If you're filtering a list by typing in a search box, callpushStateon 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.statematches your expectations before relying on it. - Ignoring browser compatibility.
pushStateandreplaceStateare well-supported in modern browsers, but if you're supporting older environments, test thoroughly or use a fallback approach.
Key takeaways
- Use
pushStateto add a new history entry when opening a modal or switching a view; usereplaceStateto 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
pushStatein tight loops; batch state updates and trigger history changes on user-initiated events like clicks or form submissions.






Comments
Loading comments…