8. Server render and build
Render the todo app to HTML with renderToString, hydrate it in the browser, run it through Vite's SSR hooks and produce a production build.
Goal: the same App rendered to a string on the server, claimed by hydrate in the browser, and built for
production. The guide SSR, hydration and streaming has the details this tutorial skips.
What changes on the server#
The compiler can emit two kinds of output for the same JSX: DOM operations (generate: "dom") and string
builders (generate: "ssr"). The Vite plugin chooses ssr for modules that Vite loads for the server. On the
server:
onMountanduseEffectnever run. The title effect and the storage code from step 6 are skipped, which is why they were written to touchwindowonly inside those callbacks.- State values and stores work; reading them while rendering gives their current values.
isServer(from@shadow-js/core) istruein the server build,falsein the browser build.
The app files are the finished app of step 6, compiled for the server this time.
The app files (unchanged from step 6)
export type Filter = "all" | "active" | "done";
export interface Todo {
id: number;
title: string;
done: boolean;
}
import type { Todo } from "./types";
export interface TodoStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export const STORAGE_KEY = "shadowjs-tutorial:todos";
export function parseTodos(raw: string | null): Todo[] {
if (raw === null) return [];
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
// Corrupt JSON is treated like no data at all.
return [];
}
if (!Array.isArray(value)) return [];
const todos: Todo[] = [];
for (const entry of value) {
if (typeof entry !== "object" || entry === null) continue;
const { id, title, done } = entry as Record<string, unknown>;
if (typeof id === "number" && typeof title === "string" && typeof done === "boolean") {
todos.push({ id, title, done });
}
}
return todos;
}
export function loadTodos(storage: TodoStorage): Todo[] {
return parseTodos(storage.getItem(STORAGE_KEY));
}
export function saveTodos(storage: TodoStorage, todos: readonly Todo[]): void {
storage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
import { onMount, reconcile, useEffect, useMemo, useStore } from "@shadow-js/core";
import { loadTodos, saveTodos, STORAGE_KEY, type TodoStorage } from "./storage";
import type { Filter, Todo } from "./types";
function matches(filter: Filter, todo: Todo): boolean {
if (filter === "active") return !todo.done;
if (filter === "done") return todo.done;
return true;
}
export function useTodos(storage?: TodoStorage) {
const [state, setState] = useStore({ todos: [] as Todo[], filter: "all" as Filter });
const visible = useMemo(() => state.todos.filter((todo) => matches(state.filter, todo)));
const target = (): TodoStorage => storage ?? window.localStorage;
onMount(() => {
setState("todos", loadTodos(target()));
const onStorage = (event: StorageEvent): void => {
if (event.key !== STORAGE_KEY) return;
setState("todos", reconcile(loadTodos(target()), { key: "id" }));
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
});
useEffect(
() => state.todos.map((todo) => ({ id: todo.id, title: todo.title, done: todo.done })),
(todos) => saveTodos(target(), todos),
{ defer: true },
);
return {
state,
visible,
add(title: string): boolean {
const text = title.trim();
if (text === "") return false;
setState((draft) => {
const id = draft.todos.reduce((max, todo) => Math.max(max, todo.id), 0) + 1;
draft.todos.push({ id, title: text, done: false });
});
return true;
},
toggle(id: number) {
setState("todos", (todo) => todo.id === id, "done", (done) => !done);
},
remove(id: number) {
setState((draft) => {
const index = draft.todos.findIndex((todo) => todo.id === id);
if (index !== -1) draft.todos.splice(index, 1);
});
},
setFilter(filter: Filter) {
setState("filter", filter);
},
clearCompleted() {
setState((draft) => {
for (let index = draft.todos.length - 1; index >= 0; index -= 1) {
if (draft.todos[index]?.done === true) draft.todos.splice(index, 1);
}
});
},
};
}
import { onMount, useStore } from "@shadow-js/core";
export interface AddTodoProps {
onAdd: (title: string) => void;
}
export function AddTodo(props: AddTodoProps) {
const [title, setTitle] = useStore("");
let input: HTMLInputElement | undefined;
onMount(() => {
input?.focus();
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
props.onAdd(title());
setTitle("");
}}
>
<input
ref={(element) => {
input = element;
}}
type="text"
aria-label="New todo"
placeholder="What needs to be done?"
value={title()}
onInput={(event) => setTitle(event.currentTarget.value)}
/>
<button type="submit" disabled={title().trim() === ""}>
Add
</button>
</form>
);
}
import type { Todo } from "./types";
export interface TodoItemProps {
todo: Todo;
onToggle: () => void;
onRemove: () => void;
}
export function TodoItem(props: TodoItemProps) {
return (
<li classList={{ done: props.todo.done }}>
<label>
<input type="checkbox" checked={props.todo.done} onChange={() => props.onToggle()} />
{props.todo.title}
</label>
<button
type="button"
aria-label={`Delete ${props.todo.title}`}
onClick={() => props.onRemove()}
>
Delete
</button>
</li>
);
}
import { For } from "@shadow-js/web";
import type { Filter } from "./types";
const OPTIONS: readonly { value: Filter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "active", label: "Active" },
{ value: "done", label: "Done" },
];
export interface FiltersProps {
value: Filter;
onChange: (filter: Filter) => void;
}
export function Filters(props: FiltersProps) {
return (
<nav aria-label="Filter">
<For each={OPTIONS}>
{(option) => (
<button
type="button"
aria-pressed={props.value === option.value}
onClick={() => props.onChange(option.value)}
>
{option.label}
</button>
)}
</For>
</nav>
);
}
import { useEffect } from "@shadow-js/core";
import { For, Show } from "@shadow-js/web";
import { AddTodo } from "./AddTodo";
import { Filters } from "./Filters";
import type { TodoStorage } from "./storage";
import { TodoItem } from "./TodoItem";
import { useTodos } from "./todos";
export interface AppProps {
storage?: TodoStorage;
}
export function App(props: AppProps) {
const todos = useTodos(props.storage);
const remaining = todos.state.todos.filter((todo) => !todo.done).length;
const completed = todos.state.todos.length - remaining;
const empty =
todos.state.filter === "all" ? "Nothing to do." : `No ${todos.state.filter} todos.`;
useEffect(() => {
document.title = remaining === 0 ? "Todos" : `(${remaining}) Todos`;
});
return (
<main>
<h1>Todos</h1>
<AddTodo onAdd={todos.add} />
<Filters value={todos.state.filter} onChange={todos.setFilter} />
<Show when={todos.visible().length > 0} fallback={<p>{empty}</p>}>
<ul>
<For each={todos.visible()}>
{(todo) => (
<TodoItem
todo={todo}
onToggle={() => todos.toggle(todo.id)}
onRemove={() => todos.remove(todo.id)}
/>
)}
</For>
</ul>
</Show>
<p>{remaining} left</p>
<Show when={completed > 0}>
<button type="button" onClick={() => todos.clearCompleted()}>
Clear completed ({completed})
</button>
</Show>
</main>
);
}
Render to a string#
renderToString runs the function once inside a root, escapes the
result, disposes the root and returns the markup. For data that arrives asynchronously (a
useResource fetch), renderToStringAsync
renders again until every resource has settled and also returns the resolved values, which
serialize turns into JSON that is safe to embed in a script element.
import { renderToString, renderToStringAsync, serialize } from "@shadow-js/web/server";
import { App } from "./App";
export function renderApp(): string {
return renderToString(() => <App />);
}
export async function renderAppAsync(): Promise<{ html: string; data: string }> {
const { html, data } = await renderToStringAsync(() => <App />);
return { html, data: serialize(data) };
}
Run it and look at the markup. This block is compiled with generate: "ssr" and executed with isServer true,
exactly as on a server:
import { renderApp, renderAppAsync } from "./entry-server";
const html = renderApp();
console.log(html.slice(0, 49));
console.log(html.includes("Nothing to do."));
console.log(html.includes('aria-pressed="true"'));
console.log(html.endsWith("</main>"));
console.log((await renderAppAsync()).data);
<main data-hk="0-0-0"><h1>Todos</h1><!--[--><form
true
true
true
{"r":{"$":"ref","i":0},"t":[{"$":"obj","e":[]}]}Three things are visible in the markup (print html yourself to see all of it):
- The list is empty and the empty message is rendered: the stored todos are loaded in
onMount, which the server does not run. The browser renders the same empty state first and then fills in the stored todos. - Template roots carry
data-hkkeys (0-0-0). They are how the client finds the element that belongs to a piece of JSX. - Every dynamic slot is wrapped in
<!--[-->and<!--]-->. These comments exist only in the server string. After hydration they are removed, so the live DOM contains no comment nodes.
Hydrate in the browser#
hydrate takes the same function you gave renderToString, plus the container that
holds the server markup. Elements are claimed by their data-hk keys instead of being created, so nodes keep
their identity, listeners and bindings are attached and the markers are removed. If the server and client
disagree about a part of the page, that part is repaired by reconciliation; the page is never left half
hydrated. renderId (default "0") must match the one used on the server.
import { hydrate } from "@shadow-js/web";
import { deserialize } from "@shadow-js/web/server";
import { App } from "./App";
const root = document.getElementById("app");
if (root === null) throw new Error("Missing #app element");
// The async server render embeds the resolved resource values with `serialize`; read them back with `deserialize`.
const embedded = document.getElementById("ssr-data")?.textContent;
const parsed = embedded === undefined ? {} : deserialize(embedded);
const data = typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
hydrate(() => <App />, root, { data });
The page template has two placeholders. The server replaces them with the markup and the serialized data;
the client entry replaces src/main.tsx as the module script:
<div id="app"><!--ssr-outlet--></div>
<script type="application/json" id="ssr-data"><!--ssr-data--></script>
<script type="module" src="/src/entry-client.tsx"></script>
Serve it in development#
Vite can load modules for the server through the same plugin pipeline. In this script Vite runs in middleware
mode, ssrLoadModule loads src/entry-server.tsx with the plugin in ssr mode, and the rendered markup replaces
the placeholders.
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { createServer as createViteServer } from "vite";
const vite = await createViteServer({ server: { middlewareMode: true }, appType: "custom" });
createServer((request, response) => {
vite.middlewares(request, response, async () => {
try {
const url = request.url ?? "/";
const template = await vite.transformIndexHtml(url, await readFile("index.html", "utf8"));
const entry = (await vite.ssrLoadModule("/src/entry-server.tsx")) as {
renderAppAsync: () => Promise<{ html: string; data: string }>;
};
const { html, data } = await entry.renderAppAsync();
response.setHeader("Content-Type", "text/html");
response.end(template.replace("<!--ssr-outlet-->", html).replace("<!--ssr-data-->", data));
} catch (error) {
if (error instanceof Error) vite.ssrFixStacktrace(error);
response.statusCode = 500;
response.end(String(error));
}
});
}).listen(5173);
Run it with a TypeScript-capable runner, for example npx tsx server.ts, and open http://localhost:5173. View the
page source: the todo markup is in the HTML before any script runs.
Build for production#
The generated app builds with one command. It type-checks first (tsc --noEmit) and then runs vite build:
npm run build
npm run preview
vite build writes dist/ with the hashed bundle; npm run preview serves it. For a server-rendered build,
run Vite twice, once for the browser bundle and once for the server entry:
npx vite build --outDir dist/client
npx vite build --ssr src/entry-server.tsx --outDir dist/server
The second build is the one where the plugin compiles for the server. Your production server imports
dist/server/entry-server.js, calls renderAppAsync() per request and fills the same placeholders in
dist/client/index.html. Options that affect the build are listed in Configuration.
What you should see#
The server output starts with <main data-hk="0-0-0">,
renders the empty state, includes the filter state and is a complete element. The dev server prints the same
markup into the page source, and after hydration the page behaves exactly like the client-rendered app from
the earlier steps: add a todo, reload, and it is restored from localStorage.
You have built a complete app: state, lists, forms, derived values, effects and server rendering. Continue with the guides for each topic in depth and the API reference for every export.