vite-plugin-ssr has been renamed Vike, see migration guide.

Render Modes (SPA, SSR, SSG, HTML-only)

For each page, we can choose a different render mode:

  • SPA
  • SSR
  • HTML-only
  • Pre-rendering (aka SSG)

For example, we can render an admin panel as SPA while rendering marketing pages with SSR.

What "SPA", "SSR", "HTML-only" and "SSG" mean, and which one should be used, is explained at SPA vs SSR (and more).

The vite-plugin-ssr boilerplates do SSR by default, which is a sensible default that works for most apps.

Instead of manually integrating render modes yourself, you can use vike-* packages and Bati: vike-* packages include an ssr: boolean config and Bati helps you scaffold projects. Bati and vike-* packages are going to get out of beta soon.


Rendering a page as SPA means that the page is loaded and rendered only in the browser.

To achieve that:

  1. We define .page.client.js instead of .page.js.
  2. We adapt our render() hooks.

1. .page.js => .page.client.js

By defining /pages/about.page.client.js instead of /pages/about.page.js we ensure that the page is loaded only in the browser.


2. render() hooks (SPA only)

If we only have SPA pages, then we adapt our render() hooks like the folllowing.

Client-side render() hook:

// /renderer/_default.page.client.js
// Environment: browser

import { renderToDom } from 'some-ui-framework'

export { render }

async function render(pageContext) {
  const { Page } = pageContext
  // UI frameworks usually have two methods, such as `renderToDom()` and `hydrateDom()`.
  // Note how we use `renderToDom()` and not `hydrateDom()`.
  await renderToDom(document.getElementById('root'), Page)

See What is Hydration? for understanding the difference between "rendering to the DOM" and "hydrating the DOM".

We also adapt our server-side render() hook:

// /renderer/_default.page.server.js
// Environment: server

import { escapeInject } from 'vite-plugin-ssr/server'

export function render() {
  // Note that `div#root` is empty
  return escapeInject`<html>
      <div id="root"></div>

This is the key difference between SPA and SSR: in SPA div#root is empty, whereas with SSR div#root contains our Page's root component pageContext.Page rendererd to HTML.

This means that, with SPA, we use our server-side render() hook to generate HTML that is just an empty shell: the HTML doesn't contain the page's content.

For production, we usually want to pre-render the HTML of our SPA pages in order to remove the need for a production Node.js server.

We can also use our server-side render() hook to render <head>:

// /renderer/_default.page.server.js
// Environment: server

import { escapeInject } from 'vite-plugin-ssr/server'

export function render(pageContext) {
  const { title, description } = pageContext.exports.meta
  // Even though we load and render our page's components only in the browser,
  // we define our page's `<title>` and `<meta name="description">` on the server-side.
  return escapeInject`<html>
      <meta name="description" content="${description}" />
      <div id="root"></div>

pageContext.exports is explained at Guides > Custom Exports/Hooks.

// /pages/about.page.js
export const meta = {
  title: 'About | My App',
  description: 'My App is ...'

Note how we define pageContext.exports.meta in about.page.js instead of about.page.client.js. That's because we need to be able to access pageContext.exports.meta from the server-side.

This means that we define both about.page.js (defining meta data) and about.page.client.js (defining our page's root component pageContext.Page).

2. render() hooks (SPA + SSR)

If we have both SPA and SSR pages, then we adapt our render() hooks like this:

// /renderer/_default.page.server.js
// Environment: server

import { escapeInject, dangerouslySkipEscape } from 'vite-plugin-ssr/server'
import { renderToHtml } from 'some-ui-framework'

export function render(pageContext) {
  let pageHtml
  if (pageContext.Page) {
    // For SSR pages
    pageHtml = renderToHtml(pageContext.Page)
  } else {
    // For SPA pages
    pageHtml = ''
  return escapeInject`<html>
      <div id="root">${dangerouslySkipEscape(pageHtml)}</div>

If we define a page's root component (pageContext.Page) in .page.client.js instead of .page.js, then pageContext.Page is only defined in the browser.

// /renderer/_default.page.client.js
// Environment: browser

import { renderToDom, hydrateDom } from 'some-ui-framework'

export async function render(pageContext) {
  const { Page } = pageContext
  const root = document.getElementById('root')
  if (
    // We detect SPAs by using the fact that `innerHTML === ''` for the first render of an SPA
    root.innerHTML === '' ||
    // Upon Client Routing page navigation, vite-plugin-ssr sets `pageContext.isHydration`
    // to `false`.
  ) {
    // - SPA pages don't have any hydration steps: they need to be fully rendered.
    // - Page navigation of SSR pages also need to be fully rendered (if we use Client Routing)
    await renderToDom(root, Page)
  } else {
    // The first render of SSR pages is merely a hydration (instead of a full render)
    await hydrateDom(root, Page)

React example: /examples/render-modes/.

Vue Example: GitHub > AaronBeaudoin/vite-plugin-ssr-example.


The vite-plugin-ssr boilerplates and documentation use SSR by default.

So, if we only have SSR pages, then there is nothing for us to do: we simply follow the boilerplates/docs.

If we want to have both SSR and SPA pages, then see the SPA section.


See Guides > Pre-rendering (SSG).


Using modern UI frameworks (React/Vue/...) to render pages only to HTML is a novel technique and should be considered experimental.

To render a page to HTML-only:

  1. We define .page.server.js instead of .page.js.
  2. (Optional) We define .page.client.js (e.g. to add minimal amount of JavaScript surgically injecting bits of interactivity).
// /pages/about.page.server.js
// Environment: server

// Usually `Page` is defined in `*.page.js` but for HTML-only we
// define `Page` in `*.page.server.js` instead.
export { Page }

function Page() {
  return <>
    <h1>HTML-only page</h1>
      This page is rendered only to HTML. (It's not loaded/rendered in the browser-side.)
// /pages/about.page.client.js
// Environment: browser

// This file represents the entire browser-side JavaScript.
// We can omit defining `.page.client.js` in which case the page has zero browser-side JavaScript.

console.log("I'm the page's only browser-side JavaScript line.")