Vue.js Server-Side Rendering With Vue Router: Step-By-Step Guide
Santiago GarcÌa da Rosa | December 11th, 2017 | 12 min read
When I started collecting information about SSR (server-side rendering) with Vue, I had to get pieces from different articles as well as the official documentation to get a complete understanding of the subject.
Here are some problems I found in these sources:
- A lot of assumptions about information that you should have, like Webpack configuration, the proper way to hook up Vue Router etc.
- Lacking certain important information and leaving some gaps for the reader to fill in.
- In the examples given, most don't follow the standards and best practices provided by the official documentation.
The objective of this article is to provide all the information you may need to get SSR working with Vue Router, making an extra effort to avoid any gaps that may give you a headache later. I also try to respect all the Vue team's recommendations.
Approach
Before jumping into the actual implementation, there are some main concepts that you need to understand:
- SSR involves creating a fully-loaded version of the app for the requested route on the server. Once that page is rendered on the client side, the client-side code takes ownership.
- You are going to need two entry building points for your app, one for the server and one for the client.
With that in mind, here's what we will accomplish in this article:
- Install the needed dependencies
- Webpack configuration
- NPM build scripts
- Folder structure
- App configuration
- Setting up Vue Router
- Client entry point
- Server entry point
- Server configuration
Let's hope this example brings some clarity to the subject!
Dependencies
Let's go through the dependencies that we are going to have to install:
1. We are going to use a template that already has a basic Webpack configuration for a VueJS app. We are also going to need to install vue-cli:
#install vue-cli
npm install -g vue-cli
#create project using webpack-simple
vue init webpack-simple vue-ssr
Now we need to install all the dependencies of the webpack-simple template. Until this point we've done nothing related to SSR; we are just setting up a general VueJS environment.
#go to project folder
cd vue-cli
#install dependencies
npm install
2. So now we have a VueJS project ready to start adding SSR configuration. Before we do, we need to add three dependencies, all related to SSR.
#install vue-server-render, vue-router, express and webpack-merge
npm install vue-server-renderer vue-router express webpack-merge --save
vue-server-render
: Vue library for SSR.vue-router
: Vue library for SPA.express
: we need a NodeJS server running.webpack-merge
: we are going to use it to merge webpack configuration.
Webpack configuration
We are going to need two Webpack configurations, one to build the client entry file and one to build the server entry file.
Let's first look at the Webpack client configuration that's also going to be our base Webpack configuration for the server entry config. We are just going to use the one that comes with the template we installed, except that we are changing the entry to entry-client.js
.
var path = require('path')
var webpack = require('webpack')
module.exports = {
entry: './src/entry-client.js',
output: {
path: path.resolve(__dirname, './dist'),
publicPath: '/dist/',
filename: 'build.js'
},
module: {
rules: [
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
],
},
{
test: /\.scss$/,
use: [
'vue-style-loader',
'css-loader',
'sass-loader'
],
},
{
test: /\.sass$/,
use: [
'vue-style-loader',
'css-loader',
'sass-loader?indentedSyntax'
],
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
loaders: {
// Since sass-loader (weirdly) has SCSS as its default parse mode, we map
// the "scss" and "sass" values for the lang attribute to the right configs here.
// other preprocessors should work out of the box, no loader config like this necessary.
'scss': [
'vue-style-loader',
'css-loader',
'sass-loader'
],
'sass': [
'vue-style-loader',
'css-loader',
'sass-loader?indentedSyntax'
]
}
// other vue-loader options go here
}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'file-loader',
options: {
name: '[name].[ext]?[hash]'
}
}
]
},
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js'
},
extensions: ['*', '.js', '.vue', '.json']
},
devServer: {
historyApiFallback: true,
noInfo: true,
overlay: true
},
performance: {
hints: false
},
devtool: '#eval-source-map'
}
if (process.env.NODE_ENV === 'production') {
module.exports.devtool = '#source-map'
// http://vue-loader.vuejs.org/en/workflow/production.html
module.exports.plugins = (module.exports.plugins || []).concat([
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: '"production"'
}
}),
new webpack.optimize.UglifyJsPlugin({
sourceMap: true,
compress: {
warnings: false
}
}),
new webpack.LoaderOptionsPlugin({
minimize: true
})
])
}
Let's add now the server webpack configuration:
var path = require('path')
var webpack = require('webpack')
var merge = require('webpack-merge')
var baseWebpackConfig = require('./webpack.config')
var webpackConfig = merge(baseWebpackConfig, {
target: 'node',
entry: {
app: './src/entry-server.js'
},
devtool: false,
output: {
path: path.resolve(__dirname, './dist'),
filename: 'server.bundle.js',
libraryTarget: 'commonjs2'
},
externals: Object.keys(require('./package.json').dependencies),
plugins: [
new webpack.DefinePlugin({
'process.env': 'production'
}),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
}
})
]
})
module.exports = webpackConfig
There is nothing strange here except two things: the entry is entry-server.js
and for the output, we are using commonjs
as a library target.
So that's the Webpack configuration. Let's now see the scripts for building the app in package.json.
package.json build scripts
You can change this to your needs, but there are three steps you need to perform to start your application:
- You need to build the client-entry
- You need to build the server-entry
- You need to start the server
"scripts": {
"start": "npm run build && npm run start-server",
"build": "npm run build-client && npm run build-server",
"build-client": "cross-env NODE_ENV=production webpack --progress --hide-modules",
"build-server": "cross-env NODE_ENV=production webpack --config webpack.server.config.js --progress --hide-modules",
"start-server": "node server.js"
}
In the configuration, we are using the start
script that is going to run the three steps that we just mentioned. But we have also set scripts to run them separately if needed.
Folder structure
- The dist folder is created by webpack when building.
- The node_modules folder... you know what this is for.
- src contains our Vue app. Inside, you are going to find the server and client entry points, the Vue main.js file, the App component, a folder for other components (we have home and about components), the router folder containing the router configuration and finally the assets folder.
- .babelrc, .gitignore, packages.json... you probably know what they are.
- index.html is the main HTML for our app.
- server.js is the server configuration and starting file.
- Finally, the two webpack configuration files.
Index HTML
This is our main HTML file.
<!doctype html>
<html lang="en">
<head>
<!-- use triple mustache for non-HTML-escaped interpolation -->
{{{ meta }}}
<!-- use double mustache for HTML-escaped interpolation -->
<title>{{ title }}</title>
</head>
<body>
<!--vue-ssr-outlet-->
<script src="dist/build.js"></script>
</body>
</html>
There are a couple of things to discuss:
- I have added some interpolation to the template to populate data from the server. It's a feature of Vue SSR that I will later show.
- We load
build.js
which is the client bundle generated from Webpack.
App.vue component
This component is the root component of our app and it has several responsibilities:
- Configuration for a menu with Vue Router links.
- Setting the container for the route components to render.
- Setting the element with the id
app
that is going to be used for mounting the client side part of the application.
<template>
<div id="app">
Hello World!
<p>
<router-link to="/">Go To Home</router-link>
<router-link to="/about">Go To About</router-link>
</p>
<router-view></router-view>
</div>
</template>
<script>
export default {
};
</script>
Router file configuration
Since our application is going to start on the server, we need to provide a new instance of the router for each server request. Inside the router folder, we are going to have a file with our router config.
// router.js
import Vue from 'vue';
import Router from 'vue-router';
import Home from '../components/Home.vue';
import About from '../components/About.vue';
Vue.use(Router);
export function createRouter () {
return new Router({
mode: 'history',
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About }
]
});
}
Let's go through the code:
- We import all the dependencies we needed.
- We tell Vue to use Vue Router.
- We export a function that provides a new instance of the router configuration.
- We instantiate the router in history mode and declare the two routes we are going to handle.
Main Vue file configuration
For the same reason that we need to provide a new router instance, we need to provide a new app instance. This file has the responsibility of starting the router and the root app component. Both the server entry point and the client entry point are going to use this file.
// main.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router/router.js'
// export a factory function for creating fresh app, router and store
// instances
export function createApp() {
// create router instance
const router = createRouter();
const app = new Vue({
router,
// the root instance simply renders the App component.
render: h => h(App)
});
return { app, router };
}
Let's go through the code:
- We import all the dependencies needed.
- We export a function that provides a new instance of the app and the router.
- We instantiate the router using the method we saw before in the
router.js
file. - We create a new app instance with the router and a render function, passing the root app component.
- We return both instances.
Client entry point
This code is quite straight-forward. This is the entry file for the Webpack client build configuration.
//client-entry.js
import { createApp } from './main.js';
const { app } = createApp()
// this assumes App.vue template root element has `id="app"`
app.$mount('#app')
Let's go through the code:
- We import all the dependencies needed.
- We create the app from the
main.js
file and keep theapp
instance. - We mount
app
in a node with the id set to app. In the case of this example, the node containing that id is the root element of the template of theApp.vue
component.
Server entry point
This file is the entry point for webpack server build. The result of that build is what we are going to target later when we configure the server.
//server-entry.js
import { createApp } from './main.js';
export default context => {
// since there could potentially be asynchronous route hooks or components,
// we will be returning a Promise so that the server can wait until
// everything is ready before rendering.
return new Promise((resolve, reject) => {
const { app, router } = createApp();
// set server-side router's location
router.push(context.url);
// wait until router has resolved possible async components and hooks
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// no matched routes, reject with 404
if (!matchedComponents.length) {
return reject({ code: 404 });
}
// the Promise should resolve to the app instance so it can be rendered
resolve(app);
}, reject);
});
}
Let's go through the code:
- We import all the dependencies needed.
- We export a function that receives a context as param.
- The functions return a promise.
- We instantiate the app and router from the
main.js
create app function. - We get the current URL from the context (this is going to be provided by the server) in order to push the correct URL to the router.
- Once the router is ready we check that a route matches the context URL. If it does, we resolve the promise and return the app instance. If not, we reject the promise.
Configuring and starting the server
We have almost everything ready. The only thing missing is the configuration and start-up of the express
server.
//server.js
const express = require('express');
const server = express();
const fs = require('fs');
const path = require('path');
//obtain bundle
const bundle = require('./dist/server.bundle.js');
//get renderer from vue server renderer
const renderer = require('vue-server-renderer').createRenderer({
//set template
template: fs.readFileSync('./index.html', 'utf-8')
});
server.use('/dist', express.static(path.join(__dirname, './dist')));
//start server
server.get('*', (req, res) => {
bundle.default({ url: req.url }).then((app) => {
//context to use as data source
//in the template for interpolation
const context = {
title: 'Vue JS - Server Render',
meta: `
<meta description="vuejs server side render">
`
};
renderer.renderToString(app, context, function (err, html) {
if (err) {
if (err.code === 404) {
res.status(404).end('Page not found')
} else {
res.status(500).end('Internal Server Error')
}
} else {
res.end(html)
}
});
}, (err) => {
console.log(err);
});
});
server.listen(8080);
Wow! And you thought it was too much before. Let's dig into the code and see what's going on.
- We are importing
express
to create the server. We are also importing some NodeJS functionality. - We import the server bundle that's the result of the Webpack server build.
- We import the
vue-server-renderer
library and create the renderer, providing theindex.html
location for the template. - We configure the
express
path. - We start the server.
- The bundle is the result of building the
serve-entry.js
with Webpack, so we can use the default function that receives the context as a param with the URL. Since it is a promise, we set a success and error callback.
The success callback does a bunch of stuff so let's go through that:
- We create a const with the data that is going to be interpolated in the
index.html
(we saw the interpolation in index.html before). - We call the render to string function of the renderer that receives the app (returned by the resolved promise), the context that we just created (to use in the interpolation in the index...this is optional), and the callback function if everything works well.
- The render to string callback function checks for any error, if not, it just sends the generated HTML as a response.
Finally, we start listening to the port 8080.
Now, if you run the script start
and open the localhost:8080
in the browser, you are going to see a working SSR with vue-router.
And that's it, ladies and gentlemen!
Conclusion
I don't think I need to say that it's a lot of configuration to make things work, but once it's done you are not going to touch it a lot. Just be sure SSR is what you need.
I'll leave you the Github project which includes all these things we just went through:
Click to load comments...