In this post, I’ll describe how to build rich, dynamic path-based navigation using SvelteKit. It builds on SvelteKit’s routing capabilities, as well as leveraging the Svelte stores it provides to represent path and navigation state. It isn’t a trivial example, I want to show how a real-world application might work with all the code (some HTML redacted). Authentication and fetching data is left out, as topics for other posts.

Working code for this example is available at svelte-kit-navigation, which can be cloned and run locally.

Updated on 2022-09-15 for compatibility with the latest version of sveltekit

Setup

We are running svelte ^3.40, @sveltejs/kit ^1.0.0-next.483, and a few extra libraries - @sveltejs/adapter-static, tailwindcss, postcss and others. You can see the full package list at this link.

Summary

The main moving parts for this approach leverage features of SvelteKit — the provided load function, goto function, and $page and $navigating stores. It also uses SvelteKit’s support for dynamic paths, to encapsulate the state necessary to display the page’s intended contents. These features provide reactivity to changes in navigation and the component variables of the path, including query variables. SvelteKit also intercepts all clicks on links, allowing us to use standard HTML for navigation.

A big advantage of this approach is that it supports deep linking into your application, with rendering of each page consistent, even temporary states with modals or notifications. It also simplifies complex navigation in a SPA, without any special handling for the back button or copied links, since the page URLs are driving the details of data loading and rendering.

Detailed Overview

This example has an index page at the root path, and a page of “transactions”. Paths take a pattern of /resource/resource_id?queryParam=queryValue, and can be extended to include subpages. So a page displaying a list of transactions would match /transactions while displaying the details of a single transaction could match /transactions/000-111-000 where “000-111-000” is the transaction id. SvelteKit calls these “dynamic paths” and will extract the dynamic parts of the path as variables.

The site uses a standard SvelteKit src/routes/+layout.svelte for each page, which serves as the parent component of subsequent pages. This is a good place to initialize “global” stores with state that child components might need. There are a few states that we manage at the top level, a “loading” state while the app goes through an initial setup (such as initial user state), and authentication state to conditionally render a login prompt.

Dynamic routes

From SvelteKit’s documentation:

At the heart of SvelteKit is a filesystem-based router. This means that the structure of your application is defined by the structure of your codebase — specifically, the contents of src/routes

This includes “dynamic” pages that get encoded using [brackets] as the parent directory of the +page.svelte file name. For example, the file src/routes/transactions/[...id]/+page.svelte will match paths myapp.com/transactions as well as myapp.com/transactions/00-11-00, with the latter containing an id parameter that gets parsed and passed as a prop.

Load function

This function, provided by SvelteKit, runs before each page “load”, and parses the id from the path if available, passed into the component as a prop. It’s important to note that the load function must be declared in a module script, and the variable for the prop must be exported.

In our testing, child components cannot declare additional load functions, but we’ll detail an approach that works for those below.

The load function will run each time navigation occurs, including links and the back button. You can see a full example at /transactions/[…id]

<script>
    import { goto } from '$app/navigation';
    import { writable } from 'svelte/store';
    import ...

    // This variable is set from the load function in +page.js
	export let data

    // We use stores to reference the list of transactions as well as the transaction details
    // for the currently selected transaction.
    const transactions = writable(data.transactions);
    const selectedTxn = writable(undefined);

    // Call method reactively when transaction id changes
    $: setupPage(data.transaction_id, $transactions);

    //... continued below
</script>

Setup page function

In our component’s <script> section, we define a function called setupPage(). This function is responsible for setting component variables consistent with the current path. It will be reactive to changes in the path variables, invoked through reactive blocks and store subscriptions. This function should be consistent when setting state as it can be called multiple times in certain scenarios due to multiple subscriptions. As a result it’s best for this function to also be synchronous and not fetch external data (which is better done on mounting).

<script>
    // ... continuing from above
    // Main function for setting the correct state on the page.
    // This idempotent function sets the selected transaction data
    // based on the transaction id from dynamic path.
    // It identifies the selected transaction from the list of all transactions loaded
    // when the component mounts.
    function setupPage(txn_id, txns) {
        // If no transaction id is set in the path, default to the first transaction
        // This handles the path "/transactions"
        if (txn_id === '' && txns.length > 0) {
            goto(`/transactions/${txns[0].id}`)
            return
        }
        if ($selectedTxn?.id != txn_id) {
            const txn = txns.find((f) => f.id == txn_id)
            if (!txn) return
            $selectedTxn = txn
        }
    }

    // Also run the setupPage function when the list of transactions changes
    transactions.subscribe((ts) => setupPage(transaction_id, ts))
</script>

URL query parameters

We use URL query parameters to display intermediary states, such as forms or modals, that toggle on or off. In the example app, there are links to open a “create transaction” form, and a button to dismiss the form.

