Server rendering
A document can go through a server pass in two ways:
- Document skeleton. The server draws only the document’s shape. The app renders in the browser, and nothing from the document runs on the server.
- Full render. The server runs the document, so the first HTML has the data. Host functions come in two sets: the browser one calls your API, and the server one, with only the reads, queries the database.
Document skeleton
DocumentSkeleton draws the document’s shape from the entries alone. It runs no
expressions, calls no host functions and creates no scopes, so it renders in a
server pass. It takes the implementations and the fallback UI from the nearest
<RendererProvider>. Show it until the component mounts:
"use client";
import type { ComponentEntry } from "@uicast/core";
import { Evaluator } from "@uicast/expr";
import { DocumentSkeleton, EntriesRenderer, RendererProvider } from "@uicast/react";
import { impls } from "@uicast/shadcn-catalog/all/impls";
import { useEffect, useState } from "react";
import { tools } from "@/tools";
const evaluator = new Evaluator({ functions: tools });
export function Document({ entries }: { entries: ComponentEntry[] }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return (
<RendererProvider implementations={impls} evaluator={evaluator}>
{mounted ? <EntriesRenderer entries={entries} /> : <DocumentSkeleton entries={entries} />}
</RendererProvider>
);
}The page loads the stored entries on the server, so the first HTML already has the document’s shape. A Next.js page, for example:
import { Document } from "./document";
export default async function Page({ params }: PageProps<"/pages/[id]">) {
const { id } = await params;
return <Document entries={await loadEntries(id)} />;
}How the skeleton draws:
- An element with a
skeletondraws it, with its children inside. - An element without one draws only its children. With no children either, it
draws
fallbackComponents.defaultSkeleton, if set. - A list (
each) draws three items: its length comes from an expression. - An element with
hiddenis left out, since its value is not known yet. - Props are not read, and nothing has to match the real tree: the renderer replaces it.
The renderer draws the same skeleton for an element while its async seed loads, from that element down.
Streamdown blocks work this way unless their
ssr option is on.
Full render
<EntriesRenderer> renders wherever React renders it, the server included. A
server pass runs the document: seeds, expressions and host functions.
Two sets of tools
A server pass runs seeds, so it calls host functions too. Each side gets its
own implementation of the same contract: the same name, description and
schemas, a different execute. The server tools query the database; the
browser tools call your routes, and the routes call the server tools, so the
logic lives in one place.
// The contract, shared by both sides.
export const getCustomer = {
name: "getCustomer",
description: "Get a single customer by id.",
inputSchema: z.object({ id: z.number().int() }),
outputSchema: customerSchema,
};import { standardTool } from "standard-tool";
import { db } from "@/db";
import * as contract from "../contracts/customers";
export const getCustomer = standardTool({
...contract.getCustomer,
execute: async ({ id }) => {
const { rows } = await db.query("SELECT * FROM customers WHERE id = $1", [id]);
return rows[0];
},
});import { standardTool } from "standard-tool";
import * as contract from "../contracts/customers";
export const getCustomer = standardTool({
...contract.getCustomer,
execute: ({ id }) => fetch(`/api/customers/${id}`).then((res) => res.json()),
});The server set holds only what a seed reads. Callbacks run only in the browser,
so a function that changes data, such as deleteCustomer, is in the browser set
and stays out of the server set. No code a server render reaches can change the
database:
import { deleteCustomer, getCustomer, listCustomers } from "./customers";
export const tools = [getCustomer, listCustomers, deleteCustomer];import { getCustomer, listCustomers } from "./customers";
// deleteCustomer runs in a callback, so only the browser set has it.
export const tools = [getCustomer, listCustomers];Leaving a function out of the server set is safe. A seed that calls it fails on the server, so its element goes out as a skeleton and the browser renders it. The prompt still lists every function: build it from the contracts, not from the server set.
A server tool reads the database directly, without your API routes and their auth checks. Do the checks in the tool: give it the user from the page’s request, and return only what that user may see.
One import, two bundles
The component that renders the document runs in both passes, so it cannot
import the server tools directly: they would ship to the browser. The imports
field of package.json lets the bundler pick: the browser bundle takes the
browser condition, and the server takes default. Next.js, Vite and esbuild
all resolve it this way.
{
"imports": {
"#tools": {
"browser": "./src/tools/client/index.ts",
"default": "./src/tools/server/index.ts"
}
}
}The component imports #tools and keeps the skeleton as its fallback. The page
stays the same:
"use client";
import type { ComponentEntry } from "@uicast/core";
import { Evaluator } from "@uicast/expr";
import { DocumentSkeleton, EntriesRenderer, RendererProvider } from "@uicast/react";
import { impls } from "@uicast/shadcn-catalog/all/impls";
import { Suspense } from "react";
import { tools } from "#tools";
const evaluator = new Evaluator({ functions: tools });
export function Document({ entries }: { entries: ComponentEntry[] }) {
return (
<RendererProvider implementations={impls} evaluator={evaluator}>
<Suspense fallback={<DocumentSkeleton entries={entries} />}>
<EntriesRenderer entries={entries} />
</Suspense>
</RendererProvider>
);
}What a server pass does
Each element with an async seed renders inside its own <Suspense>, with its
skeleton as the fallback. React can send a page’s HTML in
parts as it renders: the Next.js App Router does, and so does any server that
renders with React’s renderToPipeableStream or renderToReadableStream. One
response then carries:
- The whole page, with a skeleton in place of each element whose seed is loading.
- Then each of those elements, as its seed resolves. A small script React adds puts it in place of its skeleton.
For a page with two cards whose seeds take 300 ms and 900 ms:
0 ms the page, with a skeleton for each card
300 ms the first card replaces its skeleton
900 ms the second card replaces its skeleton; the response endsAn element inside another element with an async seed starts loading only after its parent renders, so it arrives after its parent.
A server that sends the HTML in one piece cannot wait for seeds: the skeletons go out, and those elements load in the browser.
Two more things to know:
- Seeds run twice. The server does not send scope values to the browser, so the browser runs every seed again. A host function that a seed calls runs once on the server and once in the browser on each page load. The skeleton does not come back: React keeps the server HTML on screen until the browser’s seed resolves, then hydrates the element. If the data changed in between, the element shows the browser’s data.
- Callbacks run only in the browser.
Errors
A seed that fails on the server sends its skeleton, and the onError you pass
to <RendererProvider> reports it,
on the server. The browser then renders the element again.
Any other error in a server pass fails more than its element: the server runs
no error boundaries, so an unknown component, props that fail their schema or
an implementation’s own throw stops the render up to the nearest <Suspense>.
Without one, the page fails. With the <Suspense> above, the skeleton goes out
instead, and the browser renders the document with each error in its own slot.