Tutorial on Server Side Rendering with Vue.js and Express

server side rendering vue.js express

Single page applications have become one of the most popular trends in web3 development. Everyone interacts with these kinds of applications daily.

Their most essential feature is that they don’t have a separation between front and back end. Instead, they are one thing.

Developers use JavaScript frameworks like Vue.js to create single-page applications. Within Vue.js, they use server side rendering to improve their apps.

Below, you’ll find all the basic details you need to know on how to get SSR working with Vue.js and Express, plus its benefits, and drawbacks.

What is Server Side Rendering?

As you know, SPAs built with Vue.js framework come with reactivity, no page refresh, component architecture, state management, etc.

SSR basically transforms the components into rendered HTML strings on the server and also serves them statically to the browser, subsequently “hydrating” the static markup into a regular interactive app on the client.

When Should You Consider Using SSR?

A Better SEO for Your Platform

The main use case of SSR is a better SEO. Even if you want to build an admin dashboard or some other back office platform that doesn’t really need any kind of SEO, at some point, you realize you might need a plugin.

But even plugins that should help you change the title and some meta tags aren’t good enough to get the job of SEO optimization done.

Faster Site Speed

A second use case is speed. If you have the strings generated on the server, that means that your client won’t have to wait for all the JavaScript files to load before they see anything on their screen. 

That’s because the first response already has a DOM (Document Object Model) with components that have been statically converted by the server and the initial data for the first render has been already retrieved on the server.

However, you need to set this up on your own, depending on what your app needs to render first.

Let’s Begin – All You Need to Know About Running Vue.js with Server Side Rendering

The entire code template is available in this github repo. First, clone the repo, and then I’ll walk you through all the important pieces that aren’t just used in basic Vue.js setup.

Take a look at the webpack configuration files, starting with config/webpack.base.config.js

const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const srcPath = path.resolve(process.cwd(), 'src');
const isProduction = process.env.NODE_ENV === 'production';

module.exports = {
    mode: process.env.NODE_ENV,
    devtool: isProduction ? 'source-map' : 'eval-source-map',
    module: {
        rules: [
            {
                test: /.vue$/,
                loader: 'vue-loader',
                include: [ srcPath ],
            },
            {
                test: /.js$/,
                loader: 'babel-loader',
                include: [ srcPath ],
                exclude: /node_modules/,
            },          
            {
                test: /.(png|jpe?g|gif|svg)(?.*)?$/,
                use: [
                    {
                        loader: 'url-loader',
                        options: {
                            limit: 10000,
                            name: '[path][name].[hash:7].[ext]',
                            context: srcPath
                        }
                    }
                ]
            },
            {
                test: /.(woff2?|eot|ttf|otf)(?.*)?$/,
                use: [
                    {
                        loader: 'url-loader',
                        options: {
                            limit: 10000,
                            name: '[name].[hash:7].[ext]'
                        }
                    }
                ]
            },
        ]
    },
    plugins: [
        new VueLoaderPlugin()
    ]
};

This is a basic webpack build config for Vue components, it includes loaders for .vue, .js, .svg, image and font files. 

Now, in the same folder, you’ll see that there are also the following two files: webpack.client.config.js and webpack.server.config.js.

Both files use webpack merge to overwrite or add certain fields to the previously mentioned webpack.base.config.js.

Below, I’ll only cover the client config because it’s longer and doesn’t have comments, which is quite the opposite of the server config.

const path = require('path');
const webpack = require('webpack');
const merge = require('webpack-merge');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const autoprefixer = require('autoprefixer');

const base = require('./webpack.base.config');
const isProduction = process.env.NODE_ENV === 'production';
const srcPath = path.resolve(process.cwd(), 'src');

