Server-side rendering (SSR) with Vue 3 (TypeScript), Node (TypeScript), Vue Router, and Store

Server-side rendering (SSR) with Vue 3 (TypeScript), Node (TypeScript), Vue Router, and Store

Sun May 16 20215,515 words

Follow the guide here to create all the required files. Or have a look at the example repository

Vue 3 Server-side rendering (SSR) life cycle

Installing Vue and Webpack Dependencies


The easiest way to set up a new Vue 3 project is using the Vue CLI

npm install -g @vue/cli

vue create my-ssr-app

Choose the following options

Please pick a preset: Manually select features

Check the features needed for your project: Babel, TypeScript, Router, Linter

Choose a version of Vue.js that you want to start the project with 3.x

Use class-style component syntax? Yes

Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes

Use history mode for router? (Requires proper server setup for index fallback in production) Yes

Pick a linter / formatter config: Basic

Pick additional lint features: Lint on save

Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files


npm install @vue/server-renderer

The vue 3 server renderer provides the API to convert a Vue App instance into its HTML output string


npm install file-loader url-loader vue-loader webpack-node-externals webpack-manifest-plugin --save-dev

Install some loaders to deal with files, urls etc as well as the latest version of the vue compiler.

Some notes on the following 2 dependencies which relate to SSR

webpack-node-externals - Used to ignore the node_modules folder during the server build

webpack-manifest-plugin - Generates a ssr-manifest.json file containing a list of all the bundled app files

The manifest has a mapping of [file].[ext] to [file].[hash].[ext] and can be used in the Node app to find the vue server app JS file

Installing TypeScript


npm install ts-node --save-dev

ts-node - This allows us to execute TypeScript code in a Node environment in the same way as JavaScript

Because you can run TypeScript code in Node, it means you can write Node apps in TypeScript and send a compiled JS version to your production server.


npm install @types/compression @types/express @types/node --save-dev

As everything is written in TypeScript, definitions are required for Node (the environment), Express (the Node app), and Express compression (Node app middleware).

Installing Node


Node can be installed on your local machine directly from the Node website or through a package manager like Homebrew.


npm install cross-env nodemon livereload connect-livereload --save-dev

The above dependencies give us a Node development environment that listens for file changes and reloads the Node server and the browser

cross-env - Sets environment variables on the CLI

nodemon - Listens for file changes and reloads the Node server

livereload - Listens for file changes and reloads the browser

connect-livereload - Injects the livereload JS script into the response


npm install express compression

express - To create a Node app that will serve the rendered SSR Vue app content, we can use Express which is a framework for building Node applications.

compression - Turns on GZIP (or similar) and compresses the response from the Node server

Configuring Vue, Webpack, and TypeScript

