[]previous and next page

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.

For agentsUpdated

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:

  • onMount and useEffect never run. The title effect and the storage code from step 6 are skipped, which is why they were written to touch window only inside those callbacks.
  • State values and stores work; reading them while rendering gives their current values.
  • isServer (from @shadow-js/core) is true in the server build, false in 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)
src/types.tsts
export type Filter = "all" | "active" | "done";

export interface Todo {
  id: number;
  title: string;
  done: boolean;
}
src/storage.tsts
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));
}
src/todos.tsts
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);
        }
      });
    },
  };
}
src/AddTodo.tsxtsx
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>
  );
}
src/TodoItem.tsxtsx
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>
  );
}
src/Filters.tsxtsx
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>
  );
}
src/App.tsxtsx
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.

src/entry-server.tsxtsx
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:

src/print-html.tsxtsx
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);
Expected output
<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-hk keys (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.

src/entry-client.tsxtsx
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:

html
<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.

server.tsts
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:

sh
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:

sh
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.