404 after refreshing the browser for Angular / Vue.js app
Older Article
This article was published 7 years ago. Some information may be outdated or no longer applicable.
Intro
You’ve built an Angular or Vue.js app, created a production build, and now you’re serving it from your server. Everything looks fine until you navigate to a route and hit refresh. The browser spits back a 404.
Here’s why that happens, and how to fix it in a Node.js / Express setup.
Navigation strategies
Both Angular and Vue.js offer two ways to work with the browser’s location history: a “hash” strategy, and one built on the pushState of the History API.
http://site.com/page --> "HTML5" style routing (using the History API)
http://site.com/#/page --> "hash" based routing
Both strategies follow the golden rule of SPAs (Single Page Applications): the application shell loads once, and only the content swaps out. Content lives in components, so changing the route loads the right component as defined by the router.
Modern browsers support pushState from the history object. This method lets us bolt on new entries to the browser’s history and modify existing ones, all without firing a request to the server. That’s the critical bit to grasp.
The difference between the
pushStateand the hash-based strategies is that older browsers make a request to the underlying server unless a#is added to the URL, in which case they won’t make a request.
We must use the “HTML5” style navigation (the History API) to apply server-side rendering. True for both frameworks.
The pushState strategy
This strategy gives us clean, meaningful URLs that look like what users expect.
Because of pushState() and how the History API works, application URLs can be separated from server URLs entirely.
Angular
Angular uses the pushState strategy by default, while Vue.js defaults to the “hash strategy”. (Angular calls this “PathLocationStrategy”.)
When using this strategy, we need to specify the <base href="/"> element in the HTML header. Alternatively, the APP_BASE_HREF provider token can be wired up in the module definition:
// app.module.ts
import {APP_BASE_HREF} from '@angular/common';
@NgModule({
providers: [{ provide: APP_BASE_HREF, useValue: '/' }]
})
Vue.js
Vue.js doesn’t use the History API by default. We need to switch it on in the routes file:
// router.js
import Router from 'vue-router';
Vue.use(Router);
export default new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: Home,
},
{
path: '/about',
name: 'about',
component: About,
},
],
});
Notice the base property. By default it gets assigned to process.env.BASE_URL, which points to the publicPath option in vue.config.js (if present) but falls back to '/'.
”Hash” strategy
The hash strategy works by bolting a # symbol into the URL. It’s a holdover from the fact that “older” browsers (ones that don’t support the History API) fire a request to the server when they see domain.com/path. A # in the route prevents that; the page refreshes without hitting the server.
Angular
The two frameworks differ here: Vue.js defaults to hash mode, Angular defaults to the HTML5 History API.
For Angular, the hash strategy must be set explicitly in the routing module by passing { useHash: true }:
// app-routing.module.ts
@NgModule({
imports: [RouterModule.forRoot(routes, { useHash: true })],
exports: [RouterModule]
})
Vue.js
In Vue.js the hash strategy is the default. When we scaffold an application with routes, we’ll see the # symbol in the URL right away.
Handling the request in the server-side
Assume we’ve created a production build via ng build --prod for Angular or npm run build for Vue.js, and we want to serve it from Node.js / Express.
The boilerplate:
const express = require('express');
const app = express();
const port = 80;
// for Angular
app.use(express.static('dist/angular-app'));
// or for Vue.js
// app.use(express.static('dist/'));
app.listen(port, () => console.info(`Server running on port ${port}`));
Given what we know about the History API and pushState(), we need to tell the server that when someone refreshes site.com/page, it shouldn’t hunt for that page. Instead, it should serve index.html (the application shell, since it’s a SPA) and let the router load the right component.
A global middleware handles this:
// update for a Vue.js app accordingly
const buildLocation = 'dist/angular-app';
app.use((req, res, next) => {
if (!req.originalUrl.includes(buildLocation)) {
res.sendFile(`${__dirname}/${buildLocation}/index.html`);
} else {
next();
}
});
The middleware inspects the request URL. If it doesn’t match the build location (i.e. the root of the project), it sends back the application shell index.html, which lets the app pick up from there.
Packages like connect-history-api-fallback can handle this too.
Conclusion
Both Angular and Vue.js support two routing strategies: one built on the # symbol, one built on the History API. Using the History API (and pushState()) means we’ve got work to do on the server side to make sure a production build serves correctly on refresh.