We need 2 tsconfig.json files (the other being One for the Vue app build and one for the Node app build.


module.exports = {
  presets: [
        useBuiltIns: 'entry',


Used by the vue-cli-service for the Vue browser and server bundles

  "compilerOptions": {
    "target": "es6",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "module": "commonjs",
    "rootDir": "./",
    "outDir": "build",
    "strict": true,
    "jsx": "preserve",
    "importHelpers": true,
    "moduleResolution": "node",
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "sourceMap": true,
    "baseUrl": ".",
    "typeRoots": [
    "types": [
    "paths": {
      "@/*": [
    "lib": [
  "include": [
  "exclude": [

Used for the Node app build.

Inherits all the config from tsconfig.json (and changes the entry point to the Node app start file).

  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./",
  "include": [


When we bundle the Vue app, we create 2 bundles

client.js - executes in the browser

server.js - executes in Node

We do this by running the build command twice but with SSR=1 set as an environment variable on the second build.

When SSR=1 is set:

  • the app entry point changes from entry.client.ts to entry.server.ts
  • SSR specifc steps are added to the build process such as disabling plugins that dont need to run in Node
  • An ssr-manifest.json file is created allowing the Node app to find the outputted JS bundle entry point on the server
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  outputDir: './dist/client',
  publicPath: '/',
  runtimeCompiler: true,
  devServer: {
    https: false,
    port: 8080,
    writeToDisk: true
  chainWebpack: (config) => {

    if (!process.env.SSR) {

    config.externals(nodeExternals({ allowlist: /\.(css|vue)$/ }));

    config.plugin('manifest').use(new WebpackManifestPlugin({ fileName: 'ssr-manifest.json' }));



Creating the view

  • public/index.html
  • src/components/App/App.vue
  • src/components/Page/HomePage.vue

For a basic Vue application with routing, all we need is the main App component and a page component


After the Vue server bundle has been executed by the Node app, we have access to the Vue app and state.

We inject values from these into the Vue client bundle index.html through the Node app

  • <meta name="meta"> is replaced with the rendered meta HTML
  • <div id="app"></div> is replaced with the rendered HTML from the Vue app
  • <div id="state"></div> is replaced by a JavaScript tag with the state JSON

The updated index.html is returned as the response with the Vue application server side rendered (SSR), and the initial state available to hydrate the store client side.

<!DOCTYPE html>
<html lang="en">
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
    <meta name="meta">
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    <div id="app"></div>
    <div id="state"></div>
    <!-- built files will be auto injected -->


  <div class="app">
    <PageMetaTeleport />
    <router-view />

<script lang="ts">
import { Vue } from "vue-class-component";

export default class App extends Vue {}



<script lang="ts">
import { Vue } from "vue-class-component";

export default class HomePage extends Vue {}

Creating the page Meta components

  • src/interfaces/index.ts
  • src/components/Page/PageMeta.vue
  • src/components/Page/PageMetaTeleport.vue

The PageMeta component is used twice in the page rendering lifecycle

Server side

During SSR, the Vue PageMeta and App components are rendered separately.

Both are injected into the index.html template and the rendered page is returned.

The meta tags rendered inside the PageMeta component during SSR have an attribute ssr="true"

Client side

On the client side a PageMeta component is wrapped in a <teleport element> and is teleported into the <head> when the page loads

At the same time any tags rendered that have ssr="true" are removed from the <head> stopping the tags being duplicated

A more elegant solution to this would be to perform the client actions on the first page navigation instead of page load


export interface HeadMeta {
    title: string;
    meta: Array<MetaTag>;
    links: Array<MetaTag>;

export interface MetaTag {
    ssr?: boolean;
    [key: string]: string | number | boolean | undefined;


  <title v-bind="attributes({})">
      {{ head.title }}
      v-for="(meta, i) in head.meta"
      v-for="(link, i) in head.links"

<script lang="ts">
import { Vue } from "vue-class-component";
import { isSSR } from "@/helpers";
import { HeadMeta, MetaTag } from "@/interfaces";

export default class PageMeta extends Vue {
    attributes(attributes: MetaTag): MetaTag {
        return {
            ...(isSSR() && { ssr: true }),

    mounted() {
        this.$nextTick(() => {
            ).forEach((node: Element) => {
                if (node.getAttribute("ssr")) {

    get head(): HeadMeta {
        return {
            title: 'SSR title',
            meta: [
                    name: "description",
                    content: 'SSR description'
            links: [
                    rel: "canonical",
                    href: 'https://...'


    <teleport to="head">
        <PageMeta />

<script lang="ts">
import { Vue } from "vue-class-component";

export default class PageMetaTeleport extends Vue {}

Creating the store

  • src/store/app.ts
  • src/store/index.ts

You can create a basic store using the composition API

Using a simple check - isSSR (if !window), the store can react differently client and server side

browser - The store looks for window.STATE that has been injected via SSR, and hydrates the store with that data if its found

server - The store loads the default store data


import { Store } from "@/store";

export interface AppState extends Object {
    fetching: boolean

class AppStore extends Store<AppState> {
    protected hydrate(state: object): any {
        return (state as any).app ||;

    protected data(): AppState {
        return {
            fetching: false,

    set fetching(fetching: boolean) {
        this.state.fetching = fetching;
export const appStore: AppStore = new AppStore()


import { reactive, readonly } from 'vue';
import { isSSR } from '@/helpers'

export abstract class Store<T extends Object> {
    protected state: T;

    constructor() {
        let data = isSSR()
            : this.hydrate((window as any).__STATE__ || {});

        this.state = reactive(data) as T;

    protected abstract hydrate(state: object): T

    protected abstract data(): T

    public getState(): T {
        return readonly(this.state) as T

Creating the router

  • src/routing/router.ts
  • src/routing/routes.ts


The router does a simple check isSSR (if !window), and changes the history mode from web to memory if true

import { createMemoryHistory, createWebHistory, createRouter } from "vue-router";
import { isSSR } from '@/helpers'
import { routes } from './routes'

export function createApplicationRouter(): any {
    const router = createRouter({
        history: isSSR() ? createMemoryHistory() : createWebHistory(),
    return router;


export const routes = [
        path: '/',
        name: 'home',
        component: () => import(/* webpackChunkName: "Home" */ '../components/Page/HomePage.vue')

Creating the applications

  • src/helpers/index.ts
  • src/app.ts
  • src/entry.client.ts
  • src/entry.server.ts

The two entry files are used to create the Vue client and server bundles

Both import a function to create a new Vue app from app.ts

To avoid sharing state in a Node environment across requests, app.ts exports a function to create a new Vue app instance


A simple check to determine if the app is running in a server environment is to check if the window object is undefined (as this will only be set in the browser).

export function isSSR(): boolean {
    return typeof window === 'undefined'


import { createApp, createSSRApp } from 'vue'
import { Router } from 'vue-router'
import { createApplicationRouter } from './routing/router'
import { isSSR } from "@/helpers";
import App from './components/App/App.vue'
import PageMeta from "./components/Page/PageMeta.vue";
import PageMetaTeleport from "./components/Page/PageMetaTeleport.vue";

export function createApplication() {
    const app = isSSR() ? createSSRApp(App) : createApp(App);

    const router: Router = createApplicationRouter();


    app.component('PageMeta', PageMeta)
    app.component('PageMetaTeleport', PageMetaTeleport)

    return { app, router };


The client JS bundle is executed in the browser, and attaches the created Vue app to the DOM

import { createApplication } from './app'

const { app, router } = createApplication();

(async (r, a) => {
    await r.isReady();
    a.mount('#app', true);
})(router, app);


The server JS bundle is executed on the server in the Node environment and returns the Vue app instance, state, and router to the Node app.

The Node app injects the rendered Vue app HTML, meta HTML, and state into the index.html template to create a fully rendered page, and then returns it as a response.

import { createApplication } from './app'
import { appStore } from './store/app'

export default (context: any) => {
  return new Promise((resolve, reject) => {
    const { app, router } = createApplication()

      .then(() => {
        const matchedComponents = router.currentRoute.value.matched;
        if (!matchedComponents.length) {
          return reject(new Error('404'));
        const state = {
          app: appStore.getState()
        return resolve({ app, router, state });
      }).catch(() => reject);

The Vue app, state, and router are returned to the Node app, but you could also return other objects here as well.

Creating the server

  • app.ts
  • app/index.ts
  • app/context.ts
  • app/core/bundle.ts
  • app/core/middleware.ts
  • app/core/renderer.ts
  • app/core/shutdown.ts
  • app/typings/index.ts

Express is an unopinionated framework allowing you a large degree of control about how you build and structure your Node apps.

For the purposes here, the Node app has been broken down into small files to make it a bit easier to explain

To create a server we create an entry point (app.ts) for our Node environment

Inside the entry point we will set some context data and create an Express Node app that will listen for requests and serve an SSR response from the Vue app


import app from './app/index'
import context from './app/context'
import { SSR } from './app/typings';

const ctx: SSR.Context = context(<SSR.Config>{
  port: process.env.PORT || 8080,
  root: __dirname,
  https: process.env.HTTPS === 'true',
  reload: process.env.RELOAD === 'true',
  template: 'index.html',
  manifest: 'ssr-manifest.json',
  entry: {
    dist: 'dist',
    client: 'client',
    server: 'server',



Here is where the different components are brought together to create the Express app, start listening for connections, and return a response.

import express, { Express, Request, Response } from 'express';
import compression from 'compression';
import middleware from './core/middleware'
import renderer from './core/renderer'
import shutdown from './core/shutdown'
import { SSR } from './typings';

export default <SSR.Scoped>function (ctx: SSR.Context): void {
    const { assets, https, reload } = middleware(ctx);

    const app: Express = express();


    const { config } = ctx;

    config.https && app.use(https());
    config.reload && app.use(reload());

    app.get('*', async (req: Request, res: Response) => {
        await renderer(ctx).response(req, res);
    app.listen(config.port, () =>
        console.log(`Server started at localhost:${config.port}`)


Returns a context data object available to the Node app.

The context data consists of the entry point config data and some path functions.

import path from 'path';
import { SSR } from './typings';

export default function (config: SSR.Config): SSR.Context {
    const directories: SSR.Directories = {
        dist: () => path.join(config.root, config.entry.dist),

        client: () => path.join(directories.dist(), config.entry.client),

        server: () => path.join(directories.dist(), config.entry.server),
    const paths: SSR.Paths = {
        template: () => path.join(directories.client(), config.template),

        manifest: () => path.join(directories.server(), config.manifest)
    return { config, directories, paths }


Returns the Vue app SSR manifest and server bundle that is executed during SSR

import path from 'path';
import { Request } from 'express';
import { SSR } from '../typings';

export default <SSR.Scoped>function ({ config, directories, paths }: SSR.Context): SSR.Bundle {
    const bundle: SSR.Bundle = {
        manifest: () => require(paths.manifest()),

        path: () => path.join(directories.server(), bundle.manifest()[`${config.entry.server}.js`]),

        entry: async (req: Request): Promise<SSR.BundleContext> => await require(bundle.path()).default(req),
    return bundle


Returns the Node app middeware

import express, { Request, Response } from 'express';
import { SSR } from '../typings';

export default <SSR.Scoped>function ({ directories }: SSR.Context): SSR.Middleware {
    return {
        assets: () => express.static(directories.client(), { index: false }),

        https: () => (req: Request, res: Response, next: () => any): void => {
            ! ? res.redirect(`https://${}${req.url}`) : next();

        reload: () => {
            const livereload = require("livereload");

            const server = livereload.createServer();
            server.server.once("connection", () => {
                setTimeout(() => {
                }, 100);
            return require('connect-livereload')({
                port: 35729


Converts the context returned from the server entry point (Vue app, state) to the data used in the index.html template (app HTML, meta HTML, state JSON) and renders the SSR response.

The renderToString function must be called before the state is encoded otherwise the state will be the default state and not the final app state.

You can find the PageMeta component as its registered on the app context. The PageMeta component is converted to a VNode using the h function before being rendered.

When renderToString is called on the PageMeta component props can be passed (in this case a page object, but you could also use the current state etc)

import { Request, Response } from 'express';
import { renderToString } from '@vue/server-renderer';
import fs from 'fs';
import { h } from 'vue';
import bundle from './bundle'
import { SSR } from '../typings';

export default <SSR.Scoped>function (ctx: SSR.Context): SSR.Renderer {
    const renderer: SSR.Renderer = {
        response: async (req: Request, res: Response) => {
            const context: SSR.BundleContext = await bundle(ctx).entry(req);
            const content: SSR.OutputContext = await renderer.context(context);

            if (fs.existsSync(ctx.paths.template())) {
                fs.readFile(ctx.paths.template(), (err: NodeJS.ErrnoException | null, template: Buffer) => {
                    if (err) {
                        throw err;
                    res.setHeader('Content-Type', 'text/html');
                    res.send(renderer.hydrate(template, content));
        context: async ({ app, state }: SSR.BundleContext): Promise<SSR.OutputContext> => {
            return {
                app: await renderToString(app),
                state: JSON.stringify(state),
                meta: await renderToString(
                    h(app._context.components.PageMeta as any)
        hydrate: (template: Buffer, { app, meta, state }: SSR.OutputContext) => {
            return template.toString()
                .replace('<meta name="meta">', meta)
                .replace('<div id="app"></div>', `<div id="app">${app}</div>`)
                .replace('<div id="state"></div>', `<script>window.__STATE__ = ${state}</script>`)
    return renderer


Handles errors and kills any lingering processes. Memory issues and other issues can occur if Node apps are not terminated correctly

import { SSR } from '../typings';

const SIGNALS: Array<string> = [

export default function(): void {
    const handler: SSR.ExitHandler = (options: Record<string, boolean>, code: number): void => {
        options.clean && console.log(code);
        options.exit && process.exit();
    SIGNALS.forEach((code) => process.on(code, handler.bind(null, { exit: true })));

    process.on('exit', handler.bind(null, { clean: true }));


The type definition for the components of our Node app

import { RequestHandler, Request, Response } from 'express';
import { App } from 'vue';

export declare namespace SSR {
   * Denotes a function scoped to, and with access to the SSR context
  type Scoped = (ctx: Context) => any;

   * The context data used during SSR 
  interface Context {
    config: Config,
    directories: Directories,
    paths: Paths

   * Base config data for the SSR context
  interface Config {
    port: string | number,
    root: string,
    https: boolean,
    reload: boolean;
    template: string,
    manifest: string,
    entry: EntryPoints,

   * Entry point config for the dist, server, and client folders for the SSR context
  interface EntryPoints {
    dist: string,
    client: string,
    server: string,

  interface Directories {
     * Returns the full system path to the [dist]/ folder
    dist: () => string,
     * Returns the full system path to the [dist]/[client]/ folder
    client: () => string,
     * Returns the full system path to the [dist]/[server]/ folder
    server: () => string,

  interface Paths {
     * Returns the full system path to the [dist]/[client]/.../index.html file
    template: () => string,
     * Returns the full system path to the [dist]/[server]/.../ssr-manifest.json file
    manifest: () => string,

   * The app instance and app state are returned as the bundle context after
   * the app is invoked
  interface BundleContext {
    app: App,
    state: Record<string, any>

   * The output context which is passed to the index.html template is the
   * rendered app HTML and the encoded JSON state
  interface OutputContext {
    app: string,
    state: string
    meta: string

   * Object representation of the server js bundle
   * The server js bundle is executed by node and its output captured to create 
   * the HTML output with the rendered app and initial state
  interface Bundle {
     * Returns the [dist]/[server]/.../ssr-manifest.json file
     * This file contains a mapping of non hashed file names to hashed file names
     * Using this file you can find the entry point server.[hash].js file
    manifest: () => Record<any, any>
     * Returns the [dist]/[server]/.../server.[hash].js file
    path: () => string,
     * Executes the server bundle and returns the app and state context
    entry: (req: Request) => Promise<BundleContext>,

  interface Renderer {
     * Creates and returns the SSR response data promise
    response: (req: Request, res: Response) => Promise<void>,
     * Converts the server app and state context data into the template context data
    context: (context: BundleContext) => Promise<OutputContext>,
     * Renders the app HTML and the state JSON into the template
    hydrate: (template: Buffer, context: OutputContext) => string

  interface Middleware {
     * Set the server to serve the static public files while ignoring index.html
    assets: () => RequestHandler<Response<any, Record<string, any>>>,
     * Enable / disable https
    https: () => (req: Request, res: Response, next: () => any) => void,
     * Creates the live reload server watches for file changes
     * Connect live reload injects a script into the page HTML to listen for changes and reloads the page
    reload: () => any,

   * Gracefully handles the exiting of the server application
  type ExitHandler = (options: Record<string, boolean>, code: number) => void

Running the application

  • package.json

Add the following commands can be added to the package.json file


    "clean": "rm -rf ./dist",
    "bundle": "npm run clean && npm run bundle:client && npm run bundle:server",
    "bundle:client": "vue-cli-service build",
    "bundle:server": "SSR=1 vue-cli-service build --dest dist/server"

There are 2 separate commands to create the Vue client and server bundles which can both be ran together with the bundle command.

npm run bundle

Outputs the following:

  • dist/client/.... - The Vue client bundle
  • dist/server/.... - The Vue server bundle
    "bundle:app": "tsc --project",
    "bundle:production": "npm run bundle:app && npm run bundle:client && npm run bundle:server"

bundle:app - Creates the JS bundle for the Node app in the directory root (or another place if required)

npm run bundle:app

Outputs the following:

  • app.js - The Node app entry point
  • app/.... - The Node app bundle

bundle:production - Creates the Node app, Vue client and server bundles.

npm run bundle:production

Outputs the following:

  • app.js - The Node app entry point
  • app/.... - The Node app bundle
  • dist/client/.... - The Vue client bundle
  • dist/server/.... - The Vue server bundle


    "serve": "vue-cli-service serve",
    "serve:ssr": "cross-env NODE_ENV=development nodemon --ignore dist/ -e ejs,js,ts,css,html,jpg,png,scss app.ts",
    "serve:refresh": "npm run bundle && npm run serve:ssr"

serve - Starts the web browser, listens for Vue app file changes, and serves the app to the browser

npm run serve

serve:ssr - Starts the node server, listens for Node app file changes, and serves the SSR rendered app to the browser

npm run serve:ssr

You must run bundle first before serve:ssr as the client and server bundles must be available to the Node app

serve:refresh - Creates the client and server bundles and serves the SSR app


To test if the page is rendered via SSR, you can check the response data (which should be the index.html template)

The Vue App component HTML should replace the app placeholder

<div id="app"></div>
// to
<div id="app">

The Vue PageMeta component HTML should replace the meta placeholder

<meta name="meta">
// to
<meta name="description" content="SSR description">

The state JSON wrapped in a JS tag should replace the state placeholder

<div id="state"></div>
// to
<script>window.__STATE__ = { ... }</script>

And there you have it. SSR with Vue 3 and Node.