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:
- Define common UI components that can be shared on both server-side and client-side.
- Implement server-side script with the help of Mini-Van for serving the HTML content to end users.
- 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.hello.ts
:Hello
component.counter.ts
:Counter
component.
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.2.6"
}
- finalhandler and serve-static: Server-side packages for serving static files (primarily used for serving
.js
files). - mini-van-plate: The Mini-Van package used for SSR.
- vanjs-core: The VanJS package used for CSR.
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 avan
object as its input property. This is crucial to makeHello
component cross-platform. Callers are responsible for providing thevan
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, thevan
object from Mini-Van will be used (we can choose thevan
object fromvan-plate
mode or frommini-van
mode), whereas on the client-side, thevan
object from VanJS will be used. - We can determine if the component is being rendered on the server-side or client-side:
and show different content based on it:const fromServer = typeof window === "undefined"
This will help us differentiate whether the component is rendered from server or from client.p(() => `πHello (from ${fromServer ? "server" : "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 avan
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 theinit
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:
indicates that we're choosing different JavaScript bundle files under different modes:script({type: "text/javascript", src: `dist/client.bundle${env === "dev" ? "" : ".min"}.js`, defer: true})
client.bundle.js
in dev mode whereasclient.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:
and line:const counterInit = Number(parse(req.url!, true).query["counter-init"] ?? 0)
enable that.Counter({van, id: "basic-counter", init: counterInit})
- 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 choosemini-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
Signature | van.hydrate(dom, f) => undefined |
Description | Hydrates the SSR component dom with the hydration function f . |
Parameters |
|
Returns | undefined |
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:
- Clone the GitHub repo:
git clone https://github.com/vanjs-org/vanjs-org.github.io.git
- Go to the directory for the demo:
cd vanjs-org.github.io/hydration-example
- Install NPM packages:
npm install
- Launch the development server:
You will see something like this in the terminal:npm run dev
Try visiting the server via http://localhost:8080. Also try http://localhost:8080?counter-init=5 to set the initial value of the counter.
- By clicking the links printed in the terminal, you will go to the demo page.
- 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.
Appendix
on...
Properties
Since Mini-Van 0.4.0
, properties specified in tag functions will be consistently set as HTML attributes
. This is because for SSR (which is Mini-Van's primary use case), setting the properties of a DOM node won't be visible in the rendered HTML string unless the action of setting the property itself will also set the corresponding HTML attribute (e.g.: setting the id
property of a DOM node will also set the id
attribute).
There is a special situation of this handling - on...
properties. For DOM nodes, the valid values of on...
properties are function
or null
. However, functions are not valid value for HTML attributes. It doesn't make too much sense to specify a function-valued on...
attribute for an SSR DOM node. As a result, since Mini-Van 0.4.0
, all function-valued properties specified in tag functions will be ignored.
Sometimes, it might be desirable to specify some string-valued on...
attribute for SSR DOM nodes. For instance, DOM nodes like <button onclick="play()">Play</button>
might have legitimate use cases. But, if we render the DOM node with code button({onclick: "play()"}, "Play")
, there will be problems if the code is inside a component shared between server-side and client-side. The problem is on the client-side, onclick
property, instead ofonclick
attribute will be set for the DOM node, and strings are not valid values for onclick
property (only functions are valid values for onclick
property), causing the onclick
event handler to be lost on the client-side.
One solution to the problem is to use camelCase for the on...
property of the tag function. i.e.: to use onClick
property instead of onclick
. This way, button({onClick: "play()"}, "Play")
will generate <button onclick="play()">Play</button>
in both VanJS and Mini-Van. This is because onClick
is not a valid DOM property, thus in tag functions, we will set the specified corresponding string value "play()"
as an HTML attribute (You can refer to the property resolution rule for more information). And also dom.setAttribute(<key>, <value>)
will first lowercase the <key>
before setting the attribute value, which means dom.setAttribute("onClick", "play()")
is equivalent to dom.setAttribute("onclick", "play()")
.