To show the form, we use a shorthand link to add the parameter to the current path.

<a href="?new=t">
    <!-- link contents -->
</a>

Dismissing the form takes a bit more code, as we want to only remove the parameter new without modifying the rest of the path. We can use the SvelteKit goto method to navigate without resetting the position or focus of the current page.

<button
    on:click={() => {
        // Hide form by unsetting query param new
        $page.url.searchParams.delete('new');
        goto(`${$page.url.pathname}?${$page.url.searchParams.toString()}`, {
            noscroll: true,
            keepfocus: true
        })
    }}
>
    Cancel
</button>

Child components and $navigating store

Since the load function is scoped to the entire component, in the case when child components need to be reactive to navigation we use subscriptions on the $page and $navigating stores. These are also used to invoke the setupPage() method.

In the example below, we have a child component displaying the details of a transaction. It also displays a form for creating a new transaction, based on a query parameter value in the URL path. The $navigating store has a few states that transition during navigation, please refer to the SvelteKit docs for full details. Here we react to the state where a to object represents the next page being loaded.

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

	let showForm = false;
	const unsubs = [];

	// Show form based on url parameters
	// Svelte-kit page store contains an instance of URLSearchParams
	// https://kit.svelte.dev/docs#loading-input-page
	function setupPage(url) {
		if (url.searchParams.get('new') == 't') {
			showForm = true;
		} else {
			showForm = false;
		}
	}

	// Subscribe to page and navigating stores to setup page when navigation changes
	// Note that, in our testing, the Svelte-kit load function does not fire on child modules
	// This is an alternative way to detect navigation changes without a component load function
	unsubs[unsubs.length] = page.subscribe(setupPage);
	unsubs[unsubs.length] = navigating.subscribe((n) => {
		if (n?.to) {
			setupPage(n.to);
		}
	});

	// ... full component below

Put it all together

Here is the entire component. Transaction data is fetched during onMount and added to stores, and current transaction details are displayed based on the navigation. “Selecting” a transaction to view details is done through regular <a href> links or programatically using the goto method provided by SvelteKit.

Changes to navigation or state invoke the setupPage(...) method which ensures component variables are set correctly.

Also note the use of a URL query parameter ?new=t which opens (and closes) a form for “creating” a new transaction.

src/routes/transactions/[…id]/+page.js

import { fetchTransactions } from '$lib/api'
// Pass the id parameter from the dynamic path slug corresponding to /transactions/[id]
// This gets set to the data.transaction_id variable
export async function load({ params: { id } }) {
	const transactions = await fetchTransactions()
	return { transaction_id: id } };
}

src/routes/transactions/[…id]/+page.svelte

<script>
	import { goto } from '$app/navigation';
	import { writable } from 'svelte/store';
	import { onDestroy, onMount } from 'svelte';
	import TransactionDetails from '$lib/Transaction/details.svelte';

	// This variable is set from the load function above
	export let data;

	// We use stores to reference the list of transactions as well as the transaction details
	// for the currently selected transaction.
	const transactions = writable(data.transactions);
	const selectedTxn = writable(undefined);

	// Track subscriptions to wrtable stores, to unsubscribe when the component is destroyed
	const unsubs = [];

	// Main function for setting the correct state on the page.
	// This idempotent function sets the selected transaction data
	// based on the transaction id from dynamic path.
	// It identifies the selected transaction from the list of all transactions loaded
	// when the component mounts.
	function setupPage(txn_id, txns) {
		if (txn_id === '' && txns.length > 0) {
			goto(`/transactions/${txns[0].id}`);
			return;
		}

		if ($selectedTxn?.id != txn_id) {
			const txn = txns.find((f) => f.id == txn_id);
			if (!txn) return;
			$selectedTxn = txn;
		}
	}

	// Call the setupPage method reactively when the transaction_id is changed
	$: setupPage(data.transaction_id, $transactions);

	// Call the setupPage method reactively when the list of all transactions is changed
	unsubs[unsubs.length] = transactions.subscribe((ts) => setupPage(data.transaction_id, ts));

	// Fetch all transactions when this component mounts
	onMount(() => {
		fetchTransactions().then((ts) => {
			transactions.set(ts);
		});
	});

	// Unsubscribe from all subscriptions
	onDestroy(() => unsubs.forEach((_) => _()));
</script>

