Skip to content

Server-Side Rendering

Note

SSR specifically refers to front-end frameworks (for example React and Kdu) that support running the same application in Node.js, pre-rendering it to HTML, and finally hydrating it on the client. If you are looking for integration with traditional server-side frameworks, check out the Backend Integration guide instead.

The following guide also assumes prior experience working with SSR in your framework of choice, and will only focus on Wite-specific integration details.

Low-level API

This is a low-level API meant for library and framework authors.

Source Structure

A typical SSR application will have the following source file structure:

- index.html
- server.js # main application server
- src/
  - main.js          # exports env-agnostic (universal) app code
  - entry-client.js  # mounts the app to a DOM element
  - entry-server.js  # renders the app using the framework's SSR API

The index.html will need to reference entry-client.js and include a placeholder where the server-rendered markup should be injected:

<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.js"></script>

You can use any placeholder you prefer instead of <!--ssr-outlet-->, as long as it can be precisely replaced.

Conditional Logic

If you need to perform conditional logic based on SSR vs. client, you can use

if (import.meta.env.SSR) {
  // ... server only logic
}

This is statically replaced during build so it will allow tree-shaking of unused branches.

Setting Up the Dev Server

When building an SSR app, you likely want to have full control over your main server and decouple Wite from the production environment. It is therefore recommended to use Wite in middleware mode. Here is an example with express:

server.js

















 
 
 










import fs from 'fs'
import path from 'path'
import express from 'express'
import {createServer as createWiteServer} from 'wite'

async function createServer() {
  const app = express()

  // Create Wite server in middleware mode and configure the app type as
  // 'custom', disabling Wite's own HTML serving logic so parent server
  // can take control
  const wite = await createWiteServer({
    server: { middlewareMode: true },
    appType: 'custom'
  })

  // use wite's connect instance as middleware
  // if you use your own express router (express.Router()), you should use router.use
  app.use(wite.middlewares)

  app.use('*', async (req, res) => {
    // serve index.html - we will tackle this next
  })

  app.listen(5173)
}

createServer()

Here wite is an instance of WiteDevServer. wite.middlewares is a Connect instance which can be used as a middleware in any connect-compatible Node.js framework.

The next step is implementing the * handler to serve server-rendered HTML:

app.use('*', async (req, res, next) => {
  const url = req.originalUrl

  try {
    // 1. Read index.html
    let template = fs.readFileSync(
      path.resolve(__dirname, 'index.html'),
      'utf-8'
    )

    // 2. Apply Wite HTML transforms. This injects the Wite HMR client, and
    //    also applies HTML transforms from Wite plugins, e.g. global preambles
    //    from @witejs/plugin-react
    template = await wite.transformIndexHtml(url, template)

    // 3. Load the server entry. wite.ssrLoadModule automatically transforms
    //    your ESM source code to be usable in Node.js! There is no bundling
    //    required, and provides efficient invalidation similar to HMR.
    const { render } = await wite.ssrLoadModule('/src/entry-server.js')

    // 4. render the app HTML. This assumes entry-server.js's exported `render`
    //    function calls appropriate framework SSR APIs,
    //    e.g. ReactDOMServer.renderToString()
    const appHtml = await render(url)

    // 5. Inject the app-rendered HTML into the template.
    const html = template.replace(`<!--ssr-outlet-->`, appHtml)

    // 6. Send the rendered HTML back.
    res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
  } catch (e) {
    // If an error is caught, let Wite fix the stack trace so it maps back to
    // your actual source code.
    wite.ssrFixStacktrace(e)
    next(e)
  }
})

The dev script in package.json should also be changed to use the server script instead:

  "scripts": {
-   "dev": "wite"
+   "dev": "node server"
  }

Building for Production

To ship an SSR project for production, we need to:

  1. Produce a client build as normal;
  2. Produce an SSR build, which can be directly loaded via import() so that we don't have to go through Wite's ssrLoadModule;

Our scripts in package.json will look like this:

{
  "scripts": {
    "dev": "node server",
    "build:client": "wite build --outDir dist/client",
    "build:server": "wite build --outDir dist/server --ssr src/entry-server.js "
  }
}

