A twisty maze of stairways.

Clean Client-side Routing

Joeri Sebrechts

The main Plain Vanilla tutorial explains two ways of doing client-side routing. Both use old school anchor tags for route navigation. First is the traditional multi-page approach described on the Sites page as one HTML file per route, great for content sites, not so great for web applications. Second is the hash-based routing approach decribed on the Applications page, one custom element per route, better for web applications, but not for having clean URLs or having Google index your content.

In this article I will describe a third way, single-file and single-page but with clean URLs using the pushState API, and still using anchor tags for route navigation. The conceit of this technique will be that it needs more code, and the tiniest bit of server cooperation.

Intercepting anchor clicks

To get a true single-page experience the first thing we have to do is intercept link tag navigation and redirect them to in-page events. Our SPA can then respond to these events by updating its routes.

In an example HTML page we can leverage this to implement routing in a <demo-app></demo-app> element.

open example 1 in a separate tab

The first thing we're doing in view-route.js is the interceptNavigation() function. It adds an event handler at the top of the DOM that traps bubbling link clicks and turns them into a navigate event instead of the default action of browser page navigation. Then it also adds a navigate event listener that will update the browser's URL by calling pushState.

In app.js we can listen to the same navigate event to actually update the routes. Suddenly we've implemented a very basic in-page routing, but there are still a bunch of missing pieces.

There and back again

For one, browser back and forward buttons don't actually work. We can click and see the URL update in the browser, but the page does not respond. In order to do this, we need to start listening to popstate events.

However, this risks creating diverging code paths for route navigation, one for the navigate event and one for the popstate event. Ideally a single event listener responds to both types of navigation. A simplistic way of providing a single event to listen can look like this:

Now our views can respond to popstate events and update based on the current route. A second question then becomes: what is the current route? The popstate event does not carry that info. The window.location value does have that, and it is always updated as we navigate, but because it has the full URL it is cumbersome to parse. What is needed is a way of easily parsing it, something like this:

The matchesRoute() function accepts a regex to match as the route, and will wrap it so it is interpreted relative to the current document's URL, making all routes relative to our single page. Now we can clean up the application code leveraging these new generic routing features:

open example 2 in a separate tab

Opening that in a separate tab we can see that the absolute URL neatly updates with the routes, that browser back/forwards navigation updates the view, and that inside the view the route is relative to the document.

Because matchesRoute() accepts a regex, it can be used to capture route components that are used inside of the view. Something like matchesRoute('/details/(?<id>[\\w]+)') would put the ID in matches.groups.id. It's simple, but it gets the job done.

Can you use it in a sentence?

While this rudimentary way of detecting routes works, adding more routes quickly becomes unwieldy. It would be nice to instead have a declarative way of wrapping parts of views inside routes. Enter: a custom element to wrap each route in the page's markup.

Now we can rewrite our app to be a lot more declarative, while preserving the behavior.

open example 3 in a separate tab

404 not found

While things now look like they work perfectly, the illusion is shattered upon reloading the page when it is on the details route. To get rid of the 404 error we need a handler that will redirect to the main index page. This is typically something that requires server-side logic, locking us out from simple static hosting like GitHub Pages, but thanks to the kindness of internet strangers, there is a solution.

It involves creating a 404.html file that GitHub will load for any 404 error (the tiny bit of server cooperation). In this file the route is encoded as a query parameter, the page redirects to index.html, and inside that index page the route is restored.

Adding this last piece to what we already had gets us a complete routing solution for vanilla single page applications that are hosted on GitHub Pages. Here's a live example hosted from there:

To full code of view-route.js and of this example is on GitHub.