module.exports = merge(base, {
    entry: {
        app: path.join(srcPath, 'client-entry.js')
    },
  output: {
        path: path.resolve(process.cwd(), 'dist'),
        publicPath: '/public',
        filename: '[name].js',
        sourceMapFilename: isProduction ? '[name].[hash].js.map' : '[name].js.map',
    },
    resolve: {
        extensions: ['.js', '.vue'],
    },   
    module: {
        rules: [          
            {
                test: /.css$/,
                use: [
                    MiniCssExtractPlugin.loader,
                    { loader: 'css-loader', options: { sourceMap: !isProduction } },
                ]
            },
            {
                test: /.scss$/,
                use: [
                    MiniCssExtractPlugin.loader,
                    'css-loader',
                    {
                        loader: 'postcss-loader',
                        options: {
                            plugins: () => [autoprefixer]
                        }
                    },
                    'sass-loader',
                ],
            },            
        ]
    },

    plugins: (isProduction ? 
        [
            new MiniCssExtractPlugin({
                filename: '[name].css',
            }),
        ] : [
            new MiniCssExtractPlugin({
                filename: '[name].css'
            }),
            new webpack.HotModuleReplacementPlugin(),
        ]
    )
});

As you can see, the client config adds some additional file loader rules for .css and .scss files along with the MiniCss plugin. It makes the bundle smaller and adds the HotModule when running in development mode.

What’s more important is to set up the entry file for the client side build to src/client-entry.js (find below more details about this).

Basically, when you build the bundles either in dev or in prod, the script will create two bundles:

  • one that is served by the server initially (which already has static rendered html into it) 
  • another bundle for the client that’s the same as the usual Vue setup that mounts on the specified id attribute in the DOM 

Each of these bundles has different entry points because they run in different environments; one bundle is in the browser, and the other runs on the server in NodeJS/Express.
The next file you’ll need to take a look at before you touch the entry files is the actual server.js file located in the root of our repository.

const express = require('express');
const path = require('path');
const fs = require('fs');
const vueServerRenderer = require('vue-server-renderer');
const setupDevServer = require('./config/setup-dev-server');

const port = 3000;
const app = express();

const createRenderer = (bundle) =>
    vueServerRenderer.createBundleRenderer(bundle, {
        runInNewContext: false,
        template: fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8')
    });
let renderer;

// you may want to serve static files with nginx or CDN in production
app.use('/public',  express.static(path.resolve(__dirname, './dist')));

if (process.env.NODE_ENV === 'development') {
    setupDevServer(app, (serverBundle) => {
        renderer = createRenderer(serverBundle);
    });
} else {
    renderer = createRenderer(require('./dist/vue-ssr-server-bundle.json'));
}

app.get(/^/(about)?/?$/, async (req, res) => {
    const context = {
        url: req.params['0'] || '/',
        state: {
            title: 'VueJS SSR',
            users: []
        }
    };
    let html;

    try {
        html = await renderer.renderToString(context);
    } catch (error) {
        if (error.code === 404) {
            return res.status(404).send('404 | Page Not Found');
        }
        return res.status(500).send('500 | Internal Server Error');
    }

    res.end(html);
});

// the endpoint for 'serverPrefetch' demonstration
app.get('/users', (req, res) => {
    res.json([{
            name: 'Albert',
            lastname: 'Einstein'
        }, {
            name: 'Isaac',
            lastname: 'Newton'
        }, {
            name: 'Marie',
            lastname: 'Curie'
        }]
    );
});

app.listen(port, () => console.log(`Listening on: ${port}`));

Notable packages and other config files present here are vue-server-renderer and the last remaining file in the config folder called setup-dev-server.js.

The vue-server-renderer package is a Vue package you can use to interpret regular Vue files into static html and create already rendered bundles.

The setup-dev-server.js file contains a custom dev server callback function. This file tells Express how to run everything on the server, such as specifying a custom in memory file system using memory-fs and builds client, and server bundles on the fly making the hot module work for the development environment; this isn’t used in production use cases.

As you can see, the Express server has two endpoints specified. You can leave aside the endpoint for  /users ; it’s just a mock data endpoint for the user list visible in the home module.
Just focus on the regex endpoint that is made to match  /  and  /about  requests.

app.get(/^/(about)?/?$/, async (req, res) => {
    const context = {
        url: req.params['0'] || '/',
        state: {
            title: 'VueJS SSR',
            users: []
        }
    };
    let html;

    try {
        html = await renderer.renderToString(context);
    } catch (error) {
        if (error.code === 404) {
            return res.status(404).send('404 | Page Not Found');
        }
        return res.status(500).send('500 | Internal Server Error');
    }

    res.end(html);
});