<div class="flex flex-row">
	<div class="w-1/4">
		<div class="flex flex-row m-2 mt-6 justify-between">
			Transactions
			<a href="?new=t">
				<!-- SVG details omitted for conciseness -->
				<svg />
			</a>
		</div>
		<ul class="flex flex-col">
			{#each $transactions as txn (txn.id)}
				<li
					class:active={txn.id == data.transaction_id}
					class="m-2 border border-green-900 rounded-sm p-2"
				>
					<a href={`/transactions/${txn.id}`} class="linklike">Transaction {txn.id}</a>
				</li>
			{:else}
				<li>No transactions</li>
			{/each}
		</ul>
	</div>
	<div class="w-3/4">
		{#if !$selectedTxn && $transactions?.length == 0}
			<!-- empty page element goes here -->
		{:else if $selectedTxn}
			<TransactionDetails ransaction_id={data.transaction_id} />
		{:else if data.transaction_id}
			<div>Transaction {data.transaction_id} not found</div>
		{/if}
	</div>
</div>

<style>
	li.active {
		@apply bg-gray-300 font-bold;
	}
</style>

src/lib/Transaction/details.svelte

<script>
	import { page, navigating } from '$app/stores';
	import { goto } from '$app/navigation';
	import { writable } from 'svelte/store';
	import { onDestroy } from 'svelte';

	export let transaction_id;
	let transaction = writable(undefined);
	let showForm = false;
	const unsubs = [];

	// Show form based on URL parameters
	// Svelte-kit page store contains an instance of URLSearchParams
	// https://kit.svelte.dev/docs#loading-input-page
	function setupPage({ url }) {
		if (url.searchParams.get('new') == 't') {
			showForm = true;
		} else {
			showForm = false;
		}
	}

	// Subscribe to page and navigating stores to setup page when navigation changes
	// Note that, in our testing, the Svelte-kit load function does not fire on child modules
	// This is an alternative way to detect navigation changes without the component load function
	unsubs[unsubs.length] = page.subscribe(setupPage);
	unsubs[unsubs.length] = navigating.subscribe((n) => {
		if (n?.to) {
			setupPage(n.to);
		}
	});

	async function fetchTransactionDetails(txn_id) {
		if (!txn_id) return;

		// In normal circumstances, a call to an API would take place here
		// const api = fetchapi(`/api/transactions/${txn_id}`)
		// const res = await api.ready
		const res = await Promise.resolve({
			ok: true,
			json: () =>
				Promise.resolve({
					data: {
						id: txn_id,
						name: `Transaction ${txn_id}`,
						user: 'Not a person',
						amount: '1 million dollars'
					}
				})
		});

		if (!res.ok) throw new Error('Network error');

		const json = await res.json();
		transaction.set(json.data);
	}

	$: fetchTransactionDetails(transaction_id);

	onDestroy(() => unsubs.forEach((_) => _()));
</script>

{#if !showForm && $transaction}
	<div class="m-6 p-6 border border-gray-600 rounded">
		Details for {$transaction.name}
		<div class="grid grid-cols-2 pt-6">
			<div>Id: {$transaction.id}</div>
			<div>Name: {$transaction.name}</div>
			<div>User: {$transaction.user}</div>
			<div>Amount: {$transaction.amount}</div>
		</div>
	</div>
{/if}

{#if showForm}
	<div class="m-6 p-6 border border-gray-600 rounded">
		Create new transaction
		<form class="grid grid-cols-2">
			<label for="name">Name</label>
			<input type="text" name="name" value="" />
			<label for="user">User</label>
			<input type="text" name="user" value="" />
			<label for="amount">Amount</label>
			<input type="text" name="amount" value="" />
			<button
				name="cancel"
				class="border border-purple-800 bg-purple-100 rounded-md w-16 mt-2"
				on:click|preventDefault={() => {
					// Hide form by unsetting query param new
					$page.url.searchParams.delete('new');
					goto(`${$page.url.pathname}?${$page.url.searchParams.toString()}`, {
						noscroll: true,
						keepfocus: true
					});
				}}
			>
				Cancel
			</button>
			<button name="save" class="border border-purple-800 bg-purple-100 rounded-md w-12 mt-2"
				>Save</button
			>
		</form>
	</div>
{/if}

Here is a screenshot of the example app in action. Note the transaction id in the path, and the corresponding details selected on the page being displayed!

Screenshot of example app

Svelte stores are one of the more powerful features of the Svelte framework. Importing and subscribing to state allows for component encapsulation without passing all data as props through children.

Conclusion

I’ve been working with SvelteKit for a few months now and am really enjoying the experience. There have been rare moments of coding delight as something just works in Svelte as intuited. This is in contrast with my experience in React or NextJS, where I found components, lifecycles and hooks downright befuddling at times. Svelte solves just enough problems that make reactive web page development easy, and doesn’t hide much behind magic.

Using path-based variables and parameters to set component state ties together the ease of state management in Svelte along with people’s normal browsing behavior of saving links and using the back button. Additionally, driving state changes through the path drives a consistent approach to component data that simplifies the execution flow of code across a Svelte app.

We will continue to post about our use of Svelte and experience in the broader Svelte ecosystem of tools and extensions. If you found this article helpful, we would love to hear from you!

Happy coding adventures! - The JumpWire team