☰ SSR & Hydration

VanJS: Fullstack Rendering (SSR, CSR and Hydration)

Requires VanJS 1.2.0 or later, and Mini-Van 0.4.0 or later.

VanJS offers a seamless and framework-agnostic solution for fullstack rendering. We will provide a walkthrough for a sample application with SSR (server-side rendering), CSR (client-side rendering) and hydration. As an outline, here are the major steps we're going to take to build the sample application:

  1. Define common UI components that can be shared on both server-side and client-side.
  2. Implement server-side script with the help of Mini-Van for serving the HTML content to end users.
  3. Implement client-side script with the help of VanJS for adding client-side components and enabling hydration.

The sample application requires a bare minimum of dependencies. The server-side script can be run by Node.js. We can also build a fullstack application with other JavaScript runtime like Deno or Bun. Other front-end frameworks like Vite or Astro are not required, but it should be easy to integrate with them.

The source code of the sample application can be found here with the following directory structure:

  • hydration-example: Root of the sample application.
    • src: Source files of the application.
      • components: Common components that are shared on both server-side and client-side.
      • server.ts: server-side script to serve the HTML content.
      • client.ts: client-side script for client-side components and hydration.
    • dist: Bundled (and minified) client-side .js files.
    • package.json: Basic information of the application. Primarily, it defines the NPM dependencies.

You can preview the sample application via CodeSandbox.

A Bun-based variation of this example can be found here.

package-lock.json File


Dependencies are declared in package.json file:

  "dependencies": {
    "finalhandler": "^1.2.0",
    "mini-van-plate": "^0.5.3",
    "serve-static": "^1.15.0",
    "vanjs-core": "^1.3.0"
  }

Shared UI Components


Now, let's build some shared UI components that can run on both server-side and client-side.

Static Component

First, let's take a look at a static (non-reactive) component - Hello:

import { VanObj } from "mini-van-plate/shared"

interface Props {
  van: VanObj
}

export default ({van} : Props) => {
  const {a, div, li, p, ul} = van.tags

  const fromServer = typeof window === "undefined"
  return div(
    p(() => `πŸ‘‹Hello (from ${fromServer ? "server" : "client"})`),
    ul(
      li("πŸ—ΊοΈWorld"),
      li(a({href: "https://vanjs.org/"}, "🍦VanJS")),
    ),
  )
}

