404 after refreshing the browser for Angular / Vue.js app

This post is 4 years old. (Or older!) Code samples may not work, screenshots may be missing and links could be broken. Although some of the content may be relevant please take it with a pinch of salt.

Intro

If you are reading this article you're probably familiar with the scenario: you developed an Angular or a Vue.js application, created a (production) build for it and now you'd like to serve the built project from your server. Seemingly everything works, but when you navigate to a route, and you hit refresh, the browser comes back with a 404 message.

Let's review why this is happening and how to apply a fix in a Node.js / Express environment.

Navigation strategies

Both Angular and Vue.js offer two ways to work with the browser's location history - one is a "hash" strategy, and the other one is based 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 of these strategies work from the same assumption that follows the golden role of SPA (Single Page Applications) - we have the application shell loaded, and only the content gets changed. Content is typically specified within components, so changing the route (the URL effectively) will load the appropriate component as defined by the router.

Modern browsers do support the pushState from the history object. By using this method, we can add and modify entries in the browser's history, without making a request to the server. This is an important concept to grasp.

The difference between the pushState and 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 will not make a request.

Another key to understand is that we must use the "HTML5" style navigation (that is, use the History API) to be able to apply and use server-side rendering - this is true for both frameworks.

The pushState strategy

This strategy allows us to have meaningful URLs that are clear to the users and they look like how we are used to seeing URLs.

Because of the pushState() method and how the History API works, the application URLs can be separated from the server URLs.

Angular

Angular by default uses the pushState strategy whereas Vue.js defaults to the "hash strategy". (Angular does refer to this strategy as "PathLocationStrategy".)

Remember that if this strategy is used, we must also specify the <base href="/"> element in the header of our HTML. As an alternative the APP_BASE_HREF provider token can be used as part of the module definition, in the following way:

// app.module.ts
import {APP_BASE_HREF} from '@angular/common';

@NgModule({
providers: [{ provide: APP_BASE_HREF, useValue: '/' }]
})

Vue.js

Vue.js does not use the history API by default, it is something that we need to define in the file where we specify our routes:

// 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 that we also have to specify the base property which by default will be assigned to process.env.BASE_URL which points to the publicPath option in vue.config.js (if added) but will be '/' as the default.

"Hash" strategy

The hash strategy works by adding a # symbol in the URL - this is almost a legacy thing coming from the fact that "older" browsers (that do not support the History API) make a request to the server when presented with a route of domain.com/path and in a SPA environment this should be avoided. Having a # in the route does exactly that - refreshes the page without making a request to the server.

Angular

The two frameworks differ from each other in that Vue.js's default location strategy is the hash mode, whereas Angular defaults to the HTML5 History API.

For Angular, the hash strategy needs to be set explicitly in the routing module by adding the { useHash: true } object:

// app-routing.module.ts
@NgModule({
imports: [RouterModule.forRoot(routes, { useHash: true })],
exports: [RouterModule]
})

Vue.js

In Vue.js the hash strategy is the default strategy. This means that by default when we create an application and the appropriate routes, we'll see the # symbol in the URL.

Handling the request in the server-side

Now let's assume that we have created a production-ready build for our applications either via ng build --prod for Angular or npm run build for Vue.js, and we wish to serve it from Node.js / Express.

Let's start with the boilerplate code:

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}`));

Based on the what we have learnt about the History API and the pushState() method we need to be able to tell the server somehow, that if we refresh site.com/page it should not try to load that page but instead, load index.html (which is the shell of the application since it's a SPA) and then load the component that represents page.

To achieve the desired functionality, we can add a global middleware:

// 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();
}
});

In the middleware above, we take a look at the URL of the request, and if it doesn't match the location of the build (i.e. the root of the project), then we send the shell of the application index.html back to the user which will allow the app to work correctly.

There are packages available in npm like the connect-history-api-fallback which could also be used.

Conclusion

Both Angular and Vue.js support two different routing strategies - one based on the # symbol and one based on the History API. Using the History API (and the pushState() method) means that there's some work we need to do at the server side as well to be sure to serve up a production build of our application correctly.