⚠
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.

SPA

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.

Example:

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>
    <body>
      <div id="root"></div>
    </body>
  </html>`
}

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>
    <head>
      <title>${title}</title>
      <meta name="description" content="${description}" />
    </head>
    <body>
      <div id="root"></div>
    </body>
  </html>`
}

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>
    <body>
      <div id="root">${dangerouslySkipEscape(pageHtml)}</div>
    </body>
  </html>`
}

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`.
    !pageContext.isHydration
  ) {
    // - 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.

SSR

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.

Pre-rendering

See Guides > Pre-rendering (SSG).

HTML-only

⚠
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>
    <p>
      This page is rendered only to HTML. (It's not loaded/rendered in the browser-side.)
    </p>
  </>
}
// /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.")

Examples: