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.
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.
When should I use shallow routing?
You should use shallow routing in 2 situations:
- when you want some significant change to happen on a page without having to navigate away from it, and
- 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.
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.
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:
Both also share the same argument inputs:
- the URL input, and
- 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:
url
: the URL you want to go to (or leave an empty string (''
) to stay on the same page), andstate
: the state change, which is some state you define in yourPageState
which would look like this:
function pushState(url: string | URL, state: App.PageState): void
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
.
The replaceState
function
The replaceState
function takes 2 arguments, much like the pushState
function. These arguments are:
url
: the URL to replace the history entry with, andstate
: 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
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
withURL[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.
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);
}
}
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:
- Loads the data from another route,
- Pushes the URL to the user’s ‘history entries’, and
- 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! 😄
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.
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.
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. 💪
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! ❤️