Compared to the Hello component in the "VanJS by Example" page, there are following notable differences:

  • The shared Hello component takes a van object as its input property. This is crucial to make Hello component cross-platform. Callers are responsible for providing the van object based on what's available in their specific environment so that the component can be agnostic to the execution environment. On the server-side, the van object from Mini-Van will be used (we can choose the van object from van-plate mode or from mini-van mode), whereas on the client-side, the van object from VanJS will be used.
  • We can determine if the component is being rendered on the server-side or client-side:
    const fromServer = typeof window === "undefined"
    and show different content based on it:
    p(() => `πŸ‘‹Hello (from ${fromServer ? "server" : "client"})
    This will help us differentiate whether the component is rendered from server or from client.

To help with typechecking if you're working with TypeScript, you can import the VanObj type from mini-van-plate/shared (part of the Mini-Van package: source file).

Limitations: The typechecking for tag functions and van.add is quite limited. This is because it's hard to unify the type system across the common types between server-side and client-side.

Reactive Component

Next, let's take a look at a reactive component - Counter:

import { VanObj, State } from "mini-van-plate/shared"

interface Props {
  van: VanObj
  id?: string
  init?: number
  buttonStyle?: string | State<string>
}

export default ({
  van, id, init = 0, buttonStyle = "πŸ‘πŸ‘Ž",
}: Props) => {
  const {button, div} = van.tags

  const [up, down] = [...van.val(buttonStyle)]
  const counter = van.state(init)
  return div({...(id ? {id} : {}), "data-counter": counter},
    "❀️ ", counter, " ",
    button({onclick: () => ++counter.val}, up),
    button({onclick: () => --counter.val}, down),
  )
}

Notable differences from the Counter component in the "VanJS by Example" page:

  • Similar to the Hello component, it takes a van object as its input property to make the component environment-agnostic.
  • You can define states and bind states to DOM nodes as you normally do on the client-side. This is because in Mini-Van 0.4.0 release, we adjusted its implementation to make it compatible to states and state-bindings related API, though with the absence of reactively (i.e.: changing a state won't lead to the update of the DOM tree), which is only possible on the client-side after hydration.
  • You can optionally specify the ID of the component with the id property. This is helpful to locate the component while hydrating.
  • You can optionally specify the initial counter value (default: 0) with the init property.
  • You can optionally specify the style of the increment/decrement buttons. As illustrated later, we will see how to make the button style of the Counter component reactive to user selection.
  • We keep the data-counter attribute of the component in sync with the current value of the counter. This will help us keep the counter value while hydrating.

Server-Side Script: HTML Template


Now, let's build the server-side script that enables SSR:

import { createServer } from "node:http"
import { parse } from "node:url"
import serveStatic from "serve-static"
import finalhandler from "finalhandler"
import van from "mini-van-plate/van-plate"
import Hello from "./components/hello.js"
import Counter from "./components/counter.js"

const {body, div, h1, h2, head, link, meta, option, p, script, select, title} = van.tags

const [env, port = 8080] = process.argv.slice(2);

const serveFile = serveStatic(".")

createServer((req, res) => {
  if (req.url?.endsWith(".js")) return serveFile(req, res, finalhandler(req, res))
  const counterInit = Number(parse(req.url!, true).query["counter-init"] ?? 0)
  res.statusCode = 200
  res.setHeader('Content-Type', 'text/html; charset=utf-8')
  res.end(van.html(
    head(
      link({rel: "icon", href: "logo.svg"}),
      title("SSR and Hydration Example"),
      meta({name: "viewport", content: "width=device-width, initial-scale=1"}),
    ),
    body(
      script({type: "text/javascript", src: `dist/client.bundle${env === "dev" ? "" : ".min"}.js`, defer: true}),
      h1("Hello Components"),
      div({id: "hello-container"},
        Hello({van}),
      ),
      h1("Counter Components"),
      div({id: "counter-container"},
        h2("Basic Counter"),
        Counter({van, id: "basic-counter", init: counterInit}),
        h2("Styled Counter"),
        p("Select the button style: ",
          select({id: "button-style", value: "πŸ‘†πŸ‘‡"},
            option("πŸ‘†πŸ‘‡"),
            option("πŸ‘πŸ‘Ž"),
            option("πŸ”ΌπŸ”½"),
            option("⏫⏬"),
            option("πŸ“ˆπŸ“‰"),
          ),
        ),
        Counter({van, id: "styled-counter", init: counterInit, buttonStyle: "πŸ‘†πŸ‘‡"}),
      ),
    )
  ))
}).listen(Number(port), () => console.log(`Try visiting the server via http://localhost:${port}.
Also try http://localhost:${port}?counter-init=5 to set the initial value of the counters.`))

The script implements a basic HTTP server with the built-in node:http module (no web framework needed). You will probably first notice this line:

if (req.url?.endsWith(".js")) return serveFile(req, res, finalhandler(req, res))

This is to tell the HTTP server to serve the file statically if any .js file is requested.

The bulk of the script is declaring the DOM structure of the page that is enclosed in van.html(...). As you can see, the expressiveness of tag functions enable us to declare the entire HTML page, including everything in the <head> section and <body> section.

The code declares an HTML page with one Hello component and two Counter components - one with the default button style, and the other whose button style can be selected by the user. Here are a few interesting things to note:

  • The line:
    script({type: "text/javascript", src: `dist/client.bundle${env === "dev" ? "" : ".min"}.js`, defer: true})
    indicates that we're choosing different JavaScript bundle files under different modes: client.bundle.js in dev mode whereas client.bundle.min.js in prod mode. It makes sense to use original client-side script during development and use the minified script in production.
  • We're allowing users to set the initial value of the counters via query parameters. Specifically, the line:
    const counterInit = Number(parse(req.url!, true).query["counter-init"] ?? 0)
    and line:
    Counter({van, id: "basic-counter", init: counterInit})
    enable that.
  • We're choosing van-plate mode as SSR is done with pure text templating without any DOM manipulation. If you want some DOM manipulation for your SSR, you can choose mini-van mode instead.

Client-Side Script: CSR and Hydration


The final step is to complete the client-side script file.

Client-Side Component

First, let's try to add a client-side component:

van.add(document.getElementById("hello-container")!, Hello({van}))

This will append a CSR Hello component right after the SSR Hello component. You can tell whether the component is rendered on the server-side or on the client-side by checking whether the text is πŸ‘‹Hello (from server) or πŸ‘‹Hello (from client).

Hydration

Next, let's hydrate the counter components rendered on the server side to add the reactivity. We can use van.hydrate to achieve that:

van.hydrate(document.getElementById("basic-counter")!, dom => Counter({
  van,
  id: dom.id,
  init: Number(dom.getAttribute("data-counter")),
}))

van.hydrate replaces the SSR component (located by document.getElementById("basic-counter")!) with the CSR Counter component. Note that the 2nd argument of van.hydrate is the hydration function that takes the existing DOM node as its parameter and returns the new hydrated component. This way we can get the current state of SSR component (via Number(dom.getAttribute("data-counter"))) and pass-in the information while constructing the hydrated component, which keeps the counter value the same after hydration.

In the hydration function, you can read the val property of external states. In this way, the hydrated component will be a State-derived node, i.e.: a DOM node that will be updated whenever its dependency states change. Now, with that, let's build a Counter component whose button style can be adjusted by end users. First, let's define a state buttonStyle whose val is bound to the value of the #button-style <select> element:

const styleSelectDom = <HTMLSelectElement>document.getElementById("button-style")
const buttonStyle = van.state(styleSelectDom.value)
styleSelectDom.oninput = e => buttonStyle.val = (<HTMLSelectElement>e.target).value

Next, let's make the hydrated Counter component reactive to buttonStyle state:

van.hydrate(document.getElementById("styled-counter")!, dom => Counter({
  van,
  id: dom.id,
  init: Number(dom.getAttribute("data-counter")),
  buttonStyle,
}))

Since buttonStyle is passed into the Counter component where its val property is referenced, the hydrated Counter component will be reactive to the change of buttonStyle state.

Note that, this is an illustrative example to show how to make the entire hydrated component reactive to external states. In practice, the implementation of Counter component can be optimized to only make the <button>s' child text nodes of the Counter component reactive to buttonStyle state. This can be achieved by binding more localized DOM nodes (i.e.: the child text nodes of <button>s) to the buttonStyle state. You can check out the implementation below for an optimized Counter component:

import { VanObj, State } from "mini-van-plate/shared"

interface Props {
  van: VanObj
  id?: string
  init?: number
  buttonStyle?: string | State<string>
}

export default ({
  van, id, init = 0, buttonStyle = "πŸ‘πŸ‘Ž",
}: Props) => {
  const {button, div} = van.tags

  const counter = van.state(init)
  return div({...(id ? {id} : {}), "data-counter": counter},
    "❀️ ", counter, " ",
    button({onclick: () => ++counter.val}, () => [...van.val(buttonStyle)][0]),
    button({onclick: () => --counter.val}, () => [...van.val(buttonStyle)][1]),
  )
}

API reference: van.hydrate

Signaturevan.hydrate(dom, f) => undefined
DescriptionHydrates the SSR component dom with the hydration function f.
Parameters
  • dom - The root DOM node of the SSR component we want to hydrate.
  • f - The hydration function, which takes a DOM node as its input parameter and returns the new version of the DOM node. The hydration function describes how we want to convert an existing DOM node into a new one with added reactivity. If the val property of any states are referenced in the hydration function, the hydrated component will be bound to the dependency states (i.e.: reactive to the changes of the referenced states). In this case, the behavior of the hydrated component will be similar to a State-derived child node.
Returnsundefined

Demo


Now, let's check out what we have built so far. You can preview the application via CodeSandbox. Alternatively, you can build and deploy application locally by following the steps below:

  1. Clone the GitHub repo:
    git clone https://github.com/vanjs-org/vanjs-org.github.io.git
  2. Go to the directory for the demo:
    cd vanjs-org.github.io/hydration-example
  3. Install NPM packages:
    npm install
  4. Launch the development server:
    npm run dev
    You will see something like this in the terminal:
    Try visiting the server via http://localhost:8080.
          Also try http://localhost:8080?counter-init=5 to set the initial value of the counter.
    
  5. By clicking the links printed in the terminal, you will go to the demo page.
  6. You can build the bundle for production with:
    npm run build

Let's go to the demo page now. You will probably first notice the Hello components of the demo:

You can see an SSR Hello component followed by a CSR Hello component.

The second part of the demo page is for hydrating the Counter components. In real-world use cases, hydration typically happens immediately after the page load, or when the application is idle. But if we do that in our sample application, hydration will happen so fast that we won't even be able to notice how hydration happens. Thus, for illustration purpose, we introduce a <button> where hydration only happens upon user click:

van.add(document.getElementById("counter-container")!, p(button({onclick: hydrate}, "Hydrate")))

As a result, the second part of the demo will look like this:

You can verified that all the Counter components are non-reactive before the Hydrate button is clicked and can be turned reactive upon clicking the Hydrate button.

The End


πŸŽ‰ Congratulations! You have completed the walkthrough for fullstack rendering. With the knowledge you have learned, you will be able to build sophisticated applications that take advantage of SSR, CSR and hydration.