im

Svelte routing with Page.js, Part 1

Svelte doesn't have a built-in router, but it's really easy to build one

There are many routing solutions for Svelte out there. Some are better than others. I remember Rich Harris tweeted something that many people in Svelte community use page.js - an old, small, simple and battle tested routing lib by TJ, the orginal creator of the express.js web framework.

I wanted to take page.js out for a spin and see what's possible, so I spent an hour playing with it. Something pretty interesting came out as a result. Something that I want to share with you and also teach you a bit about how some of the stuff in Svelte works.

In this article you will learn about:

The simplest possible solution#

Let's skip the fluff. Just do the following.

Uno

$ npx degit sveltejs/template svelte-pagejs && cd svelte-pagejs
$ yarn add -D page

Dos

Create a few components and put some H2 tags in them so we have something to work with. Replace App.svelte with the code below. Make sure to get your imports right for the components you created.

<script>
import page from 'page';

import Home from './pages/Home.svelte';
import About from './pages/About.svelte';
import Profile from './pages/Profile.svelte';

// set default component
let current = Home;

// Map routes to page. If a route is hit the current
// reference is set to the route's component
page('/', () => (current = Home));
page('/about', () => (current = About));
page('/profile', () => (current = Profile));
// activate router
page.start();
</script>

<style>
main {
text-align: center;
padding: 1em;
max-width: 240px;
margin: 0 auto;
}

h1 {
color: #ff3e00;
text-transform: uppercase;
font-size: 4em;
font-weight: 100;
}

@media (min-width: 640px) {
main {
max-width: none;
}
}

nav a {
padding-right: 3rem;
}
</style>

<main>
<nav>
<a href="/">home</a>
<a href="/about">about</a>
<a href="/profile">profile</a>
</nav>

<svelte:component this={current} />
</main>

Important announcement

In order to our SPA to work you have to add -⁠-⁠single flag to the start script in package.json. Like this.

"start": "sirv public --single"

Tres

Start the app (yarn dev) and be amazed that it works.

But HOW does it actually work? First, we wire up the router where each route when hit re-assigns the current var to its matched component. Then our svelte:component tag sees that the reference has changed. It then creates the new component and renders it.

Note on <svelte:component>

This Svelte directive works like this:

Can we do better?#

Our simple solution works, but I wanted to have something better, something more declarative, something like this.

<Router>
<Route path="/" component="{Home}" />
<Route path="/about" component="{About}" />
<Route path="/profile" component="{Profile}" />
<Route path="/news">
<h2>Latest News</h2>
<p>Finally some good news!</p>
</Route>
<NotFound>
<h2>Sorry. Page not found.</h2>
</NotFound>
</Router>

Can we make something like this? Yep. Sure we can. Totally achievable with the right level of abstraction. Read on to learn how.

pager.js#

Let's try to create our own router by somehow wrapping page.js to do the hard work for us. We can call it pager.js. Start by creating a folder under src called pager and create the following files in it.

$ tree src/pager
src/pager
├── NotFound.svelte
├── Router.svelte
└── Route.svelte

Router.svelte#

We will start with the router as it's the main file that will do the dirty work for us. Since we will do the routing in there we need to move the page.js to it.

We also need to declare the routes inside our router. For that we will use Svelte's slot. See slot as a placeholder into which you can put other components and html tags and stuff. Here is the file so far.

<script>
import page from 'page';
</script>

<slot />

Now create a Route.svelte file and define the component and path properties in it.

<script>
export let path = '/';
export let component = null;
</script>

<slot />

Add NotFound.svelte with just a <slot /> in it.

Import those files in the App.svelte file and paste the declarative router code in the main area. The file should look like this (with style omitted).

<!-- App.svelte -->

<script>
import Router from './pager/Router.svelte';
import Route from './pager/Route.svelte';
import NotFound from './pager/NotFound.svelte';

import Home from './pages/Home.svelte';
import About from './pages/About.svelte';
import Profile from './pages/Profile.svelte';
</script>

<main>
<nav>
<a href="/">home</a>
<a href="/about">about</a>
<a href="/profile">profile</a>
<a href="/news">news</a>
</nav>

<Router>
<Route path="/" component="{Home}" />
<Route path="/about" component="{About}" />
<Route path="/profile" component="{Profile}" />
<Route path="/news">
<h2>Latest News</h2>
<p>Finally some good news!</p>
</Route>
<NotFound>
<h2>Sorry. Page not found.</h2>
</NotFound>
</Router>
</main>

