skip to content

When hand-rolling Vue 3 SSR on a small Node server, how do you split the code into universal, server and client entries?

level: middleimportance: must knowfreq 50%

answer

  1. one factory, two entries
  2. per-request app on the server
  3. HTML shell around the string
  4. restore state, then mount

basics

~20 s

A universal module exports a factory that calls createSSRApp. The server entry calls it per request, renders with renderToString and wraps the HTML in a page shell with state and the client script. The client entry calls it once, restores state and mounts.

solid answer

~40 s

Vue's SSR guide calls code shared by both sides **universal code**. Put app creation in a factory, for example `createApp()` in `app.ts`, that calls `createSSRApp`, installs plugins and creates per-request state. The **server entry** handles each request: it calls the factory, awaits `renderToString(app, ctx)` from `vue/server-renderer`, and writes a full HTML page with the result inside the mount container, the serialized state, any `ctx.teleports`, and a script tag for the client bundle. The **client entry** calls the same factory once, restores the serialized state and calls `app.mount('#app')`, which hydrates because the app came from `createSSRApp`. The app is built twice: the server build compiles templates into string-concatenating render functions and the client build produces the normal bundle.

code

ts · 16 lines
ts
// app.ts: universal factory
import { createSSRApp, reactive } from 'vue'
import App from './App.vue'

export function createApp() {
  const app = createSSRApp(App)
  const state = reactive({ user: null as null | { name: string } })
  app.provide('state', state)
  return { app, state }
}

// client.ts: browser entry
// import { createApp } from './app'
// const { app, state } = createApp()
// Object.assign(state, (window as any).__STATE__)
// app.mount('#app')

go deeper

for a junior

Know the three pieces: a shared factory with createSSRApp, a server entry calling renderToString, and a client entry calling mount().

for a middle

Walk through a request end to end: new app, render with a context, page shell with state and scripts, client restores state before hydrating.

for a senior

Harden the split: per-request apps, escaped state serialization, error pages when rendering fails, matching mount selectors, and no browser-only imports in universal code.

for a principal

Judge how much of this wiring the team should own versus delegate to a framework, given the build, routing and asset-linking work it implies.

## The three pieces A Vue 3 SSR app without a meta-framework has three kinds of module: | Module | Runs on | Job | |---|---|---| | Universal app factory (`app.ts`) | both | create the app with `createSSRApp`, install plugins, create per-request state | | Server entry (`server.ts`) | Node only | per request: create the app, render it, send the full HTML page | | Client entry (`client.ts`) | browser only | create the app once, restore state, mount to hydrate | Everything the factory imports, components included, is **universal code** and must be safe to run on both sides. ## The universal factory The factory is a function rather than a module-level app so that the server can create a fresh app for every request. It should: - call `createSSRApp(App)` so the client's `mount()` hydrates; - install plugins and create per-request state (and a router, if you use one) inside the function; - return the app plus anything the entries need, such as the state object to serialize. ## The server entry For each request the handler: 1. Calls the factory to get a new app and its state. 2. Awaits `renderToString(app, ctx)`, passing a fresh SSR context object `ctx`. 3. Builds the page shell: `<!DOCTYPE html>`, the head, the rendered string inside the mount container (for example `<div id="app">`), any `ctx.teleports` in their own containers, a script with the serialized state, and a module script for the client bundle. 4. Sets the status and headers and sends the page, or sends an error page if rendering failed. Serialize state with escaping: `JSON.stringify` alone lets a `</script>` inside user data close the tag early. ## What the page shell must contain - A doctype and a `<head>` with the stylesheet and preload links produced by the client build. - The mount container holding exactly the rendered string, so hydration sees the DOM the server produced. - Containers for teleported content, filled from `ctx.teleports`. - The escaped state script, placed before the client bundle so the entry can read it. - The client entry as a module script, which loads the same components and hydrates them. ## The client entry 1. Call the same factory once. 2. Copy the serialized state into the app's state before mounting, so the first client render matches the HTML. 3. Call `app.mount('#app')` with the same selector the server wrapped the HTML in. ## Two builds of the same code The guide notes that a production setup coordinates **two builds**: one for the client and one for the server. Components are compiled differently for SSR: templates become render functions that concatenate strings instead of creating virtual DOM, which is faster on the server. The server build also has to know which client assets to link from the shell. That build wiring belongs to your bundler. ## A minimal server entry ```ts import { createServer } from 'node:http' import { renderToString } from 'vue/server-renderer' import { createApp } from './app' createServer(async (req, res) => { const { app, state } = createApp() const ctx: Record<string, any> = {} try { const html = await renderToString(app, ctx) res.setHeader('Content-Type', 'text/html; charset=utf-8') res.end(`<!DOCTYPE html><html><body><div id="app">${html}</div>` + `<script>window.__STATE__=${JSON.stringify(state).replace(/</g, '\\u003c')}</script>` + `<script type="module" src="/client.js"></script></body></html>`) } catch (err) { res.statusCode = 500 res.end('Server error') } }).listen(3000) ``` ## Common mistakes in the split - Creating the app once at module scope in the server entry, so requests share app-level state. - Calling `createApp` from `vue` in the client entry, which wipes the server HTML instead of hydrating. - A mount selector that does not match the container the server used. - Forgetting to serve the client bundle, so the page renders but never becomes interactive. - Importing browser-only modules from the factory, which crashes the server build at import time.

  • Why must the Vue client entry restore serialized state before calling mount(), not after?
    `mount()` on an SSR app hydrates immediately, running the first client render against the server DOM. If the state is still empty at that moment, that render differs from the HTML and Vue has to repair the DOM, then re-render again when the state arrives. Restoring first makes the first client render identical to the server's.
  • Why does a Vue SSR setup need a separate server build instead of running the client bundle in Node?
    For SSR, Vue compiles templates into render functions that concatenate strings rather than build virtual DOM, which is faster for producing HTML. The server bundle must also run in Node, and it needs to know the client bundle's asset URLs to link them from the page shell. The client build stays a normal browser bundle.
  • What should a hand-rolled Vue SSR handler do when renderToString rejects?
    Catch it and send a complete error response, typically a 500 with a static error page, because nothing has been sent yet. Errors thrown inside components go through Vue's error handling first, `app.config.errorHandler` included, so decide there which errors should fail the whole page rather than be logged.

saying these in an interview costs you the question

  • One app instance created when the server starts can render every request.
  • The client entry should call createApp because the server already built the DOM.
  • The same bundle can be used unchanged on the server and in the browser.
  • JSON.stringify output can be embedded in a script tag without escaping.
  • State can be restored after mount() because hydration waits for it.