Mastering Shallow Routing in SvelteKit

| Published: February 03, 2024

| Updated: February 03, 2024

Shallow routing is a SvelteKit v2 feature which allows you to separate your history entries from your page navigation. It does by exposing push and replace methods for history entries in your user’s browser. Additionally, you can fetch data from other routes and render it inside the current page without navigating away.

Now SvelteKit apps can use shallow routing.

The use cases for this may seem unclear at first, so in this article we’ll go through shallow routing implementations and examples of how it can improve your app’s UX. We’ll also talk about why it’s especially useful for mobile users, and when it should be avoided.

Skip to FAQ’s

When should I use shallow routing?

You should use shallow routing in 2 situations:

  1. when you want some significant change to happen on a page without having to navigate away from it, and
  2. when you want to display the data from a route inside your page

Some examples of web UI’s that commonly pair with shallow routing are:

  • Modals for opening and closing form confirmations,
  • Preview modals for displaying a page inside a modal
  • Stepper forms when the user goes through multiple steps to fill out a form and you want them to easily go back from the browser without losing state data, and
  • Side drawers, where to close a drawer, the user can swipe back and not have to navigate away from the page.

All of these features require the user to stay on the same page, but they are notable events that are often worth logging in your history entries.

via GIPHY

How does this affect the user experience?

If you’ve used shallow routing to log some history entry (e.g. showing a preview), then when the user presses/swipes back on their browser, they’ll stay on the same page, but revert to the state before the modal opened.

If you didn’t want to navigate the user away from a page for some reason, but rather render some other pages inside a component (e.g. a modal), then you could do that also, using the preloadData function.

Then if they were to copy the URL from some step in the flow, and then re-paste it into the address bar and press ‘Enter’, the current shallow routed route page would be rendered as a full page, rather than a preview inside the modal from the last page.

All of this effectively means you can have more control over how your pages render, whilst also having control over the browser history entries.

via GIPHY

Before shallow routing

Without shallow routing, if you wanted to render a page, you would have to navigate to that specific page, and then render it as a whole page. And any changes made on the page that were kept in state would be lost if the user pressed ‘back’.

Now with shallow routing, you can open a modal on a page and continue browsing inside the modal. And if the user presses back, you would just go back to the last state inside the modal, and the page with its current state would remain.

How to implement shallow routing

Now we know the purpose of shallow routing, let’s look at how to implement it.

There are 2 functions available to execute shallow routing functionality:

  1. The pushState function, and
  2. The replaceState function

via GIPHY

Both also share the same argument inputs:

  1. the URL input, and
  2. the pageState change

Whilst they arguments are the same for both functions, the outcome differs depending on the function you use. And if you want to get advanced and start loading and rendering other pages pages without navigating, you can use the preloadData function to pass into the pushState/replaceState functions.

As the PageState change will be used as arguments for both arguments, we’ll discuss it quite a bit in the next sections. If you don’t know what that is, click here to quickly skip to the PageState section.

So let’s look at the basics first by discussing the primitive functions and their parameters.

The pushState function

pushState takes 2 arguments:

  1. url: the URL you want to go to (or leave an empty string ('') to stay on the same page), and
  2. state: the state change, which is some state you define in your PageState

which would look like this:

function pushState(url: string | URL, state: App.PageState): void

via GIPHY

pushState First Argument - URL

The 1st argument is simple - you declare the relative URL to the page you are on. For example, if you are on https://sveltekit.io/blog and you want to shallow route https://sveltekit.io/blog/shallow-routing, then you would use:

pushState('shallow-routing', stateChange)

But if you wanted to go to the route https://sveltekit.io/about, then you would use this:

pushState('/about', stateChange)

Or if you want to stay on the same page, you can leave the URL as an empty string, like this:

pushState('', stateChange)

This is useful for allowing local page state changes to be logged in the user’s history entries, so that if they press ‘back’, if just reverts to the last state.

pushState Second Argument - PageState

The state change you will need to put in your second argument is a little more involved. As SvelteKit requires to keep track of events in the SvelteKit PageState.

You’ll need to first set up your PageState in your app.d.ts with the instructions outlined here.

