Sun May 16 20215,688 words
The following guide to Vue 3 server side rendering. Follow the guide to create all the required files or have a look at the example repository
A Vue app can run on the client side or server side. Server side rendering in Vue can be achieved by bundling a Vue app, executing and rendering it in a Node server side environment using a framework like Express, and then returning the Vue server side response HTML and rendering it in the browser.
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 package provides the API to convert a Vue App instance into its HTML output string. This Vue server rendered output can then be sent to the client side.
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 server side rendering in Vue.
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
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).
Node can be installed on your local machine directly from the Node website.
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 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
We need 2 tsconfig.json files (the other being tsconfig.app.ts). One for the Vue app build and one for the Node app build.
module.exports = {
presets: [
[
'@vue/app',
{
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": [
"node_modules/@types",
"src/types"
],
"types": [
"webpack-env",
"node"
],
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules"
]
}
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": [
"app.ts"
]
}
When we bundle the Vue app, we create 2 bundles for vue server side rendering
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:
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) => {
config.entryPoints.delete('app')
if (!process.env.SSR) {
config.entry('client').add('./src/entry.client.ts');
return
}
config.entry('server').add('./src/entry.server.ts');
config.target('node');
config.output.libraryTarget('commonjs2');
config.externals(nodeExternals({ allowlist: /\.(css|vue)$/ }));
config.plugin('manifest').use(new WebpackManifestPlugin({ fileName: 'ssr-manifest.json' }));
config.optimization.splitChunks(false).minimize(false);
config.plugins.delete('hmr');
config.plugins.delete('html');
config.plugins.delete('preload');
config.plugins.delete('prefetch');
config.plugins.delete('progress');
config.plugins.delete('friendly-errors');
config.plugins.delete('mini-css-extract-plugin');
}
}
For a basic server side rendered Vue application with routing, all we need is the main App component and a page component
After the Vue server bundle has been executed and rendered server side 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
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">
<head>
<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">
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<div id="state"></div>
<!-- built files will be auto injected -->
</body>
</html>
<template>
<div class="app">
<PageMetaTeleport />
<router-view />
</div>
</template>
<script lang="ts">
import { Vue } from "vue-class-component";
export default class App extends Vue {}
</script>
<template>
<h1>SSR</h1>
</template>
<script lang="ts">
import { Vue } from "vue-class-component";
export default class HomePage extends Vue {}
</script>
The PageMeta component is used twice in the page server side rendering lifecycle
Server side
During Vue 3 server side rendering, 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 Vue server side rendering 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;
}
<template>
<title v-bind="attributes({})">
{{ head.title }}
</title>
<meta
v-for="(meta, i) in head.meta"
:key="`meta-${i}`"
v-bind="attributes(meta)"
>
<link
v-for="(link, i) in head.links"
:key="`link-${i}`"
v-bind="attributes(link)"
>
</template>
<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 }),
...attributes
};
}
mounted() {
this.$nextTick(() => {
Array.from(
document.getElementsByTagName("head")[0].children
).forEach((node: Element) => {
if (node.getAttribute("ssr")) {
node.remove();
}
});
});
}
get head(): HeadMeta {
return {
title: 'SSR title',
meta: [
{
name: "description",
content: 'SSR description'
},
],
links: [
{
rel: "canonical",
href: 'https://...'
}
]
};
}
}
</script>
<template>
<teleport to="head">
<PageMeta />
</teleport>
</template>
<script lang="ts">
import { Vue } from "vue-class-component";
export default class PageMetaTeleport extends Vue {}
</script>
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 Vue server side rendering, 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 || this.data();
}
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.data()
: 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
}
}
The router does a simple check isSSR (if !window), and changes the history mode from web to memory if the app is executing during Vue server side rendering.
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(),
routes
});
return router;
}
export const routes = [
{
path: '/',
name: 'home',
component: () => import(/* webpackChunkName: "Home" */ '../components/Page/HomePage.vue')
},
]
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.use(router)
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 side rendering the Vue 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()
router.push(context.url)
router.isReady()
.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.
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',
}
});
app(ctx);
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();
app.use(compression())
app.use(assets());
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}`)
);
shutdown();
}
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 Vue 3 server side rendering
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 => {
!req.secure ? res.redirect(`https://${req.headers.host}${req.url}`) : next();
},
reload: () => {
const livereload = require("livereload");
const server = livereload.createServer();
server.watch(directories.dist());
server.server.once("connection", () => {
setTimeout(() => {
server.refresh("/");
}, 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> = [
'SIGINT',
'SIGUR1',
'SIGUR2',
'SIGTERM',
'uncaughtException',
];
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
}
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 server side rendered Vue JS bundle, and the client bundle. These can both be ran together with the bundle command.
npm run bundle
Outputs the following:
{
"bundle:app": "tsc --project tsconfig.app.json",
"bundle:production": "npm run bundle:app && npm run bundle:client && npm run bundle:server"
}
bundle:app - Creates the server JS bundle for the Node app in the directory root (or another place if required)
npm run bundle:app
Outputs the following:
bundle:production - Creates the Node app, Vue client and server bundles.
npm run bundle:production
Outputs the following:
{
"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 server side rendered Vue 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 server side rendered Vue app
To test if the page is server side rendered, 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">
<div>...</div>
</div>
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.. a guide to server side rendering with Vue JS and Express / Node.