Web app routing with fallback

How to use fallback with client-side routing to avoid 404 pages.

Imagine we have a web page with client-side routing. For different urls we will show different pages. For example, using HyperApp Routing we can show "home" and "about" pages.

The web page

1
2
3
4
5
<body>
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://web-packing.com/hyperx"></script>
<script src="app.js"></script>
</body>

The app.js has the simple code from the HyperApp routing example, but using hyperx template strings rather than JSX.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const {h, app, Router} = hyperapp
const html = packs.hyperx(h)
app({
view: {
'/': (model, actions) =>
html`<div>
<h1>Home</h1>
<button
onClick=${_ => actions.router.go("/about")}>
About
</button>
</div>`,

'/about': (model, actions) =>
html`<div>
<h1>About</h1>
<button
onClick=${_ => actions.router.go("/")}>
Home
</button>
</div>`,

},
plugins: [Router]
})

We can serve this page using simple static server like http-server and get the correct routing if we load the index page first. If we try to load the /about page, we will get a 404 error.

Why is this happening? If we do not have the web application loaded from the index.html page listening and intercepting location change events, then the browser has no idea that /about is part of the application. Instead it goes directly to the server, that fails to find a page to serve.

This has another defect. Notice that the links from one client view, for example from home to about view had to use a button click and a router action?

1
<button onClick=${_ => actions.router.go("/about")}>About</button>

These links would not work if we tried using plain <a href="/about">About</a> elements, which is a shame.

Fallback

In order to solve this problem, the server has to catch stray requests like GET /about and return the index.html page. Then the application will pick up the location /about and will serve the right view client-side. There is a middleware that does that called connect-history-api-fallback

We can use a simple Express app to serve static files with history fallback.

1
2
3
4
5
6
7
8
9
10
const history = require('connect-history-api-fallback')
const express = require('express')
const app = express()
app.use(history({
verbose: true
}))
app.use(express.static('.'))
app.listen(3000, function () {
console.log('Example app listening on port 3000!')
})

Now the user can type localhost:3000/about and the right page is served:

  • browser goes to the server to fetch :3000/about page
  • the history middleware catches the request and returns index.html
  • the web application in index.html initializes itself, looks at location and routes to /about view

Not only this is a much better experience for the user, it also allows plain href links like <a href="/about">...</a> to work correctly.

You just need to remember that your application state will NOT be preserved during such transitions, because the page is loaded again from scratch.

Parametrized views

A view does not have to be hard coded, client side routing also can allow parameters. For example, if we want to have "user" view where the user name is dynamic, we could define a view like this

1
2
3
4
5
6
7
8
'/:username': (model, actions) =>
html`<div>
<h1>User ${model.router.params.username}</h1>
<button
onClick=${_ => actions.router.go("/")}>
Home
</button>
</div>`

Going through the router action actions.router.go("/bahmutov") and typing the url directly work.

But what if the path has parts? For example if we define /user/:username view

1
2
3
4
5
6
7
8
'/user/:username': (model, actions) =>
html`<div>
<h1>User ${model.router.params.username}</h1>
<button
onClick=${_ => actions.router.go("/")}>
Home
</button>
</div>`

Then using actions.router.go("/user/bahmutov") works, but typing the URL directly does not.

The history middleware does not handle relative path from the /user/index.html page to app.js and tries to load /user/app.js. There are two solutions to this problem: and the first one is very simple. Since our application really lives in index.html at the "root" of the domain, we should force loading app.js and any other file from the root. Just change index.html and prefix the local resources with / character.

1
2
3
4
<body>
<!-- ... CDN resources unchanged ... -->
<script src="/app.js"></script>
</body>

The web application works now, even if the user opened the address localhost:3000/user/bahmutov directly.

The second solution is to add custom a rewrite rules to the history middleware. For example, if the client is asking for /user/app.js we should serve the file app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
const history = require('connect-history-api-fallback')
const rewriteApp = {
from: /^\/user\/app\.js$/,
to: context => '/app.js'
}
app.use(history({
verbose: true,
rewrites: [rewriteApp]
}))
app.use(express.static('.'))
app.listen(3000, function () {
console.log('Example app listening on port 3000!')
})

I prefer the first solution, since it is so much simpler.