And then you can make changes to it inside of the pushState function, so that it gets logged in the history entries. So going back in history one step just reverts the declared page state change to how it was before the pushState.

So if $page.state.showModal == false, and then you went and called this:

    pushState('', {
        showModal: true
    });

The last history entry was that $page.state.showModal == false, so regardless of what happens in between, when the person clicks back, it will stay on the page and revert to the state where $page.state.showModal == false.

via GIPHY

The replaceState function

The replaceState function takes 2 arguments, much like the pushState function. These arguments are:

  1. url: the URL to replace the history entry with, and
  2. state: the new app state to log the replaces history entry with

So a replaceState will look like this:

function replaceState(url: string | URL, state: App.PageState): void

via GIPHY

First first argument - URL

The 1st argument takes the url for which the current history entry gets replaced with. This means that if you were to pass a replaceState, when the user presses back, they would navigate to the previous URL.

Another way to think of this is:

  • if you’re on URL[0], and
  • you replaceState with URL[1], and
  • then you press back => you’ll be taken to URL[-1].

The specification of the URL argument is equivalent to the pushState URL argument.

replaceState Second argument - PageState

The 2nd argument argument logs the state change and replaces the current history entry with it. So if you were to go ‘back’ in your history, the original state would not be there.

The type and specification of the argument takes on the same properties as the PageState prop in the pushState function.

How to access $page.state

Before accessing properties from $page.state, you must create a PageState interface in your app.d.ts file. This interface will be accessed site-wide and will be used for Shallow Routing. The interface will look like this:

src/app.d.ts

declare namespace App {
    // ... other interfaces
   
    interface PageState {
        modal: boolean;
        signedInUserId: string | undefined;
        // add as many as you'd like...
    }
}

So, if you wanted to access the modal PageState variable, you would use $page.state.property, like this:

<script>
	import { page } from '$app/stores';
</script>

