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.