Let’s break down the context first. The context has a (default) state specified that the vue-server-renderer handler will pass and further used in the entrypoint files src/client-entry.js and src/server-entry.js, along with the url property. The latter tells the vue router what route to display (obviously this has to match with a router record to work).

There’s no need to pay too much attention to client-entry.js. The browser environment works as usual for the Vue.js setup. The only notable thing to mention is that you can use a __INITIAL_STATE___ variable to set the default state that’s provided by the server in the served static bundle.

import { createApp } from './app';
const { app, router } = createApp({state: window.__INITIAL_STATE__});
import './assets/style.scss';

router.onReady(() => {
    app.$mount('#app');
});

The server-entry.js file has more set up inside because the server needs to know what location it needs to set the Vue.js app to. Additionally, the file needs to know what data to inject into it for initial static generation.

import { createApp } from './app';

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, store } = createApp(context);
        // metadata is provided by vue-meta plugin
        const meta = app.$meta();
        // set server-side router's location
        router.push(context.url);

        context.meta = meta;

        // 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 });
            }
            // This `rendered` hook is called when the app has finished rendering
            context.rendered = () => {
                // After the app is rendered, our store is now
                // filled with the state from our components.
                // When we attach the state to the context, and the `template` option
                // is used for the renderer, the state will automatically be
                // serialized and injected into the HTML as `window.__INITIAL_STATE__`.
                context.state = store.state;
            };
            // the Promise should resolve to the app instance so it can be rendered
            resolve(app);
        }, reject);
    })
}

Aside from the explanatory comments, notice the meta addition to the context. It adds the VueMeta instance from the app into the context for use in the main html template file, which is in the repository root as index.html.

<!DOCTYPE html>
<html lang="en">
    <head>
        {{{ meta.inject().title.text() }}}
        {{{ meta.inject().meta.text() }}}
        <link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet">
        <link rel="stylesheet" href="public/app.css">        
    </head>
    <body>
        <div id="app">
        <!--vue-ssr-outlet-->
        </div>
        <script src="public/app.js"></script>
    </body>
</html>

Notice the meta.inject().title.text() which is pulled for the corresponding route, for example from the home module in modules/home/index.vue in the script tag under the metaInfo property.

export default {
        ...
        metaInfo: {
            title: 'Home - VueJS SSR',
            meta: [{ 
                name: 'description', 
                content: 'Home page description'
            }]
        },
        ...
}

The <!–vue-ssr-outlet–> tag is basically where the renderer knows to insert the client bundle stuff.

Building for Production

All you have to do is run the following command:

yarn build

This will generate a dist folder with the generated bundles, which will be used if you run the yarn start command. This command will only serve the bundles in the dist folder instead of generating new bundles on the fly like the yarn dev command.

You can also build the docker image using the following command:

docker build -t vue-ssr:latest -f ./Dockerfile .

Worthy Mentions About Vue.js With SSR

As you know, Vue.js has the following lifecycle hooks beforeCreate, created, beforeMount, mounted, beforeUpdate and updated.

The beforeCreate and created hooks should only be used for server side intended stuff, because they’re the only ones that get triggered when the server is rendering the templates. You should use all the other ones to run on the client side browser.

For example, if you’re populating a list with some default store data in mounted and are wondering why the list is empty when inspecting the page source, it’s because you should have populated the list in the created lifecycle hook instead.

You can handle metadata from configuration files instead of just adding them to the main page files. Another option is injecting it from the server while fetching data from the database for all routes or places where you have it stored.

Regarding the lifecycle hooks, you should only use browser-specific code in the hooks that aren’t run on the server. For example, if you want to use the window object for anything, you should never use the beforeCreate or created hooks because the server doesn’t have such a global object; the outcome will be a 500 server error.

Keep in mind that SSR will cause more resource usage for the server as opposed to just sending the files to the browser and having the client take care of the rest.

In conclusion, I hope you now have a better understanding of what changes are needed to run Vue.js with SSR and the caveats you should be aware of.