skip to content

In a Vue 3 SSR app, what is the difference between createSSRApp and createApp, and which one must the browser entry call?

level: juniorimportance: must knowfreq 58%

answer

  1. same arguments, different mount
  2. hydrate versus replace
  3. container content on mount
  4. shared factory uses one

basics

~20 s

createSSRApp creates an app whose mount() hydrates the server-rendered DOM in the container; createApp's mount() clears the container and renders from scratch. The browser entry of an SSR app must use createSSRApp, usually through the shared app factory.

solid answer

~40 s

Both are exported from `vue` and take the same arguments; the docs say `createSSRApp` usage is exactly the same as `createApp`. The difference is what `mount()` does in the browser. An app from `createApp` clears the container (`textContent = ''`) and mounts fresh DOM, so server HTML is thrown away and rebuilt. An app from `createSSRApp` uses Vue's hydration renderer: `mount()` walks the existing DOM, attaches event listeners and adopts the nodes, repairing any mismatches. If the container is empty it warns and performs a full mount instead. On the server, `renderToString` accepts either, but the universal factory calls `createSSRApp` so the same code hydrates on the client.

code

ts · 7 lines
ts
// client.ts: browser entry of a Vue SSR app
import { createSSRApp } from 'vue'
import App from './App.vue'

// createApp(App).mount('#app') would wipe the server HTML and rebuild it.
// createSSRApp hydrates: it adopts the existing nodes and attaches listeners.
createSSRApp(App).mount('#app')

go deeper

for a junior

Remember the pairing: createSSRApp in the shared factory, mount() in the browser hydrates; createApp's mount() replaces whatever is in the container.

for a middle

Explain what hydration does on mount, adopting nodes and attaching listeners, and what the empty-container warning means when you see it.

for a senior

Recognise the silent failure of calling createApp on the client, measure it as needless DOM work and flicker, and enforce a single universal factory.

for a principal

Treat one universal app factory as the contract between server and client entries, so both sides cannot drift in plugins, state or root component.

## Two ways to create a Vue app Vue 3 exports two application factories from the `vue` package: - `createApp(rootComponent, rootProps?)`: the normal client-side app. - `createSSRApp(rootComponent, rootProps?)`: an app in **SSR hydration mode**. The API reference is explicit that `createSSRApp` usage is exactly the same as `createApp()`: same arguments, same `app.use`, `app.provide`, `app.component` and `app.config`. The difference only appears when you call `app.mount(container)` in the browser. ## What mount() does in each case | | `createApp(...).mount('#app')` | `createSSRApp(...).mount('#app')` | |---|---|---| | Existing content in the container | removed (`container.textContent = ''`) | kept and adopted | | DOM creation | builds every node from scratch | reuses server-rendered nodes | | Event listeners | attached to the new nodes | attached to the existing nodes | | Mismatch between server HTML and client render | not applicable | existing nodes are morphed to match | | Empty container | normal mount | warning, then a full mount | With `createSSRApp`, Vue lazily switches to its **hydration renderer**. Mounting then means walking the existing DOM alongside the component tree, matching each vnode to the node already there, attaching listeners, and running client-side hooks such as `onMounted`. That process is **hydration**. If the container has no children, the hydration renderer logs `Attempting to hydrate existing markup but container is empty. Performing full mount instead.` and mounts normally. Seeing that warning usually means the server HTML never reached the container: wrong selector, a shell that forgot to insert the rendered string, or a client-only route. ## Why using createApp in the browser is a bug Nothing throws, which is what makes it easy to miss: 1. The server renders full HTML and the browser paints it. 2. The client entry calls `createApp(App).mount('#app')`. 3. Vue empties the container and rebuilds the same DOM from scratch. The result is wasted work on the client, possible visual flicker, lost focus or scroll inside the replaced nodes, and none of the benefits of reusing the server's DOM. ## Which side calls which The usual structure is one **universal** factory, imported by both entries: ```ts import { createSSRApp } from 'vue' import App from './App.vue' export function createApp() { const app = createSSRApp(App) // register plugins, provide per-request state here return { app } } ``` - The **server entry** calls it per request and passes the app to `renderToString` or a stream renderer from `vue/server-renderer`. Those functions accept any app instance, but using the same factory keeps both sides identical. - The **client entry** calls it once and runs `app.mount('#app')`, which hydrates because the app came from `createSSRApp`. ## What does not change Choosing `createSSRApp` changes how the first mount treats existing DOM, and nothing else: - Components, plugins, `provide`/`inject` and `app.config` behave exactly as in a client-only app. - After hydration, updates are ordinary client-side patches; the app does not stay in a special mode. - `onMounted` still runs in the browser once the component's DOM, here the adopted server DOM, is in place. - Components still have to be SSR-safe: `createSSRApp` does not make browser-only code safe to run on the server. Internally the hydration-capable renderer is created lazily, the first time `createSSRApp` is called, so a purely client-side app never sets it up. ## Rules that still apply - `mount()` can only be called once per app instance, on either kind of app. - The selector passed to `mount()` must match the element the server wrapped the rendered HTML in. - The server and client must render the same component tree with the same state, or hydration has to repair the difference. - In development both factories add the same checks on the app config; the SSR variant changes only how mounting treats existing DOM.

  • In the browser, a Vue app created with createSSRApp logs 'Attempting to hydrate existing markup but container is empty.' What does that tell you?
    Hydration found no child nodes in the mount container, so Vue fell back to a full client mount. The server HTML did not reach that element: the shell may not insert the rendered string, the selector may differ from the wrapping element's id, or the page was served without SSR. The page still works, but you are paying for SSR without using it.
  • Can the Vue server entry pass an app made with createApp to renderToString?
    Yes. `renderToString` renders any app instance to a string; hydration mode only matters when `mount()` runs in the browser. Teams still build both sides from one factory that calls `createSSRApp`, because the client must hydrate and keeping a single code path avoids the two sides drifting apart.

saying these in an interview costs you the question

  • createApp detects server HTML in the container and hydrates it automatically.
  • createSSRApp is imported from vue/server-renderer, not from vue.
  • createSSRApp renders to a string on its own, without renderToString.
  • Mounting an SSR app into an empty container throws an error.
  • createSSRApp must never be called in the browser.