Skip to Content
ReactServer rendering

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:

src/app/pages/[id]/document.tsx
"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:

src/app/pages/[id]/page.tsx
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 skeleton draws 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 hidden is 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.

src/tools/contracts/customers.ts
// 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, };
src/tools/server/customers.ts
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]; }, });
src/tools/client/customers.ts
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:

src/tools/client/index.ts
import { deleteCustomer, getCustomer, listCustomers } from "./customers"; export const tools = [getCustomer, listCustomers, deleteCustomer];
src/tools/server/index.ts
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.

package.json
{ "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:

src/app/pages/[id]/document.tsx
"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:

  1. The whole page, with a skeleton in place of each element whose seed is loading.
  2. 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 ends

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

Last updated on