Vite SSR plugin. Simple, full-fledged, do-one-thing-do-it-well.
Overview
Introduction
Vue Tour
React Tour
Get Started
Boilerplates
Manual Installation
Guides
Basics
Data Fetching
Routing
Pre-rendering (SSG)
More
SPA vs SSR vs HTML
HTML <head>
Page Redirection
Base URL
Import Paths Alias Mapping
.env
Files
Integrations
Authentication (Auth0, Passport.js, Grant, ...)
Markdown
Store (Vuex, Redux, ...)
GraphQL & RPC (Apollo, Relay, Wildcard API, ...)
Tailwind CSS
Other Tools (CSS Frameworks, Google Analytics, jQuery, Service Workers, Sentry, ...)
Deploy
Static Hosts (Netlify, GitHub Pages, Cloudflare Pages, ...)
Cloudflare Workers
AWS Lambda
Firebase
API
Node.js & Browser
*.page.js
pageContext
Node.js
*.page.server.js
• export { addPageContext }
• export { passToClient }
• export { render }
• export { prerender }
import { html } from 'vite-plugin-ssr'
Browser
*.page.client.js
import { getPage } from 'vite-plugin-ssr/client'
import { useClientRouter } from 'vite-plugin-ssr/client/router'
import { navigate } from 'vite-plugin-ssr/client/router'
Routing
*.page.route.js
• Route String
• Route Function
Filesystem Routing
Special Pages
_default.page.*
_error.page.*
Integration
import { createPageRender } from 'vite-plugin-ssr'
(Server Integration Point)
import ssr from 'vite-plugin-ssr/plugin'
(Vite Plugin)
CLI
Command prerender
vite-plugin-ssr
provides a similar experience than Nuxt/Next.js, but with Vite's wonderful DX, and as a do-one-thing-do-it-well tool.
- Do-One-Thing-Do-It-Well. Only takes care of SSR and works with: other Vite plugins, any view framework (Vue, React, ...), and any server environment (Express, Fastify, Cloudflare Workers, Firebase, ...).
- Render Control. You control how your pages are rendered enabling you to easily and naturally integrate any tool you want (Vuex, Redux, Apollo GraphQL, Service Workers, ...).
- SPA & SSR & HTML. Render some pages as SPA, some with SSR, and some to HTML-only (zero/minimal browser-side JavaScript).
- Pre-render / SSG / Static Websites. Deploy your app to a static host (Netlify, GitHub Pages, Cloudflare Pages, ...) by pre-rendering your pages.
- Routing. You can choose between Server-side Routing (for a simple architecture) and Client-side Routing (for faster/animated page transitions). You can also use Vue Router and React Router.
- HMR. Browser as well as server code is automatically refreshed/reloaded.
- Fast Cold Start. [Node.js] Your pages are lazy-loaded; adding pages doesn't increase the cold start of your serverless functions.
- Code Splitting. [Browser] Each page loads only the code it needs. Lighthouse score of 100%.
- Simple Design. Simple overall design resulting in a tool that is small, robust, and easy to use.
- Scalable. Your source code can scale to thousands of files with no hit on dev speed (thanks to Vite's lazy transpiling), and
vite-plugin-ssr
provides you with an SSR architecture that scales from small hobby projects with simple needs to large-scale enterprise projects with highly custom SSR needs. - No Known Bug. The source code of
vite-plugin-ssr
has no known bug; if you encounter a bug then it will be quickly fixed. - Responsive. Made with ❤️. GitHub issues are welcome and answered. Conversations are welcome at Discord -
vite-plugin-ssr
.
Get an idea of what it's like to use vite-plugin-ssr
with the Vue Tour or React Tour.
Scaffold a new app with npm init vite-plugin-ssr
(or yarn create vite-plugin-ssr
), or manually add vite-plugin-ssr
to your existing Vite app. (Although we recommend to first read the Vue/React tour before getting started.)
Similarly to Nuxt,
you create a page by defining a new .page.vue
file.
<!-- /pages/index.page.vue -->
<!-- Environment: Browser, Node.js -->
<template>
This page is rendered to HTML and interactive:
<button @click="state.count++">Counter {{ state.count }}</button>
</template>
<script>
import { reactive } from 'vue'
export default {
setup() {
const state = reactive({ count: 0 })
return { state }
}
}
</script>
By default, vite-plugin-ssr
does Filesystem Routing.
FILESYSTEM URL
pages/index.page.vue /
pages/about.page.vue /about
You can also define a page's route with a Route String (for parameterized routes such as /movies/:id
) or a Route Function (for full programmatic flexibility).
// /pages/index.page.route.js
// Environment: Node.js (and Browser if you opt-in for Client-side Routing)
// Note how the two files share the same base `pages/index.page.`; this is how `vite-plugin-ssr`
// knows that `pages/index.page.route.js` defines the route of `pages/index.page.vue`.
// Route Function
export default pageContext => pageContext.url === '/'
// If we don't create a `.page.route.js` file then Filesystem Routing is used
Unlike Nuxt, you define how your pages are rendered.
// /pages/_default.page.server.js
// Environment: Node.js
import { createSSRApp, h } from 'vue'
import { renderToString } from '@vue/server-renderer'
import { html } from 'vite-plugin-ssr'
export { render }
async function render(pageContext) {
const { Page, pageProps } = pageContext
const app = createSSRApp({
render: () => h(Page, pageProps)
})
const appHtml = await renderToString(app)
const title = 'Vite SSR'
return html`<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
</head>
<body>
<div id="app">${html.dangerouslySkipEscape(appHtml)}</div>
</body>
</html>`
}
// /pages/_default.page.client.js
// Environment: Browser
import { createSSRApp, h } from 'vue'
import { getPage } from 'vite-plugin-ssr/client'
hydrate()
async function hydrate() {
const pageContext = await getPage() // (Page context is preloaded in production)
const { Page, pageProps } = pageContext
const app = createSSRApp({
render: () => h(Page, pageProps)
})
app.mount('#app')
}
The render()
hook in /pages/_default.page.server.js
gives you full control over how your pages are rendered,
and /pages/_default.page.client.js
gives you full control over the browser-side code.
This control enables you to easily and naturally use any tool you want (Vuex, GraphQL, Service Worker, ...).
There are four suffixes:
.page.js
: exports the page's root Vue component..page.client.js
: defines the page's browser-side code..page.server.js
: exports the page's hooks (always run in Node.js)..page.route.js
: exports the page's Route String or Route Function.
Instead of creating a .page.client.js
and .page.server.js
file for each page,
you can create _default.page.client.js
and _default.page.server.js
which apply as default for all pages.
We already defined our _default
files,
which means that we can create a new page solely by defining a new .page.vue
file (the .page.route.js
file is optional).
The _default
files can be overridden. For example, you can create a page with a different browser-side code than your other pages.
// /pages/about.page.client.js
// This file is empty which means that the `/about` page has zero browser-side JavaScript.
<!-- /pages/about.page.vue -->
<template>
This page is only rendered to HTML.
</template>
By overriding _default.page.server.js#render
you can
even render some of your pages with a different view framework, e.g. another Vue version (for progressive upgrade) or even React.
Let's now have a look at how to fetch data.
<!-- /pages/star-wars/movie.page.vue -->
<!-- Environment: Browser, Node.js -->
<template>
<h1>{{movie.title}}</h1>
<p>Release Date: {{movie.release_date}}</p>
<p>Director: {{movie.director}}</p>
</template>
<script lang="js">
const pageProps = ['movie']
export default { props: pageProps }
</script>
// /pages/star-wars/movie.page.route.js
// Environment: Node.js
// Route String
export default '/star-wars/:movieId'
// /pages/star-wars/movie.page.server.js
// Environment: Node.js
import fetch from 'node-fetch'
export async function addPageContext(pageContext) {
// The route parameter of `/star-wars/:movieId` is available at `pageContext.routeParams.movieId`
const { movieId } = pageContext.routeParams
// `.page.server.js` files always run in Node.js; we could use SQL/ORM queries here.
const response = await fetch(`https://swapi.dev/api/films/${movieId}`)
let movie = await response.json()
// The render/hydrate functions we defined earlier use `pageContext.pageProps`.
// This is where we define `pageContext.pageProps`.
const pageProps = { movie }
// `pageProps` is now available at `pageContext.pageProps`
return { pageProps }
}
// By default `pageContext` is available only on the server. But our hydrate function
// we defined earlier runs in the browser and needs `pageContext.pageProps`; we use
// `passToClient` to tell `vite-plugin-ssr` to serialize and make `pageContext.pageProps`
// available in the browser.
export const passToClient = ['pageProps']
Note that vite-plugin-ssr
doesn't know anything about pageProps
; it's an object we create to
conveniently hold all props of the root Vue component.
That's it, and we have actually already seen most of the interface;
not only is vite-plugin-ssr
flexible, but it is also simple, easy, and fun to use.
Scaffold a new vite-plugin-ssr
app with npm init vite-plugin-ssr
(or yarn create vite-plugin-ssr
), or manually install vite-plugin-ssr
to your existing Vite app.
Similarly to Next.js,
you create a page by defining a new .page.jsx
file.
// /pages/index.page.jsx
// Environment: Browser, Node.js
import React, { useState } from "react";
export { Page };
function Page() {
return <>
This page is rendered to HTML and interactive: <Counter />
</>;
}
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((count) => count + 1)}>
Counter {count}
</button>
);
}
By default, vite-plugin-ssr
does Filesystem Routing.
FILESYSTEM URL
pages/index.page.jsx /
pages/about.page.jsx /about
You can also define a page's route with a Route String (for parameterized routes such as /movies/:id
) or a Route Function (for full programmatic flexibility).
// /pages/index.page.route.js
// Environment: Node.js (and Browser if you opt-in for Client-side Routing)
// Note how the two files share the same base `pages/index.page.`; this is how `vite-plugin-ssr`
// knows that `pages/index.page.route.js` defines the route of `pages/index.page.jsx`.
// Route Function
export default pageContext => pageContext.url === '/';
// If we don't create a `.page.route.js` file then Filesystem Routing is used
Unlike Next.js, you define how your pages are rendered.
// /pages/_default.page.server.jsx
// Environment: Node.js
import ReactDOMServer from "react-dom/server";
import React from "react";
import { html } from "vite-plugin-ssr";
export { render };
async function render(pageContext) {
const { Page, pageProps } = pageContext;
const viewHtml = ReactDOMServer.renderToString(
<Page {...pageProps} />
);
const title = "Vite SSR";
return html`<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
</head>
<body>
<div id="page-view">${html.dangerouslySkipEscape(viewHtml)}</div>
</body>
</html>`;
}
// /pages/_default.page.client.jsx
// Environment: Browser
import ReactDOM from "react-dom";
import React from "react";
import { getPage } from "vite-plugin-ssr/client";
hydrate();
async function hydrate() {
const pageContext = await getPage(); // (Page context is preloaded in production)
const { Page, pageProps } = pageContext
ReactDOM.hydrate(
<Page {...pageProps} />,
document.getElementById("page-view")
);
}
The render()
hook in /pages/_default.page.server.jsx
gives you full control over how your pages are rendered,
and /pages/_default.page.client.jsx
gives you full control over the browser-side code.
This control enables you to easily and naturally use any tool you want (Redux, GraphQL, Service Worker, Preact, ...).
There are four suffixes:
.page.js
: exports the page's root React component..page.client.js
: defines the page's browser-side code..page.server.js
: exports the page's hooks (always run in Node.js)..page.route.js
: exports the page's Route String or Route Function.
Instead of creating a .page.client.js
and .page.server.js
file for each page,
you can create _default.page.client.js
and _default.page.server.js
which apply as default for all pages.
We already defined our _default
files,
which means that we can create a new page solely by defining a new .page.jsx
file (the .page.route.js
file is optional).
The _default
files can be overridden. For example, you can create a page with a different browser-side code than your other pages.
// /pages/about.page.client.js
// This file is empty which means that the `/about` page has zero browser-side JavaScript.
// /pages/about.page.jsx
export { Page };
function Page() {
return <>This page is only rendered to HTML.<>;
}
By overriding _default.page.server.js#render
you can
even render some of your pages with a different view framework, e.g. another React version (for progressive upgrade) or even Vue.
Let's now have a look at how to fetch data.
// /pages/star-wars/movie.page.jsx
// Environment: Browser, Node.js
import React from "react";
export { Page };
function Page(pageProps) {
const { movie } = pageProps;
return <>
<h1>{movie.title}</h1>
<p>Release Date: {movie.release_date}</p>
<p>Director: {movie.director}</p>
</>;
}
// /pages/star-wars/movie.page.route.js
// Environment: Node.js
// Route String
export default "/star-wars/:movieId";
// /pages/star-wars/movie.page.server.js
// Environment: Node.js
import fetch from "node-fetch";
export async function addPageContext(pageContext) {
// The route parameter of `/star-wars/:movieId` is available at `pageContext.routeParams.movieId`
const { movieId } = pageContext.routeParams
// `.page.server.js` files always run in Node.js; we could use SQL/ORM queries here.
const response = await fetch(`https://swapi.dev/api/films/${movieId}`)
let movie = await response.json();
// The render/hydrate functions we defined earlier use `pageContext.pageProps`.
// This is where we define `pageContext.pageProps`.
const pageProps = { movie };
// `pageProps` is now available at `pageContext.pageProps`
return { pageProps };
}
// By default `pageContext` is available only on the server. But our hydrate function
// we defined earlier runs in the browser and needs `pageContext.pageProps`; we use
// `passToClient` to tell `vite-plugin-ssr` to serialize and make `pageContext.pageProps`
// available in the browser.
export const passToClient = ["pageProps"];
Note that vite-plugin-ssr
doesn't know anything about pageProps
; it's an object we create to
conveniently hold all props of the root React component.
That's it, and we have actually already seen most of the interface;
not only is vite-plugin-ssr
flexible, but it is also simple, easy, and fun to use.
Scaffold a new vite-plugin-ssr
app with npm init vite-plugin-ssr
(or yarn create vite-plugin-ssr
), or manually install vite-plugin-ssr
to your existing Vite app.
Scaffold an app with Vite and vite-plugin-ssr
.
With npm:
npm init vite-plugin-ssr
With Yarn:
yarn create vite-plugin-ssr
A prompt will let you choose between:
vue
: Vue + JavaScriptvue-ts
: Vue + TypeScriptreact
: React + JavaScriptreact-ts
: React + TypeScript
Options:
--skip-git
: don't initialize a new Git repository
If you already have an existing Vite app and don't want to start from scratch:
-
Add
vite-plugin-ssr
to yourvite.config.js
. -
Integrate
createPageRender()
to your server (Express.js, Koa, Hapi, Fastify, ...). -
Define
_default.page.client.js
and_default.page.server.js
. -
Create your first
.page.js
file. -
Add the
dev
andbuild
scripts to yourpackage.json
.
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
You fech data by defining export { addPageContext, passToClient }
in the Page's .page.server.js
file.
- Example
- Pass
pageContext
to any/all components - Data Fetching with Stateful Component
- GraphQL
- Store (Vuex/Redux...)
// /pages/movies.page.server.js
// Environment: Node.js
import fetch from "node-fetch";
export { addPageContext }
// Tell `vite-plugin-ssr` to make `pageContext.pageProps` available in the browser.
// Make sure that `pageContext.pageProps` is serializable: `vite-plugin-ssr` will
// serialize and pass `pageContext.pageProps` to the browser.
export const passToClient = ['pageProps']
async function addPageContext(pageContext) {
// `.page.server.js` files always run in Node.js; we could use SQL/ORM queries here.
const response = await fetch("https://movies.example.org/api")
let movies = await response.json()
// `movies` will be serialized and passed to the browser; we select only the data we
// need in order to minimize what is sent over the network.
movies = movies.map(({ title, release_date }) => ({title, release_date}))
// We could also `return { movies }` but we use an object `pageProps` as convenience.
const pageProps = { movies }
return { pageProps }
}
// /pages/_default.page.server.js
// Environment: Node.js
import { html } from 'vite-plugin-ssr'
import { renderToHtml, createElement } from 'some-view-framework'
export { render }
async function render(pageContext) {
const { Page, pageProps } = pageContext
const pageHtml = await renderToHtml(
// Our convenience object `pageProps` allows us to pass all root component props at once.
createElement(Page, pageProps)
)
/* JSX:
const pageHtml = await renderToHtml(<Page {...pageProps} />)
*/
return html`<html>
<div id='view-root'>
${html.dangerouslySkipEscape(pageHtml)}
</div>
</html>`
}
// /pages/_default.page.client.js
// Environment: Browser
import { getPage } from 'vite-plugin-ssr/client'
import { hydrateToDom, createElement } from 'some-view-framework'
hydrate()
async function hydrate() {
const pageContext = await getPage()
await hydrateToDom(
// Thanks to `passToClient = ['pageProps']` our `pageContext.pageProps` is
// available here in the browser.
createElement(pageContext.Page, pageContext.pageProps),
document.getElementById('view-root')
)
/* JSX:
await hydrateToDom(<Page {...pageContext.pageProps} />, document.getElementById('view-root'))
*/
}
// /pages/movies.page.js
// Environment: Browser, Node.js
export { Page }
// In our `render()` and `hydrate()` functions above, we pass `pageContext.pageProps` to `Page`
function Page(pageProps) {
const { movies } = pageProps
// ...
}
Note that vite-plugin-ssr
doesn't know anything about pageProps
: it's an object we create to
conveniently hold all props of the root component.
We could have defined movies
directly on pageContext.movies
but it's cumbersome:
our render/hydrate function would then need to know what pageContext
should be passed to the root component, whereas with pageContext.pageProps
our render/hydrate function can simply pass pageContext.pageProps
to the root component.
You can pass some pageContext
to any/all components of your component tree:
- React: React.createContext
- Vue 2: Vue.prototype
- Vue 3: app.provide or app.config.globalProperties
We can also fetch data by using a stateful component by making pageContext.routeParams
available everywhere with export const passToClient = ['routeParams']
and then pass it to the stateful component. Note that with this technique, the fetched data is not rendered to HTML.
When using GraphQL with Apollo GraphQL or Relay you can define GraphQL queries/fragments on a component-level, but you still always fetch data globally on a page-level. With vite-plugin-ssr
, you do this global fetch in the addPageContext()
hook.
In general, with vite-plugin-ssr
, you have full control over rendering which means that integrating GraphQL is mostly a matter of following the official SSR guide of the tool you are using (e.g. Apollo GraphQL - SSR Guide).
When using a global store (e.g. with Vuex or Redux), your components use the store and don't use the fetched data directly; instead, you use the fetched data to set the initial state of the store. You then render the HTML with that initial store state and then pass the initial store state to the client for hydration, which, with vite-plugin-ssr
, you can do with export const passToClient = ['initialStoreState']
.
In general, with vite-plugin-ssr
, you have full control over rendering which means that integrating a global store is mostly a matter of following the official SSR guide of the tool you are using (e.g. Redux - SSR Guide, Vuex - SSR Guide).
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
- Server-side Routing VS Client-side Routing
- Filesystem Routing VS Route Strings VS Route Functions
- Active Links
<a class="is-active">
- Nested Routes
You can choose between two routing strategy:
- Server-side Routing (for simpler architecture)
- Client-side Routing (for faster and animated page transitions)
You can also use a routing library such as Vue Router and React Router (in complete replacement or in combination). Examples:
By default, vite-plugin-ssr
does Server-side Routing,
which is the "old school" way of doing routing: when the user changes the page,
a new HTML request is made, and the old page (i.e. its HTML) is completely replaced with the new page.
If you don't have a strong rationale for doing something differently, then stick to Server-side Routing as it leads to a simpler app architecture.
That said, vite-plugin-ssr
has first-class support for Client-side Routing and you can opt-in by using useClientRouter()
:
With Client-side Routing, instead of doing a full HTML reload, only the DOM is updated: the new page's root (Vue/React/...) component is loaded and the view framework (Vue/React/...) renders the new root component to the DOM.
Client-side Routing enables:
- Slightly faster page transitions.
- Custom animated page transitions.
But Client-side Routing leads to an inherently more complex app architecture, which is why we recommend using Client-side Routing only if you have a strong rationale.
If a page doesn't have a .page.route.js
file then vite-plugin-ssr
uses Filesystem Routing:
FILESYSTEM URL
pages/index.page.js /
pages/about.page.js /about
pages/faq/index.page.js /faq
To define a parameterized route, or for more control, you can export default
a Route String in .page.route.js
.
// /pages/product.page.route.js
export default '/product/:productId'
The productId
value is available at pageContext.routeParams.productId
so that you can fetch data in async addPageContext(pageContext)
which is explained at Data Fetching.
For full programmatic flexibility, you can define a Route Function.
// /pages/admin.page.route.js
// Route Functions allow us to implement advanced routing such as route guards.
export default (pageContext) => {
const { url } = pageContext
if (url === '/admin' && pageContext.user.isAdmin) {
return { match: true }
}
}
For detailed informations about Filesystem Routing, Route Strings, and Route Functions:
Pass pageContext.urlPathname
(available on both the client and the server)
to your link component.
You can then set isActive = href===urlPathname
in your link component.
A nested route (aka sub route) is, essentially, when you have a route with multiple parameters,
for example /product/:productId/:productView
.
URL productId productView
/product/1337 1337 null
/product/1337/pricing 1337 pricing
/product/42/reviews 42 reviews
With vite-plugin-ssr
, we can define a Route String that has multiple parameters.
// product.page.route.js
export default `/product/:productId/:productView`
// product.page.route.js
// We can also use a Route Function
export default (pageContext) => {
const { url } = pageContext
if (! url.startsWith('/product/')) return false
const [productId, productView] = url.split('/').slice(2)
return { match: true, routeParams: { productId, productView } }
}
Usually, the sub route is used for navigating some (deeply) nested view:
/product/42/pricing /product/42/reviews
+------------------+ +-----------------+
| Product | | Product |
| +--------------+ | | +-------------+ |
| | Pricing | | +------------> | | Reviews | |
| | | | | | | |
| +--------------+ | | +-------------+ |
+------------------+ +-----------------+
⚠️ If your sub routes don't need URLs (if it's fine that the Product Pricing and the Product Reviews share the same URL/product/42
), then you can simply use a stateful component instead. (When the user clicks on a "pricing" link => the stateful component changes its internal stateproductView
to'pricing'
to show the pricing view.)
By default,
vite-plugin-ssr
does Server-side Routing,
which means that when the user navigates from /product/42/pricing
to /product/42/reviews
,
the old page (i.e. the HTML of) /product/42/pricing
is fully replaced with the new page (i.e. the HTML of) /product/42/reviews
,
leading to a jittery experience.
For smoother navigations, we can use Client-side Routing.
// product.page.client.js
import { useClientRouter } from 'vite-plugin-ssr/client/router'
// We use Client-side Routing so that, when the user navigates from `/product/42/pricing`
// to `/product/42/reviews`, only the relevant (deeply) nested view is updated (instead of
// a full HTML reload).
// Note that we override `_default.page.client.js`. This means all our other pages can use
// Server-side Routing while this page uses Client-side Routing.
// (If we are already using `useClientRouter()` in `_default.page.client.js`, then we don't need to
// create this `product.page.client.js` file.)
useClientRouter({
render(pageContext) {
/* ... */
}
})
We can then use <a href="/product/42/reviews" keep-scroll-position />
/ navigate('/product/42/reviews', { keepScrollPosition: true })
to avoid the browser to scroll to the top upon navigation.
We can also pass pageContext.routeParams
to any/all components,
so that we can navigate/render the routed nested view.
Alternatively,
we can use a Route String Wildcard (e.g. /product/:params*
) and then use a routing library (Vue Router, React Router, ...) for that page,
but we recommend the aforementioned solution instead as it is usually simpler.
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
*️⃣ What is pre-rendering? Pre-rendering means to render the HTML of all your pages at build-time: normally the HTML of a page is rendered at request-time (when your user navigates to that page), but with pre-rendering the HTML of a page is rendered at build-time instead. Your app then consists only of static files (HTML, JS, CSS, images, ...) that you can deploy to so-called "static hosts" such as GitHub Pages, Cloudflare Pages, or Netlify. If you don't use pre-rendering, then you need to use a Node.js server to be able to render your pages' HTML at request-time.
To pre-render your pages, use the CLI command prerender
at the end of your build:
- With npm: run
$ npx vite build && npx vite build --ssr && npx vite-plugin-ssr prerender
. - With Yarn:
$ yarn vite build && yarn vite build --ssr && yarn vite-plugin-ssr prerender
.
For pages with a parameterized route (e.g. /movie/:movieId
), you have to use the prerender()
hook.
The prerender()
hook can also be used to accelerate the pre-rendering process as it allows you to prefetch data for multiple pages at once.
Examples:
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
With vite-plugin-ssr
you can create:
- SSR pages
- SPA pages (aka MPA)
- HTML pages (with zero/minimal browser-side JavaScript)
For example, you can render your admin panel as SPA while rendering your marketing pages to HTML-only.
The rule of thumb is to render a page to:
- HTML (zero/minimal browser-side JavaScript), if the page has no interactivity (technically speaking: if the page has no stateful component). Example: blog, non-interactive marketing pages.
- SPA, if the page has interactivity and doesn't need SEO (e.g. the page doesn't need to appear on Google). Example: admin panel, desktop-like web app.
- SSR, if the page has interactivity and needs SEO (the page needs to rank high on Google). Example: social news website, interactive marketing pages.
To render a page as SPA, simply render static HTML:
// .page.server.js
// Environment: Node.js
import { html } from 'vite-plugin-ssr'
export function render() {
// `div#app-root` is empty; the HTML is static.
return html`<html>
<head>
<title>My Website</title>
</head>
<body>
<div id="app-root"/>
</body>
</html>`
}
To render a page to HTML-only, define an empty .page.client.js
:
// .page.client.js
// Environment: Browser
// We leave this empty; there is no browser-side JavaScript.
// We can still include CSS
import './path/to/some.css'
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
HTML <head>
tags such as <title>
and <meta>
are defined in the render()
hook.
// _default.page.server.js
// Environment: Node.js
import { html } from 'vite-plugin-ssr'
import { renderToHtml } from 'some-view-framework'
export { render }
async function render(pageContext) {
return html`<html>
<head>
<title>SpaceX</title>
<meta name="description" content="We deliver your payload to space.">
</head>
<body>
<div id="root">
${html.dangerouslySkipEscape(await renderToHtml(pageContext.Page))}
</div>
</body>
</html>`
}
If you want to define <title>
and <meta>
tags on a page-by-page basis, you can use pageContext
.
// _default.page.server.js
import { html } from 'vite-plugin-ssr'
import { renderToHtml } from 'some-view-framework'
export { render }
async function render(pageContext) {
// We use `pageContext.documentProps` which pages define in their `addPageContext()` hook
let title = pageContext.documentProps.title
let description = pageContext.documentProps.description
// Defaults
title = title || 'SpaceX'
description = description || 'We deliver your payload to space.'
return html`<html>
<head>
<title>${title}</title>
<meta name="description" content="${description}">
</head>
<body>
<div id="root">
${html.dangerouslySkipEscape(await renderToHtml(pageContext.Page))}
</div>
</body>
</html>`
}
// about.page.server.js
export { addPageContext }
function addPageContext() {
const documentProps = {
// This title and description will override the defaults
title: 'About SpaceX',
description: 'Our mission is to explore the galaxy.'
}
return { documentProps }
}
If you want to define <head>
tags by some deeply nested view component:
- Add
documentProps
topassToClient
. - Pass
pageContext.documentProps
to all your components. - Modify
pageContext.documentProps
in your deeply nested component.
// _default.page.server.js
// Environment: Node.js
import { html } from 'vite-plugin-ssr'
import renderToHtml from 'some-view-framework'
export async function render(pageContext) {
// Use your view framework to pass `pageContext.documentProps` to all components
// of your component tree. (E.g. React Context or Vue's `app.config.globalProperties`.)
const pageHtml = await renderToHtml(
<ContextProvider documentProps={pageContext.documentProps} >
<Page />
</ContextProvider>
)
// What happens here is:
// 1. Your view framework passed `documentProps` to all your components
// 2. One of your (deeply nested) component modified `documentProps`
// 3. You now render `documentProps` to HTML meta tags
return html`<html>
<head>
<title>${pageContext.documentProps.title}</title>
<meta name="description" content="${pageContext.documentProps.description}">
</head>
<body>
<div id="app">
${html.dangerouslySkipEscape(pageHtml)}
</div>
</body>
</html>`
}
// Somewhere in a component deep inside your component tree
// Thanks to our previous steps, `documentProps` is available here.
documentProps.title = 'I was set by some deep component.'
documentProps.description = 'Me too.'
If you use Client-side Routing, make sure to update document.title
upon page navigation.
// _default.page.server.js
// We make `pageContext.documentProps` available in the browser.
export const passToClient = ['documentProps', 'pageProps']
// _default.page.client.js
import { useClientRouter } from 'vite-plugin-ssr/client/router'
useClientRouter({
render(pageContext) {
if( ! pageContext.isHydration ) {
document.title = pageContext.documentProps.title
}
// The usual stuff.
// (Make sure to pass `pageContext.documentProps` to your whole component tree also here.)
// ...
}
})
You can also use libraries such as @vueuse/head or react-helmet
but use such library only if you have a strong rationale:
the solution using pageContext.documentProps
is considerably simpler and works for the vast majority of cases.
For pages defined with markdown, have a look at Markdown <head>
.
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
Your render()
hook doesn't have to return HTML and can, for example,
return an object such as { redirectTo: '/some/url' }
which is then available at your server integration point createPageRender()
(you can then perform a URL redirect there).
// movie.page.route.js
export default "/star-wars/:movieId";
// movie.page.server.js
// Environment: Node.js
export { addPageContext }
function addPageContext(pageContext) {
// If the user goes to `/movie/42` but there is no movie with ID `42` then
// we redirect the user to `/movie/add` so he can add a new movie.
if (pageContext.routeParams.movieId === null) {
return { redirectTo: '/movie/add' }
}
}
// _default.page.server.js
// Environment: Node.js
export { render }
function render(pageContext) {
const { redirectTo } = pageContext
if (redirectTo) return { redirectTo }
// The usual stuff
// ...
}
// server.js
// Environment: Node.js
const renderPage = createPageRender(/*...*/)
app.get('*', async (req, res, next) => {
const url = req.originalUrl
const pageContext = { url }
const result = await renderPage(pageContext)
if (result.nothingRendered) {
return next()
} else if (result.renderResult?.redirectTo) {
res.redirect(307, '/movie/add')
} else {
res.status(result.statusCode).send(result.renderResult)
}
})
If you use Client-side Routing, then also redirect at *.page.client.js
.
// movie.page.server.js
// Environment: Node.js
// We make `pageContext.redirectTo` available to the browser for Client-side Routing redirection
export const passToClient = ['redirectTo']
// _default.page.client.js
// Environment: Browser
import { useClientRouter, navigate } from 'vite-plugin-ssr/client/router'
useClientRouter({
render(pageContext) {
const { redirectTo } = pageContext
if (redirectTo) {
navigate(redirectTo)
return
}
// The usual stuff
// ...
}
})
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
The Base URL (aka Public Base Path) is about changing the URL root of your Vite app.
For example, instead of deploying your Vite app at https://example.org
(i.e. base: '/'
), you can set base: '/some/nested/path/'
and deploy your Vite app at https://example.org/some/nested/path/
.
Change Base URL for production:
- Use Vite's
--base
CLI build option:$ vite build --base=/some/nested/path/ && vite build --ssr --base=/some/nested/path/
. (For both$ vite build
and$ vite build --ssr
.) - If you don't pre-render your app: pass
base
tocreatePageRender({ base: isProduction ? '/some/nested/path/' : '/' })
. (Pre-rendering automatically sets the right Base URL.) - Use the
import.meta.env.BASE_URL
value injected by Vite to construct a<Link href="/star-wars">
component that prepends the Base URL.
Change Base URL for local dev:
- Pass
base
tocreateServer({ base: '/some/nested/path/' })
(import { createServer } from 'vite'
) andcreatePageRender({ base: '/some/nested/path/' })
(import { createPageRender } from 'vite-plugin-ssr'
).
You can also set base: 'https://another-origin.example.org/'
(for cross-origin deployments) and base: './'
(for embedded deployments at multiple paths).
Example:
- /examples/base-url/pages/_components/Link.jsx (a
<Link>
component built on top ofimport.meta.env.BASE_URL
) - /examples/base-url/server/index.js (see the
base
option passed tovite
andvite-plugin-ssr
) - /examples/base-url/package.json (see the build scripts)
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
Instead of using relative import paths
which are often cumbersome (e.g. import { Counter } from '../../../components/Counter'
),
you can use import path aliases:
// `~/components/` denotes the `components/` directory living in your project root directory
import { Counter } from `~/components/Counter`
// ...
Path aliases are defined at vite.config.js#resolve.alias
:
// vite.config.js
export default {
resolve: {
alias: {
// We can now `import '~/path/to/module'` where `~` references the project root
"~": __dirname,
}
},
// ...
}
If you use TypeScript:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"~/*": ["./*"]
}
// ...
}
}
Vite's vite.config.js#resolve.alias
only works for Vite processed files (i.e. **/*.page.*
files and their imports).
For files that are not processed by Vite (typically your server entry, e.g. server/index.js) you can use module-alias
.
Example:
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
Vite automatically makes environment variables defined in .env
available as import.meta.env
, see [Vite] .env
Files.
Note that:
- Vite only makes available environment variables that are prefixed with
VITE_
(for security reasons). - Vite makes variables available at
import.meta.env
only for files that are processed by Vite (i.e.**/*.page.*
files and their imports). For files not processed by Vite (typically your server entry, e.g. server/index.js) you can load.env
files yourself:// server/index.js if (!isProduction) { // npm install dotenv require('dotenv').config() }
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
You can add information about the authenticated user to pageContext
at the server integration point
createPageRender()
.
const renderPage = createPageRender(/*...*/)
app.get('*', async (req, res, next) => {
const url = req.originalUrl
// Authentication middlewares (e.g. Passport.js or Grant) provide informations
// about the logged-in user on the `req` object, e.g. `req.user`:
const user = req.user
/* Or when using a third-party authentication provider (e.g. Auth0):
const user = await authProviderApi.getUser(req.headers)
*/
// We add the user auth information to `pageContext`
const pageContext = { user, url }
const result = await renderPage(pageContext)
if (result.nothingRendered) return next()
res.status(result.statusCode).send(result.renderResult)
})
Some common auth tools:
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
You can use vite-plugin-ssr
with any Vite markdown plugin.
For Vue you can use vite-plugin-md
.
Example:
For React you can use vite-plugin-mdx
.
Example:
You can simply export the page's <head>
values.
// markdown.page.mdx
export const documentProps = {
title: 'A Markdown Page',
description: 'Example of setting `<title>` and `<meta name="description">`'
}
# Markdown
This page is written in _Markdown_.
// _default.page.server.js
import { html } from 'vite-plugin-ssr'
export async function render(pageContext) {
// `pageContext.pageExports` holds the exports of the page's `.page.js` file being rendered
const { title, description } = pageContext.pageExports.documentProps
return html`<html>
<head>
<title>${title}</title>
<meta name="description" content="${description}">
</head>
<!-- ... -->
</html>`
}
Examples:
You can also use a so-called front matter to define the page's metadata.
---
title: A Markdown Page
description: Example of setting `<title>` and `<meta name="description">`
---
# Markdown
This page is written in _Markdown_.
The front matter data is usually available has an export,
which you can access at pageContext.pageExports
.
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
With vite-plugin-ssr
, you have full control over rendering, which means that integrating tools is simply a matter of following the official SSR guide of the tool you are using.
While you can follow the official guides exactly as-is and serialize the initial state into HTML,
you can also leverage addPageContext()
with passToClient
to make your life slightly easier
as shown in the following examples.
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
With vite-plugin-ssr
, you have full control over rendering, which means that integrating tools is simply a matter of following the official SSR guide of the tool you are using.
RPC:
GraphQL:
While you can follow the official guides exactly as-is and serialize the initial fetched data into HTML,
you can also leverage addPageContext()
with passToClient
to make your life slightly easier
as shown in the following example.
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
-
npm install vite-plugin-windicss windicss # or yarn add vite-plugin-windicss windicss
-
Add
vite-plugin-windicss
to yourvite.config.js
.import ssr from "vite-plugin-ssr/plugin" import WindiCSS from "vite-plugin-windicss" export default { plugins: [ ssr(), WindiCSS({ scan: { // By default only `src/` is scanned dirs: ["pages"], // You only have to specify the file extensions you actually use. fileExtensions: ["vue", "js", "ts", "jsx", "tsx", "html", "pug"] } }) ] }
Alternatively, you can define these options in
windi.config.js
. -
Add WindiCSS to your
_default.page.client.js
.import 'virtual:windi.css'
That's it.
More at WindiCSS Vite Guide.
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
In general,
thanks to the fact that with vite-plugin-ssr
you have full control over how your pages are rendered,
you can use vite-plugin-ssr
with whatever tool you want.
In fact, vite-plugin-ssr
is being used with a high variety of tools at companies with all kinds of diverse environments.
So far, no tool couldn't be used.
// _default.page.server.js
// Environment: Node.js
import renderToHtml from 'some-view-framework'
import { html } from 'vite-plugin-ssr'
export { render }
async function render(pageContext) {
// We have full control over how pages are rendered.
// E.g. we can use any view framework version we want (Vue 2, Vue 3, React 16, React 17, ...).
const pageHtml = await renderToHtml(pageContext.Page)
// We have full control over the HTML
return html`<html>
<body>
<div id="root">
${html.dangerouslySkipEscape(pageHtml)}
</div>
</body>
</html>`
}
// server.js
// Environment: Node.js
// The server entry point
// We use Express.js here but we could as well use Fastify, Koa, Hapi, ...
const express = require('express')
const { createPageRender } = require('vite-plugin-ssr')
startServer()
async function startServer() {
const app = express()
// Server integration is just a function `renderPage()`.
const renderPage = createPageRender(/*...*/)
app.get('*', async (req, res, next) => {
const url = req.originalUrl
// `renderPage()` is simply a function that, for a given URL, returns the result of our
// `render()` hook (usually an HTML string). It doesn't know anything about Express.js and
// we can use it with whatever server environment we want (Fastify, Cloudflare Workers, ...).
const result = await renderPage({ url })
res.send(result.renderResult)
})
}
// _default.page.client.js
// Environment: Browser
// This is the *entire* browser-side code; we have full control over the browser-side.
// If we save an empty `.page.client.js` then we have zero browser-side JavaScript.
import { getPage } from 'vite-plugin-ssr/client'
import { hydrateToDom } from 'some-view-framework'
// We can initialize browser libraries here.
$('.my-modals').modal()
// We can also initialize Service Workers here.
hydrate()
async function hydrate() {
// `Page` is what we export in `*.page.js`; we have full control over what
// we define in `*.page.js` and we can do whatever we want with it.
const { Page } = await getPage()
// We have full control over how pages are hydrated.
await hydrateToDom(Page)
}
You have full control and you can do whatever you want.
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
You can pre-render your pages to remove the need for a Node.js server. Examples:
You can then deploy dist/client/
to any static host, for example:
- Cloudflare Pages (if you want Cloudflare Pages to build your app for you, note that
vite-plugin-ssr
requires Node.js>=12.19.0
and you may need to change the default Node.js version) - Netlify
- GitHub Pages
There are three strategies to build:
- Build locally and upload
dist/client/
to the static host. - Build with a GitHub action and upload
dist/client/
to the static host. - Let the static host run the build for you.
You can change your app's Base URL in case you don't deploy your app at the URL root /
. Example:
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
Make sure to import /dist/server/importer.js
in your worker code. (The importer.js
makes all dependencies statically analysable so that the entire server code can be bundled into a single worker file.)
Example:
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
From an architectural point of view, your vite-plugin-ssr
app is simply a Node.js application.
This means you can use any AWS Lambda Node.js deploy tool, for example:
(In production, vite-plugin-ssr
is simply two Express.js(/Fastify/Koa/...) middlewares: one middleware that serves the static files living at dist/client/
, and a second middleware that server-side renders your pages with vite-plugin-ssr
's const renderPage = createPageRender();
.)
⚠️ We recommend reading the Vue Tour or React Tour before proceeding with guides.
From an architectural point of view, your vite-plugin-ssr
app is simply a Node.js application.
This means you can simply follow Firebase's official guide.
(In production, vite-plugin-ssr
is simply two Express.js(/Fastify/Koa/...) middlewares: one middleware that serves the static files living at dist/client/
, and a second middleware that server-side renders your pages with vite-plugin-ssr
's const renderPage = createPageRender();
.)
Environment: Browser
, Node.js
Ext Glob: /**/*.page.*([a-zA-Z0-9])
A *.page.js
file should have a export { Page }
(or export default
).
Page
represents the page's view that is rendered to HTML / the DOM.
vite-plugin-ssr
doesn't do anything with Page
and just makes it available at pageContext.Page
.
// *.page.js
// Environment: Browser, Node.js
export { Page }
// We export a JSX component, but we could as well export a Vue/Svelte/... component,
// or even export some totally custom object since vite-plugin-ssr doesn't do anything
// with `Page`: it just passes it to your `render()` hook and to the client-side.
function Page() {
return <>Hello</>
}
// *.page.server.js
// Environment: Node.js
import { html } from 'vite-plugin-ssr'
import renderToHtml from 'some-view-framework'
export { render }
// `Page` is available at `pageContext.Page`
async function render(pageContext) {
const pageHtml = await renderToHtml(pageContext.Page)
return html`<html>
<body>
<div id="root">
${html.dangerouslySkipEscape(pageHtml)}
</div>
</body>
</html>`
}
// *.page.client.js
// Environment: Browser
import { getPage } from 'vite-plugin-ssr/client'
import { hydrateToDom } from 'some-view-framework'
hydrate()
async function hydrate() {
const pageContext = await getPage()
// `pageContext.Page` is available in the browser.
const { Page } = pageContext
await hydrateToDom(Page)
}
The .page.js
file is lazy-loaded: it is loaded only when needed which means that if no URL request were to match the page's route then .page.js
is not loaded in your Node.js process nor in the user's browser.
The .page.js
file is usually executed in both Node.js and the browser.
Built-in:
pageContext.Page
: theexport { Page }
orexport default
of the page's.page.js
file being rendered.pageContext.pageExports
: all exports of the page's.page.js
file being rendered.pageContext.routeParams
: the route parameters. (E.g.pageContext.routeParams.movieId
for a page with a Route String/movie/:movieId
.)pageContext.isHydration
: [only in the browser, and only if you use Client-side Routing] whether the page is being hydrated or a new page is being rendered.pageContext.url
: Theurl
you passed at your server integration point.// Server Integration Point const renderPage = createPageRender(/*...*/) app.get('*', async (req, res, next) => { const pageContext = {} // `pageContext.url` is defined here pageContext.url = req.url const result = await renderPage(pageContext) /* ... */ })
pageContext.urlNormalized
: same thanpageContext.url
but with removed URL Origin and Base URL. (E.g.pageContext.urlNormalized === '/product/42?details=yes#reviews'
forpageContext.url === 'https://example.org/some-base-url/product/42?details=yes#reviews'
.)pageContext.urlPathname
: the URL's pathname (after normalization). (E.g./product/42
forpageContext.url === 'https://example.org/some-base-url/product/42?details=yes#reviews'
).pageContext.urlParsed
:{ pathname, search, hash }
(after normalization). (E.g.{ pathname: 'product/42', search: { details: 'yes' }, hash: 'reviews' }
.)
Custom:
- The
pageContext
values you returned in your page'saddPageContext()
hook (if you defined one). - The
pageContext
values you returned in your_default.page.server.js
'saddPageContext()
hook (if you defined one). - The
pageContext
values you passed at your server integration point.// Server Integration Point const renderPage = createPageRender(/*...*/) app.get('*', async (req, res, next) => { const pageContext = { url: req.url, // We can add more `pageContext` here } const result = await renderPage(pageContext) /* ... */ })
By default only pageContext.Page
and pageContext.pageExports
are available in the browser;
use export const passToClient: string[]
to make more pageContext
available in the browser.
The pageContext
can be accessed at:
- [Node.js]
export function addPageContext(pageContext)
(*.page.server.js
) - [Node.js]
export function render(pageContext)
(*.page.server.js
) - [Node.js (& Browser)]
export default function routeFunction(pageContext)
(*.page.route.js
) - [Browser]
const pageContext = await getPage()
(import { getPage } from 'vite-plugin-ssr/client'
) - [Browser]
useClientRouter({ render(pageContext) })
(import { useClientRouter } from 'vite-plugin-ssr/client/router'
)
Environment: Node.js
Ext Glob: /**/*.page.server.*([a-zA-Z0-9])
The .page.server.js
file defines and exports
export { addPageContext }
export { passToClient }
export { render }
export { prerender }
The .page.server.js
file is lazy-loaded: it is loaded only when needed which means that if no URL request were to match the page's route then .page.server.js
is not loaded in your Node.js process' memory.
The .page.server.js
file is executed in Node.js and never in the browser.
The addPageContext()
hook is used to provide further pageContext
values.
The pageContext
is passed to all hooks (defined in .page.server.js
) and all Route Functions (defined in .page.route.js
).
You can provide initial pageContext
values at your server integration point createPageRender()
.
This is where you usually pass information about the authenticated user,
see Authentication guide.
The addPageContext()
hook is usually used with const passToClient: string[]
to fetch data, see Data Fetching guide.
Since addPageContext()
is always called in Node.js, ORM/SQL database queries can be used.
// /pages/movies.page.server.js
import fetch from "node-fetch";
export { addPageContext }
async function addPageContext(pageContext){
const response = await fetch("https://api.imdb.com/api/movies/")
const { movies } = await response.json()
/* Or with an ORM:
const movies = Movie.findAll() */
/* Or with SQL:
const movies = sql`SELECT * FROM movies;` */
return { movies }
}
You can tell vite-plugin-ssr
what pageContext
to send to the browser by using passToClient
.
The pageContext
is serialized and passed from the server to the browser with devalue
.
It is usally used with the addPageContext()
hook to fetch data:
data is fetched in async addPageContext()
and then made available to the browser with passToClient
.
// *.page.server.js
// Environment: Node.js
import fetch from "node-fetch";
export { passToClient }
// Example of `pageContext` often passed to the browser
const passToClient = [
'pageProps',
'routeParams',
// (Deep selection is not implemented yet; open a GitHub ticket if you want this.)
'user.id',
'user.name'
]
// *.page.client.js
// Environment: Browser
import { getPage } from 'vite-plugin-ssr/client'
hydrate()
async function hydrate() {
const pageContext = await getPage()
// Thanks to `passToClient`, these `pageContext` are available here in the browser
pageContext.pageProps
pageContext.routeParams
pageContext.user.id
pageContext.user.name
/* ... */
}
Or when using Client-side Routing:
// *.page.client.js
// Environment: Browser
import { useClientRouter } from 'vite-plugin-ssr/client/router'
useClientRouter({
render(pageContext) {
// Thanks to `passToClient`, these `pageContext` are available here in the browser
pageContext.pageProps
pageContext.routeParams
pageContext.user.id
pageContext.user.name
/* ... */
}
})
The render()
hook defines how a page is rendered to HTML.
It usually returns an HTML string, but it can also return something else than HTML which we talk more about down below.
// *.page.server.js
// Environment: Node.js
import { html } from 'vite-plugin-ssr'
import { renderToHtml, createElement } from 'some-view-framework'
export { render }
async function render(pageContext){
const { Page, pageProps } = pageContext
const pageHtml = await renderToHtml(createElement(Page, pageProps))
return html`<!DOCTYPE html>
<html>
<head>
<title>My SSR App</title>
</head>
<body>
<div id="page-root">${html.dangerouslySkipEscape(pageHtml)}</div>
</body>
</html>`
}
Page
is the export { Page }
(or export default
) of the .page.js
file being rendered.
The value renderResult
returned by your render()
hook doesn't have to be HTML:
vite-plugin-ssr
doesn't do anything with renderResult
and just passes it untouched at your server integration point createPageRender()
.
// *.page.server.js
export { render }
function render(pageContext) {
let renderResult
/* ... */
return renderResult
}
// server.js
const renderPage = createPageRender(/*...*/)
app.get('*', async (req, res, next) => {
const result = await renderPage({ url: req.originalUrl })
// `result.renderResult` is the value returned by your `render()` hook.
const { renderResult } = result
/* ... */
})
Your render()
hook can for example return an object like { redirectTo: '/some/url' }
in order to do Page Redirection.
*️⃣ Check out the Pre-rendering Guide to get an overview about pre-rendering.
The prerender()
hook enables parameterized routes (e.g. /movie/:movieId
) to be pre-rendered:
by defining the prerender()
hook you provide the list of URLs (/movie/1
, /movie/2
, ...) and (optionally) the pageContext
of each URL.
If you don't have any parameterized route,
then you can prerender your app without defining any prerender()
hook.
You can, however, still use the prerender()
hook
to increase the effeciency of pre-rendering as
it enables you to fetch data for multiple pages at once.
// /pages/movie.page.route.js
// Environment: Node.js
export default '/movie/:movieId`
// /pages/movie.page.server.js
// Environment: Node.js
export { prerender }
async function prerender() {
const movies = await Movie.findAll()
const moviePages = (
movies
.map(movie => {
const url = `/movie/${movie.id}`
const pageContext = { movie }
return {
url,
// Beacuse we already provide the `pageContext`, vite-plugin-ssr will *not* call
// any `addPageContext()` hook for `url`.
pageContext
}
// We could also only return `url`. In that case vite-plugin-ssr would call
// `addPageContext()`. But that would be wasteful since we already have all
// the data of all movies from our `await Movie.findAll()` call.
// return { url }
})
)
// We can also return URLs that don't match the page's route.
// That way we can provide the `pageContext` of other pages.
// Here we provide the `pageContext` of the `/movies` page since
// we already have the data.
const movieListPage = {
url: '/movies', // The `/movies` URL doesn't belong to the page's route `/movie/:movieId`
pageContext: {
movieList: movies.map(({id, title}) => ({id, title})
}
}
return [movieListPage, ...moviePages]
}
The prerender()
hook is only used when pre-rendering:
if you don't call
vite-plugin-ssr prerender
then no prerender()
hook is called.
Vue Example:
- /examples/vue-full/package.json (see the
build:prerender
script) - /examples/vue-full/pages/star-wars/index.page.server.ts (see the
prerender()
hook) - /examples/vue-full/pages/hello/index.page.server.ts (see the
prerender()
hook)
React Example:
- /examples/react-full/package.json#build:prerender (see the
build:prerender
script) - /examples/react-full/pages/star-wars/index.page.server.ts (see the
prerender()
hook) - /examples/react-full/pages/hello/index.page.server.ts (see the
prerender()
hook)
Environment: Node.js
The html
tag sanitizes HTML (to prevent XSS injections).
It is usually used in your render()
hook defined in .page.server.js
.
// *.page.server.js
// Environment: Node.js
import { html } from 'vite-plugin-ssr'
export { render }
async function render() {
const title = 'Hello<script src="https://devil.org/evil-code"></script>'
const pageHtml = "<div>I'm already <b>sanitized</b>, e.g. by Vue/React</div>"
// This HTML is safe thanks to the string template tag `html` which sanitizes `title`
return html`<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
</head>
<body>
<div id="page-root">${html.dangerouslySkipEscape(pageHtml)}</div>
</body>
</html>`
}
All strings, e.g. title
, are automatically sanitized (technically speaking: HTML-escaped)
so that you can safely include untrusted strings
such as user-generated text.
The html.dangerouslySkipEscape(str)
function injects the string str
as-is without sanitizing.
It should be used with caution and
only for HTML strings that are guaranteed to be already sanitized.
It is usually used to include HTML generated by React/Vue/Solid/... as these frameworks always generate sanitized HTML.
If you find yourself using html.dangerouslySkipEscape()
in other situations be extra careful as you run into the risk of creating a security breach.
You can assemble the overall HTML document from several pieces of HTML segments. For example, when you want some HTML parts to be included only for certain pages:
// _default.page.server.js
// Environment: Node.js
import { html } from 'vite-plugin-ssr'
import { renderToHtml } from 'some-view-framework'
export { render }
async function render(pageContext) {
// We only include the `<meta name="description">` tag if the page has a description.
// (Pages define `pageContext.documentProps.description` with their `addPageContext()` hook.)
const description = pageContext.documentProps?.description
let descriptionTag = ''
if( description ) {
// Note how we use the `html` string template tag for an HTML segment.
descriptionTag = html`<meta name="description" content="${description}">`
}
// We use the `html` tag again for the overall HTML, and since `descriptionTag` is
// already sanitized it will not be sanitized again.
return html`<html>
<head>
${descriptionTag}
</head>
<body>
<div id="root">
${html.dangerouslySkipEscape(await renderToHtml(pageContext.Page))}
</div>
</body>
</html>`
}
Environment: Browser
Ext Glob: /**/*.page.client.*([a-zA-Z0-9])
The .page.client.js
file defines the page's browser-side code.
It represents the entire browser-side code. This means that if you create an empty .page.client.js
file, then the page has zero browser-side JavaScript.
(Except of Vite's dev code when not in production.)
This also means that you have full control over the browser-side code. Not only can you render/hydrate your pages as you wish, but you can also easily & naturally integrate browser libraries.
// *.page.client.js
import { getPage } from 'vite-plugin-ssr/client'
import { hydrateToDom, createElement } from 'some-view-framework'
import GoogleAnalytics from '@brillout/google-analytics'
main()
async function main() {
analytics_init()
analytics.event('[hydration] begin')
await hydrate()
analytics.event('[hydration] end')
}
async function hydrate() {
const pageContext = await getPage()
const { Page, pageProps } = pageContext
await hydrateToDom(
createElement(Page, pageProps),
document.getElementById('view-root')
)
}
let analytics
function analytics_init() {
analytics = new GoogleAnalytics('UA-121991291')
}
Environment: Browser
You use async getPage()
to get pageContext.Page
and furhter pageContext
in the browser-side.
// *.page.client.js
import { getPage } from 'vite-plugin-ssr/client'
hydrate()
async function hydrate() {
const pageContext = await getPage()
/* ... */
}
pageContext.Page
is theexport { Page }
(orexport default
) of the/pages/demo.page.js
file.pageContext
is a subset of thepageContext
defined on the server-side; thepassToClient
determines whatpageContext
is sent to the browser.
The pageContext
is serialized and passed from the server to the browser with devalue
.
In development getPage()
dynamically import()
the page, while in production the page is preloaded (with <link rel="preload">
).
Environment: Browser
By default, vite-plugin-ssr
does Server-side Routing.
You can do Client-side Routing instead by using useClientRouter()
in your _default.page.client.js
(or in a page's *.page.client.js
).
// _default.page.client.js
// Environment: Browser
import { renderToDom, hydrateToDom, createElement } from 'some-view-framework'
import { useClientRouter } from 'vite-plugin-ssr/client/router'
const { hydrationPromise } = useClientRouter({
async render(pageContext) {
const page = createElement(pageContext.Page, pageContext.pageProps)
const container = document.getElementById('page-view')
// Render the page
if (pageContext.isHydration) {
// This is the first page rendering; the page has been rendered to HTML
// and we now make it interactive.
await hydrateToDom(page)
} else {
// Render a new page
await renderToDom(page)
}
// We use `pageContext.documentProps.title` to update `<title>`.
// (Make sure to add it to `export const passToClient = ['pageProps, 'documentProps']`,
// and your pages can then return `documentProps` in their `addPageContext()` hook.)
document.title =
pageContext.documentProps?.title ||
// A default title
'Demo'
},
onTransitionStart,
onTransitionEnd
})
hydrationPromise.then(() => {
console.log('Hydration finished; page is now interactive.')
})
function onTransitionStart() {
console.log('Page transition start')
// For example:
document.body.classList.add('page-transition')
}
function onTransitionEnd() {
console.log('Page transition end')
// For example:
document.body.classList.remove('page-transition')
}
You can keep your <a href="/some-url">
links as they are: link clicks are intercepted.
You can also use
import { navigate } from 'vite-plugin-ssr/client/router'
to programmatically navigate your user to a new page.
By default, the Client-side Router scrolls the page to the top upon page transitions;
use <a keep-scroll-position />
/ navigate(url, { keepScrollPosition: true })
if you want to preserve the scroll position instead. (Useful for Nested Routes.)
useClientRouter()
is fairly high-level, if you need lower-level control, then open a GitHub issue.
Vue example:
- /examples/vue-full/pages/_default/_default.page.client.ts
- /examples/vue-full/pages/_default/app.ts
- /examples/vue-full/pages/index.page.vue (example of using
import { navigate } from "vite-plugin-ssr/client/router"
)
React example:
- /examples/react-full/pages/_default/_default.page.client.tsx
- /examples/react-full/pages/index.page.tsx (example of using
import { navigate } from "vite-plugin-ssr/client/router"
)
Environment: Browser
, Node.js
. (In Node.js navigate()
is importable but not callable.)
You can use navigate('/some-url')
to programmatically navigate your user to another page (i.e. when navigation isn't triggered by the user clicking on an anchor tag <a>
).
For example, you can use navigate()
to redirect your user after a successful form submission.
import { navigate } from "vite-plugin-ssr/client/router";
// Some deeply nested view component
function Form() {
return (
<form onSubmit={onSubmit}>
{/*...*/}
</form>
);
}
async function onSubmit() {
/* ... */
const navigationPromise = navigate('/form/success');
console.log("The URL changed but the new page hasn't rendered yet.");
await navigationPromise
console.log("The new page has finished rendering.");
}
While you can import navigate()
in Node.js, you cannot call it: calling navigate()
in Node.js throws a [Wrong Usage]
error.
(vite-plugin-ssr
allows you to import navigate()
in Node.js because with SSR your view components are loaded in the browser as well as Node.js.)
If you want to redirect your user at page-load time, see the Page Redirection guide.
Options:
navigate('/some-url', { keepScrollPosition: true })
: Do not scroll to the top of the page; keep scroll position where it is instead. (Useful for Nested Routes.) (You can also use<a href="/some-url" keep-scroll-position />
.)
Vue example:
React example:
Environment: Node.js
(and Browser
if you call useClientRouter()
)
Ext Glob: /**/*.page.route.*([a-zA-Z0-9])
The *.page.route.js
files enable further control over routing with:
- Route Strings
- Route Functions
For a page /pages/film.page.js
, a Route String can be defined in a /pages/film.page.route.js
adjacent file.
// /pages/film.page.route.js
// Match URLs `/film/1`, `/film/2`, ...
export default '/film/:filmId'
If the URL matches, the value of filmId
is available at pageContext.routeParams.filmId
.
The syntax of Route Strings is based on path-to-regexp
(the most widespread route syntax in JavaScript).
For user friendlier docs, check out the Express.js Routing Docs
(Express.js uses path-to-regexp
).
Route Functions give you full programmatic flexibility to define your routing logic.
// /pages/film/admin.page.route.js
export default (pageContext) => {
// Route Functions allow us to implement advanced routing such as route guards.
if (! pageContext.user.isAdmin) {
return false
}
const { url } = pageContext
// We can use RegExp and any JavaScript tool we want.
if (! /\/film\/[0-9]+\/admin/.test(url)) {
return { match: false } // equivalent to `return false`
}
filmId = url.split('/')[2]
return {
match: true,
// We make `filmId` available at `pageContext.routeParams.filmId`
routeParams: { filmId }
}
}
The match
value can be a (negative) number which enables you to resolve route conflicts;
the higher the number, the higher the priority.
By default vite-plugin-ssr
does Filesystem Routing: the URL of the page is determined base on where its .page.js
file is located.
FILESYSTEM URL COMMENT
pages/about.page.js /about
pages/index/index.page.js / (`index` is mapped to the empty string)
pages/HELLO.page.js /hello (Mapping is done lower case)
In the above example, the common ancestor directory, which vite-plugin-ssr
considers the routing root, is pages/
.
It doesn't have to be pages/
and you can save your .page.js
files wherever you want. For example:
FILESYSTEM URL
user/list.page.js /user/list
user/create.page.js /user/create
todo/list.page.js /todo/list
todo/create.page.js /todo/create
You can also move your page files in a src/
directory.
FILESYSTEM URL
src/pages/index.page.js /
src/pages/about.page.js /about
The common ancestor directory here is src/pages/
.
For more control over routing, define Route Strings or Route Functions in *.page.route.js
.
The _default.page.server.js
and _default.page.client.js
files are like regular .page.server.js
and .page.client.js
files, but they are special in the sense that they don't apply to a single page file; instead, they apply as a default to all pages.
There can be several _default
:
marketing/_default.page.server.js
marketing/_default.page.client.js
marketing/index.page.js
marketing/about.page.js
marketing/jobs.page.js
admin-panel/_default.page.server.js
admin-panel/_default.page.client.js
admin-panel/index.page.js
The marketing/_default.page.*
files apply to the marketing/*.page.js
files, while
the admin-panel/_default.page.*
files apply to the admin-panel/*.page.js
files.
The _default.page.server.js
and _default.page.client.js
files are not adjacent to any .page.js
file:
defining _default.page.js
or _default.page.route.js
is forbidden.
The page _error.page.js
is used for when an error occurs:
- When no page matches the URL (acting as a
404
page). - When one of your
.page.*
files throws an error (acting as a500
page).
vite-plugin-ssr
automatically sets pageContext.pageProps.is404: boolean
allowing you to decided whether to show a 404
or 500
page.
(Normally pageContext.pageProps
is completely defined/controlled by you and vite-plugin-ssr
's source code doesn't know anything about pageContext.pageProps
but this is the only exception.)
You can define _error.page.js
like any other page and create _error.page.client.js
and _error.page.server.js
.
Environment: Node.js
createPageRender()
is the integration point between your server and vite-plugin-ssr
.
// server/index.js
// In this example we use Express.js but we could use any other server framework.
const express = require('express')
const { createPageRender } = require('vite-plugin-ssr')
const isProduction = process.env.NODE_ENV === 'production'
const root = `${__dirname}/..`
const base = '/'
startServer()
async function startServer() {
const app = express()
let viteDevServer
if (isProduction) {
app.use(express.static(`${root}/dist/client`, { index: false }))
} else {
const vite = require('vite')
viteDevServer = await vite.createServer({
root,
server: { middlewareMode: true }
})
app.use(viteDevServer.middlewares)
}
const renderPage = createPageRender({ viteDevServer, isProduction, root, base })
app.get('*', async (req, res, next) => {
const url = req.originalUrl
const pageContext = { url }
const result = await renderPage(pageContext)
if (result.nothingRendered) return next()
res.status(result.statusCode).send(result.renderResult)
})
const port = 3000
app.listen(port)
console.log(`Server running at http://localhost:${port}`)
}
viteDevServer
is the Vite dev server.isProduction
is a boolean. When set totrue
,vite-plugin-ssr
loads already-transpiled code fromdist/
instead of on-the-fly transpiling code.root
is the absolute path of your app's root directory. Theroot
directory is usally the directory wherevite.config.js
lives. Make sure that all your.page.js
files are descendent of theroot
directory.base
is the Base URL.result.nothingRendered
istrue
when a) an error occurred while rendering_error.page.js
, or b) you didn't define an_error.page.js
and no.page.js
matches theurl
.result.statusCode
is either200
,404
, or500
.result.renderResult
is the value returned by therender()
hook.
Since createPageRender()
and renderPage()
are agnostic to Express.js, you can use vite-plugin-ssr
with any server framework (Koa, Hapi, Fastify, vanilla Node.js, ...) and any deploy environment such as Cloudflare Workers.
Examples:
Environment: Node.js
The plugin has no options.
// vite.config.js
const ssr = require('vite-plugin-ssr/plugin')
module.exports = {
// Make sure to include `ssr()` and not `ssr`.
plugins: [ssr()]
}
The command prerender
does pre-rendering, see Pre-rendering.
It can be called:
- As CLI command:
$ npx vite-plugin-ssr prerender
/$ yarn vite-plugin-ssr prerender
. - As JavaScript API:
import { prerender } from 'vite-plugin-ssr/cli
.
Options:
partial
: Allow only a subset of pages to be pre-rendered. (Pages with a parameterized route cannot be pre-rendered withoutprerender()
hook; the--partial
option suppresses the warning telling you about pages not being pre-rendered.)root
: The root directory of your project (wherevite.config.js
anddist/
live). Default:process.cwd()
.
Options are passed like this:
- CLI:
$ vite-plugin-ssr prerender --partial --root path/to/root
- API:
prerender({ partial: true, root: 'path/to/root' })