Start the app and now at least it should not give you compile errors. But it's not usable at all as we only got the structure, but not logic. Let's fill that part in. Back to our router.

Now, from our simple example in the beginning, we know that we have to use slots to render our components. How can we do that? We are passing path and components to individual routes, right? Add the following code line to the Route.svelte file right above the <slot /> tag and the components passed in will now be rendered.

<svelte:component this="{component}" />

Great! Well, actually not THAT great as all components are shown at once, but at least some progress!

We now need to get back to the main router file and add some logic to it. Somehow we need the routes register themselves with page.js which lives in the Router file. How do can we do that? We can use simple dictionary for that and export some kind of register function from the Router file.

Before we start, we need to understand how Svelte components work. When you import a Svelte component somewhere in your app it has only a single default export and that is the component itself. This is important to understand.

// the standard way
import Router from './Router.svelte';

// same component but different name
import Foo from './Router.svelte';

// This will not work, unless ..
import { register } from './Router.svelte';

So the last import statement will not work unless you declare a module script in your component.

<script type="module">
export function register(route) {
console.log(route);
}
</script>

Add that module script to our Router.svelte file and now you can import register function in the Route.svelte file.

When you define the module script in the component, all defined stuff in there (vars and functions) will be available to all the instances of that component. Thus they are "shared" variables. There are some more nuances to that and what you can and can't do. Please refer to the official docs to learn more.

Our route can now register itself with the Router.

<script>
import { register } from './Router.svelte';

export let path = '/';
export let component = null;

register({ path, component });
</script>

<svelte:component this="{component}" />
<slot />

In the Router we need a place to keep these route objects somewhere. We can use a simple dict for that and use path as a key.

<script context="module">
const routes = {};

export function register(route) {
routes[route.path] = route;
}
</script>

<script>
import { onMount } from "svelte";
import page from "page";

onMount(() => console.log(routes));
</script>


<slot />

If you have done everything correctly, you can now see the routes object printed in the browser's dev console. Progress!

Now we need to wire it up to the page.js somehow. We can create the following function that wires up page.

<script>
import { onMount, onDestroy } from "svelte";
import page from "page";

const setupPage = () => {
for (let [path, route] of Object.entries(routes)) {
page(path, () => console.log(route));
}

// start page.js
page.start();
};

// wire up page.js when component mounts on the dom
onMount(setupPage);

// remove page.js click handlers when component is destroyed
onDestroy(page.stop);
</script>

Now if you click around on the nav links you should see the mapped route printed in the dev tools console. We are slowly getting there!

Somehow we need to keep the state of the current component and for that we can use Svelte's reactive store. Add the following to Router.svelte

// on top of the module script

import { writable } from 'svelte/store';

export const activeRoute = writable({});

// and change the "page" line in the regular script to

page(path, () => ($activeRoute = route));

We now need our components to know which one is the active one, meaning which should be displayed. We can easily do that by importing our activeRoute store. And since stores are reactive all components will know when it changes. Our Route.svelte file looks like this now.

<script>
import { register, activeRoute } from './Router.svelte';

export let path = '/';
export let component = null;

register({ path, component });
</script>

{#if $activeRoute.path === path}
<svelte:component this="{component}" />
<slot />
{/if}

Now stuff should ... kind of work when you click around. Except we constantly see that "not found" route. Not good. Something we need to fix and something that is thankfully, pretty easy to fix.

<script>
import { register, activeRoute } from './Router.svelte';

// page.js catch all handler eg "not found" in this context
export let path = '*';
export let component = null;

register({ path, component });
</script>

{#if $activeRoute.path === path}
<svelte:component this="{component}" />
<slot />
{/if}

Phew! Everything finally works now and you can pat yourself on the shoulder for making it this far! But ... we are not quite done yet. I want more! I want to pass custom properties and page's params down to the components and also be able to protect the routes. Something like the code below.

<Router>
<Route path="/" component="{Home}" {data} {user} />
<Route path="/about" component="{About}" />
<Route path="/profile/:username" middleware="{[guard]}" let:params>
<h2>Hello {params.username}!</h2>
<p>Here is your profile</p>
</Route>
<Route path="/news">
<h2>Latest News</h2>
<p>Finally some good news!</p>
</Route>
<NotFound>
<h2>Sorry. Page not found.</h2>
</NotFound>
</Router>

Want to know how? Stay tuned for Part 2.