im

Svelte routing with Page.js, Part 2

Learn how to abstract our router by leveraging Svelte's built-in components

Welcome to the second and final part of the series of routing with page.js. In the first part we got the basic routing in place and in this part we will finish what we started. More specifically we will implement:

This is how we want our final solution to look and work.

<Router>
<Route path="/" component="{Home}" {data} />
<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>

Exposing params#

We will start with the easiest part. Exposing params to the components and in routes. Page.js allows you to define params in the url path and will make them available to you in its context object. We first need to understand how page.js works

page('/profile/:name', (ctx, next) {
console.log('name is ', ctx.params.name);
});

Page.js takes a callback with context and next optional parameters. Context is the context object that will be passed to the next callback in the chain in this case. You can put stuff on the context object that will be available to the next callback. This is useful for building middlwares, for example pre-fetching user information, and also caching. Read more what's possible in the context docs.

Propagating params is actually pretty simple, we just have to put it in our activeRoute store in the Router.svelte file. Like this.

const setupPage = () => {
for (let [path, route] of Object.entries(routes)) {
page(path, (ctx) => ($activeRoute = { ...route, params: ctx.params }));
}

page.start();
};

And here is how our Route.svelte file looks now.

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

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

// Define empty params object
let params = {};

register({ path, component });

// if active route -> extract params
$: if ($activeRoute.path === path) {
params = $activeRoute.params;
}
</script>

{#if $activeRoute.path === path}
<!-- if component passed in ignore slot property -->
{#if $activeRoute.component}
<!-- passing custom properties and page.js extracted params -->
<svelte:component
this="{$activeRoute.component}"
{...$$restProps}
{...params}
/>

{:else}
<!-- expose params on the route via let:params -->
<slot {params} />
{/if}
{/if}

We use the spread operator to pass page.js params down to the component. That's just one way to do it. You might as well pass down the whole params object if you want. The interesting part is the $$restProps property that we also pass down to the underlying component. In Svelte, there are $$props and $$restProps properties. Props includes all props in component, the passed in ones and the defined ones, while restProps excludes the ones defined in the component and includes the only ones that are being passed in. This means that we also just solved passing custom properties down to components feature. Hooray!

Our main part of the App.svelte looks like this now.

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

<Router>
<Route path="/" component="{Home}" />
<Route path="/about" component="{About}" />
<Route path="/profile/:username" 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>
</main>

Give the app a spin and see if our params feature works as expected. I left out custom data properties as an exercise.

Protected routes with middleware#

The only missing part now is the protected routes part, which we can solve with the help of middleware. Let's implement this.

Page.js supports multiple callbacks for a route which will be executed in order they are defined. We will leverage this feature and build our middleware on top of it.

page('/profile', guard, loadUser, loadProfile, setActiveComponent);

It works something like this. Our "guard" callback will check for some pre-condition and decide whether to allow the next callback in the chain or not. Our last callback that sets the active route must be last in the chain, named setActiveComponent in the example above. For that to work we need to refactor the main router file a bit.

// extract our active route callback to its own function
const last = (route) => {
return function (ctx) {
$activeRoute = { ...route, params: ctx.params };
};
};

const registerRoutes = () => {
Object.keys($routes).forEach((path) => {
const route = $routes[path];
// use the spread operator to pass supplied middleware (callbacks) to page.js
page(path, ...route.middleware, last(route));
});

page.start();
};

You might wonder where the route.middleware comes from. That is something that we pass down to the individual routes.

<!-- Route.svelte -->

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

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

// define new middleware property
export let middleware = [];

let params = {};

// pass in middlewares to Router.
register({ path, component, middleware });

$: if ($activeRoute.path === path) {
params = $activeRoute.params;
}
</script>

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

{:else}
<slot {params} />
{/if}
{/if}

If you try to run the app now you will get a reference error. That's because we have to add middleware property to NotFound.svelte too.

<!-- NotFound.svelte -->

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

// page.js catch all handler
export let path = '*';
export let component = null;

register({ path, component, middleware: [] });
</script>

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

And here what our App.svelte looks now with style omitted.

<script>
import { Router, Route, NotFound, redirect } from './pager';

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

const data = { foo: 'bar', custom: true };

const guard = (ctx, next) => {
// check for example if user is authenticated
if (true) {
redirect('/login');
} else {
// go to the next callback in the chain
next();
}
};
</script>

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

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

The app file looks a little different now, but that's because I've added some bells and whistles to it. You can find the whole project here.

Conclusion#

This wraps everything up. We've now created fully declarative router for Svelte based on page.js. It's not feature complete, but you can easily adjust it to your own requirements. It's hard to build libraries that cover every possible corner case, kudos to those who try!

I hope that I showed you that it's actually not that hard to build something in Svelte that fits just your requirements, while also keeping control of the code. I also hope that you picked up some knowledge on the way of how Svelte works.