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.
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.
pushState
strategyThis 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 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 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.
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.
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]
})
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.
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.
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.