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

🧠

This page documents how to implement render modes yourself. It's an advanced task and we generally recommend simply using the ssr and prerender settings instead.

⚠️ We recommend using such advanced Vike functionality, which can be complex to use, only if you have a clear reason why you don't want to use potentially easier alternatives.

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

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

For example, you 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 Vike 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 a UI framework Vike extension vike-react/vike-vue/vike-solid which already integrates Render Modes. And you can use Bati to scaffold an app that uses vike-react/vike-vue/vike-solid.

SPA

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

To achieve that:

  1. We set Page's meta config env to { server: false, client: true } instead of { server: true, client: true }.
  2. We adapt our onRenderHtml() and onRenderClient() hooks.

1. Page meta config

By setting Page's meta config env to { client: true, server: false } we tell Vike to load +Page.js only in the browser.

// /renderer/+config.js
// Environment: config
 
export default {
  meta: {
    Page: {
      env: { server: false, client: true } // SPA for all pages
    }
  }
}

React example (SPA + SSR):

Vue example: GitHub > AaronBeaudoin/vite-plugin-ssr-example > /pages/spa.page.client.vue

vite-plugin-ssr was the previous name of Vike. Contributions welcome to fork and update the Vue example.

2. Render hooks (SPA only)

If we only have SPA pages, then we adapt our onRenderHtml() and onRenderClient() hooks like the following.

Client-side onRenderClient() hook:

// /renderer/+onRenderClient.js
// Environment: browser
 
import { renderToDom } from 'some-ui-framework'
 
export { onRenderClient }
 
async function onRenderClient(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 onRenderHtml() hook:

// /renderer/+onRenderHtml.js
// Environment: server
 
import { escapeInject } from 'vike/server'
 
export { onRenderHtml }
 
async function onRenderHtml() {
  // 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 rendered to HTML.

This means that, with SPA, we use our server-side onRenderHtml() 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 onRenderHtml() hook to render <head>:

// /renderer/+onRenderHtml.js
// Environment: server
 
import { escapeInject } from 'vike/server'
 
export { onRenderHtml }
 
async function onRenderHtml(pageContext) {
  const { title, description } = pageContext.config
  // 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.config.title and pageContext.config.description are custom settings, see API > meta > Example: title and description.

// /pages/about/+title.js
export const title = 'About | My App'
// /pages/about/+description.js
export const description = 'My App is ...'
// /renderer/+config.js
export default {
  meta: {
    Page: {
      env: { server: false, client: true } // SPA for all pages
    },
    title: {
      env: { server: true, client: false }
    },
    description: {
      env: { server: true, client: false }
    }
  }
}

2. Render hooks (SPA + SSR)

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

// /renderer/+onRenderHtml.js
// Environment: server
 
import { escapeInject, dangerouslySkipEscape } from 'vike/server'
import { renderToHtml } from 'some-ui-framework'
 
export { onRenderHtml }
 
async function onRenderHtml(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 set the Page meta config env to { server: false, client: true } instead of { server: true, client: true } in config.js, then pageContext.Page is only defined in the browser.

// /renderer/+onRenderClient.js
// Environment: browser
 
import { renderToDom, hydrateDom } from 'some-ui-framework'
 
export { onRenderClient }
 
async function onRenderClient(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, Vike 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.

vite-plugin-ssr was the previous name of Vike. Contributions welcome to fork and update the Vue example.

SSR

The Vike 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 (SSG)

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 set Page's meta config env to { server: true, client: false } instead of { server: true, client: true }.
  2. (Optional) We define +client.js (e.g. to add a minimal amount of JavaScript surgically injecting bits of interactivity).
// /renderer/+config.js
// Environment: config
 
export default {
  meta: {
    Page: {
      env: { server: true, client: false } // HTML-only for all pages
    }
  }
}
// /pages/about/+Page.js
// Environment: server
 
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/+client.js
// Environment: browser
 
// This file represents the entire browser-side JavaScript.
// We can omit defining `+client.js` in which case the page has zero browser-side JavaScript.
 
console.log("I'm the page's only browser-side JavaScript line.")

React example:

Vue Example: GitHub > AaronBeaudoin/vite-plugin-ssr-example > /pages/html.page.server.vue

vite-plugin-ssr was the previous name of Vike. Contributions welcome to fork and update the Vue example.

See also