Note the --ssr flag which indicates this is an SSR build. It should also specify the SSR entry.

Then, in server.js we need to add some production specific logic by checking process.env.NODE_ENV:

  • Instead of reading the root index.html, use the dist/client/index.html as the template instead, since it contains the correct asset links to the client build.

  • Instead of await wite.ssrLoadModule('/src/entry-server.js'), use import('./dist/server/entry-server.js') instead (this file is the result of the SSR build).

  • Move the creation and all usage of the wite dev server behind dev-only conditional branches, then add static file serving middlewares to serve files from dist/client.

Generating Preload Directives

wite build supports the --ssrManifest flag which will generate ssr-manifest.json in build output directory:

- "build:client": "wite build --outDir dist/client",
+ "build:client": "wite build --outDir dist/client --ssrManifest",

The above script will now generate dist/client/ssr-manifest.json for the client build (Yes, the SSR manifest is generated from the client build because we want to map module IDs to client files). The manifest contains mappings of module IDs to their associated chunks and asset files.

To leverage the manifest, frameworks need to provide a way to collect the module IDs of the components that were used during a server render call.

@witejs/plugin-kdu supports this out of the box and automatically registers used component module IDs on to the associated Kdu SSR context:

// src/entry-server.js
const ctx = {}
const html = await kduServerRenderer.renderToString(app, ctx)
// ctx.modules is now a Set of module IDs that were used during the render

In the production branch of server.js we need to read and pass the manifest to the render function exported by src/entry-server.js. This would provide us with enough information to render preload directives for files used by async routes!

Pre-Rendering / SSG

If the routes and the data needed for certain routes are known ahead of time, we can pre-render these routes into static HTML using the same logic as production SSR. This can also be considered a form of Static-Site Generation (SSG).

SSR Externals

Dependencies are "externalized" from Wite's SSR transform module system by default when running SSR. This speeds up both dev and build.

If a dependency needs to be transformed by Wite's pipeline, for example, because Wite features are used untranspiled in them, they can be added to ssr.noExternal.

Working with Aliases

If you have configured aliases that redirects one package to another, you may want to alias the actual node_modules packages instead to make it work for SSR externalized dependencies. Both Yarn and pnpm support aliasing via the npm: prefix.

SSR-specific Plugin Logic

Some frameworks such as Kdu compiles components into different formats based on client vs. SSR. To support conditional transforms, Wite passes an additional ssr property in the options object of the following plugin hooks:

  • resolveId
  • load
  • transform

Example:

export function mySSRPlugin() {
  return {
    name: 'my-ssr',
    transform(code, id, options) {
      if (options?.ssr) {
        // perform ssr-specific transform...
      }
    }
  }
}

The options object in load and transform is optional, rollup is not currently using this object but may extend these hooks with additional metadata in the future.

Note

Before Wite 2.7, this was informed to plugin hooks with a positional ssr param instead of using the options object. All major frameworks and plugins are updated but you may find outdated posts using the previous API.

SSR Target

The default target for the SSR build is a node environment, but you can also run the server in a Web Worker. Packages entry resolution is different for each platform. You can configure the target to be Web Worker using the ssr.target set to 'webworker'.

SSR Bundle

In some cases like webworker runtimes, you might want to bundle your SSR build into a single JavaScript file. You can enable this behavior by setting ssr.noExternal to true. This will do two things:

  • Treat all dependencies as noExternal
  • Throw an error if any Node.js built-ins are imported

Wite CLI

The CLI commands $ wite dev and $ wite preview can also be used for SSR apps. You can add your SSR middlewares to the development server with configureServer and to the preview server with configurePreviewServer.

Note

Use a post hook so that your SSR middleware runs after Wite's middlewares.

SSR Format

By default, Wite generates the SSR bundle in ESM. There is experimental support for configuring ssr.format, but it isn't recommended. Future efforts around SSR development will be based on ESM, and commonjs remain available for backward compatibility. If using ESM for SSR isn't possible in your project, you can set legacy.buildSsrCjsExternalHeuristics: true to generate a CJS bundle using the same externalization heuristics of Wite w2.

Released under the MIT License.