Skip to content

Nuxt and server-side rendering

An SPA first sends the browser an almost empty page and builds the content with JavaScript. With server-side rendering, SSR for short, the server already renders the same components to HTML. The browser shows the content right away, and then the same app takes over the page in the browser. That step is called hydration. Vue ships both parts. createSSRApp creates an app for this interplay, renderToString turns it into HTML on the server, and mount takes that HTML over in the browser. Nuxt is a framework on top of Vue that sets up SSR, routing and data loading for whole projects.

SSR pays off when content has to show up quickly or search engines should find it, for example in a public catalog or a blog. It comes at a cost, though. The app needs a server that runs Node instead of one that only delivers files, and code that runs in the browser must also be able to run on the server. For an app behind a sign-in, SSR adds little. If you only want some pages ready in advance, you can render them as static pages during the build.

The example under optional/ssr/en renders the catalog with Vue itself, without Nuxt. app.js creates the app with createSSRApp. The function creates a new app on every call, because on the server all requests share one process, and the state of one request must never end up in another.

app.js
// The same app for the server and for the browser. Every call creates a new app, so the state of
// one request never reaches another.
import { createSSRApp, ref } from 'vue'
export function createApp(things) {
return createSSRApp({
setup() {
const saved = ref(0)
return { things, saved }
},
template: `
<h1>Catalog</h1>
<p>Saved things {{ saved }}</p>
<ul>
<li v-for="thing in things" :key="thing.id">
{{ thing.name }}
<button type="button" @click="saved++">Save</button>
</li>
</ul>
`,
})
}

renderToString turns the app into a string of HTML. render.js puts it into a whole page, hands the data to the browser and loads the script that takes over the page. The comments <!--[--> in the HTML mark fragments that hydration relies on.

render.js
import { renderToString } from 'vue/server-renderer'
import { createApp } from './app.js'
// The whole page with the HTML of the app, the data for the browser and the script that takes over.
// \u003c keeps a name with </script> from ending the data block early.
export async function renderPage(things) {
const html = await renderToString(createApp(things))
const data = JSON.stringify(things).replaceAll('<', '\\u003c')
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Catalog</title>
<script type="importmap">{ "imports": { "vue": "/vue.js" } }</script>
</head>
<body>
<div id="app">${html}</div>
<script type="application/json" id="things">${data}</script>
<script type="module" src="/client.js"></script>
</body>
</html>`
}

In the browser, client.js calls the same function. Since the app comes from createSSRApp, mount does not draw the page again but attaches the event listeners to the existing HTML. Only then does the button "Save" react.

client.js
// In the browser the same app takes over the HTML of the server instead of drawing it again
import { createApp } from './app.js'
const things = JSON.parse(document.getElementById('things').textContent)
createApp(things).mount('#app')

With npm start, server.js serves the page at http://localhost:3002. The page source already contains the list in its HTML before any JavaScript has run. If the server delivers different HTML than the app would render in the browser, Vue reports a mismatch during hydration. That happens, for example, when a component uses the current time or a random number while rendering.

Nuxt sets all of this up for a whole project. The current version is Nuxt 4, and you create a new project with npm create nuxt@latest. Pages are components in the folder app/pages, and Nuxt turns the file names into routes instead of you listing them by hand. Nuxt imports components and composables automatically. useFetch loads data on the server and hands it to the page, so the browser does not fetch it a second time, and files in the folder server/api become routes of an API. Whether a page is rendered on the server, only in the browser or already during the build is something you decide per route. The Nuxt dev server uses port 3000, just like the course API, so don't start both on the same port.

Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.