{#if $page.state.signedInUserId}
	<p>User ID: {$page.state.signedInUserId}</p>
{/if}

{#if $page.state.modal}
	<Modal />
{/if}

This is everything we need to know to do some pretty advanced stuff with shallow routing. So let’s look at an example where we use all of these pieces to make a Svelte component which renders other pages inside a modal, whilst shallow routing throughout the steps.

via GIPHY

Pop-up Views

So far, we’ve talked about the basics of shallow routing. It’s useful, but the rubber really meets the road when you begin to load other routes inside your page without navigating.

To do this, we also need to rely on the preloadData function.

The preloadData function

If you want to render other pages inside your current page (e.g. inside a modal), you’ll first need to fetch the data from that page.

You can do this by first importing preloadData:

<script lang="ts">
	import { preloadData } from '$app/navigation';
</script>

Then, create the function to fetch the data from the page /page-to-get-data-from

async function previewPageInModal() {
	const preloadedPage = await preloadData('/page-to-get-data-from'); // the argument is the relative route

	console.log(preloadedPage);
}

The awaited return type of preloadData will be:

{
    type: "loaded";
    status: number;
    data: Record<string, any>;
} | {
    type: "redirect";
    location: string;
}

So before you start using the actual returned data, you’re going to need to first check that type == 'loaded'. And whilst you’re there, it’s also worth checking if the status == 200 (which is an OK response code).

So the function would look like this:

async function previewPageInModal() {
	const preloadedPage = await preloadData('/page-to-get-data-from');

	if (preloadedPage.type === 'loaded' && preloadedPage.status === 200) {
		// Do something with the data
		console.log(preloadedPage.data);
	}
}

via GIPHY

Combining preloadData with shallow routing

We’ve got the data from the /page-to-get-data-from page, and we’ve checked that it was a valid response, so now lets use the preloadedPage.data to make shallow route into a modal on our page.

So first, we need to go to our app.d.ts file and add this interface:

declare namespace App {
    interface PageState {
        modalContent: { id: string; name: string; image: string };
    }
}

Which prepares our type system so that in our App-wide PageState we’ll track the modalContent which has the id, name and image properties.

Then in the load function for the /page-to-get-data-from page, we have to return {id, name, image}. For example, we should have something like this:

src/routes/page-to-get-data-from/+page.server.ts

import { db } from '$lib/server';

export async function load(event) {
	const { id, name, image } = await db.getUser('admin');

	return { id, name, image };
}

As long as {id, name, image} is (at least) being returned from the load function, then we can successfully set our $page.state.modalContent.

So now it’s just a case of plugging it into the pushState function, like this:

async function previewPageInModal() {
	const url = '/page-to-get-data-from';
	const preloadedPage = await preloadData(url);

	if (preloadedPage.type === 'loaded' && preloadedPage.status === 200) {
		pushState(url, {
			modalContent: {
				id: preloadedPage.data.id,
				name: preloadedPage.data.name,
				image: preloadedPage.data.image
			}
		});
	} else {
		goto('/page-to-get-data-from');
	}
}

So now we have a function that we can call which:

  1. Loads the data from another route,
  2. Pushes the URL to the user’s ‘history entries’, and
  3. Pushes the changed PageState to reflect the content from the route which was pushed

So now all we need to do is reflect it in our markup, and we have fully functioning shallow routing in our Svelte component.

So, we’ll use this to show a modal with the other page rendered inside it:

<script lang="ts">
	import { goto, preloadData, pushState } from '$app/navigation';
	import { page } from '$app/stores';
	import { Modal } from '$lib/components/Modal.svelte';
	import { ProfilePage } from '$lib/components/ProfilePage.svelte';

	async function previewPageInModal() {
		const preloadedPage = await preloadData('/page-to-get-data-from');

		if (preloadedPage.type === 'loaded' && preloadedPage.status === 200) {
			pushState('/page-to-get-data-from', {
				modalContent: {
					id: preloadedPage.data.id,
					name: preloadedPage.data.name,
					image: preloadedPage.data.image
				}
			});
		} else {
			goto('/page-to-get-data-from');
		}
	}
</script>

{#if $page.state.modalContent}
	<Modal on:close={() => history.back()}>
		<ProfilePage data={$page.state.modalContent} />
	</Modal>
{/if}

Here, the modal will show the <ProfilePage /> component with the data loaded from the /page-to-get-data-from route. And you even have a fallback with goto if something goes wrong with the shallow routing - so that it’ll take you to the page if something goes wrong.

Pretty cool! 😄

via GIPHY

Why shallow routing is important for mobile users

Now that most of the web is explored on mobile devices, it’s important to make mobile-first experiences. Given that ‘swiping’ is a native part of how users interface with applications on their mobiles, building out ’correct’ swiping directives in your app can be important. By ‘correct’, it should be intuitive that if a modal pops up on your phone, you can back-swipe to close it.

Typically, this would take them to the last page and clean up all current state, however with a simple implementation of shadow routing, this would just close the modal.

So if you expect your users to be predominately on mobile, you should lean into shadow routing for navigation.

via GIPHY

Shallow routing requires Javascript

If you intend on implementing shallow routing into your app, you have to be wary that it won’t work for users who haven’t loaded, or have disabled Javascript.

This is particularly important to know if you’re creating a mission-critical app, or one which should be accessible to everyone, e.g. government websites, banking apps etc.

Given that ~1.3% of internet users don’t have Javascript, it ‘s usually worth creating a workaround to achieve the same results for all users.

via GIPHY

For example, if you want to trigger a pushState on a button press, you might want to have a small link below the button if the effect hasn’t actioned as expected:

<script>
	import { pushState } from '$app/navigation';
	import { page } from '$app/stores';

	function openModal() {
		pushState('', {
			showModal: true
		});
	}
</script>

{#if $page.state.showModal}
	<Modal />
{/if}

<button on:click={openModal}>Open Modal</button>

<p>Modal not showing? <a href="">Click here to visit page</a></p>

If you’re just building an MVP or demo to showcase, this might not be necessary, however the Svelte way is to build non-JS first and then enhance with JS. 💪

via GIPHY

Conclusion

Shallow routing was the main feature introduced with SvelteKit 2, and when used correctly can really improve the experience of mobile usage in particular.

There’s countless ways you can use shallow routing to your advantage, so it’s worth getting understanding the fundamentals and then finding potential improvements in your existing apps and enhancing them with shadow routing.

Now get out there and start building with it! ❤️

FAQ’s

Thanks for reading ❤️

Here are some other articles I think you might like!