Readiness: Implemented
Build an app
Rendering modes
Two questions, not one. Where does the document come from, and what happens when a visitor clicks a link? uf answers them with two settings, because the answers compose.
What you will be able to do: choose where each route's document comes from
and what a click does, read the route decisions uf build reports, and pick a
deployment shape.
What you need first: routes to render — Routing.
The two axes
app.rendering.modes says where a document comes from, and uf build picks
between the strategies it allows per route: a route with no parameters is
prerendered, a route with parameters is prerendered when its page exports
generateStaticParams, and anything left over is rendered per request. The
config reference has the table.
One value in that list is not a per-route answer and cannot share it: "csr"
is a single-page application, and it is below.
app.rendering.navigation says what happens next, and it is one answer for the
whole application:
"client" (the default) | "document" | |
|---|---|---|
| a link | the router resolves the next route in the page that is open | the browser fetches the next document |
["ssg"] | a prerendered site the client router takes over | a static site of documents |
["ssr"] | a server-rendered app the client router takes over | a server-rendered application in the older sense |
Every cell is a deployment somebody wants, which is why this is a second key rather than a fifth value in the first one. A route is prerendered or it is not, and the answer does not change because of what the browser does with the result.
The route decision
For every route in the table, uf build answers the same question: can this
URL be written as a file now, or does something need a request later?
app/$page.js / no parameters
app/admin/$middleware.js /admin/* request guard
app/api/health/$route.js /api/health request handler
app/posts/[slug]/$page.js /posts/:slug parameters
app/search/$page.js /search no parameters, maybe dynamic
The default modes list allows both halves, so the build writes the files it
can and reports the rest:
| Route shape | Default build | ["ssg"] or staticBuild | ["ssr"] | ["csr"] |
|---|---|---|---|---|
| no parameters | prerendered | prerendered | rendered per request | browser route in the shell |
parameters, exports generateStaticParams | each returned set is prerendered | each returned set is prerendered | rendered per request; the function is not called | browser route in the shell |
parameters, no generateStaticParams | listed under answered by a server | build error, naming the route | rendered per request | browser route in the shell |
export const dynamic = "force-dynamic" | listed under answered by a server | build error, naming the route | rendered per request | browser route in the shell |
no parameters, reads the request inside <Suspense> | a static shell, its boundary rendered per request (ppr) | build error, naming the route | rendered per request | browser route in the shell |
$route.js | listed under answered by a server | build error | request handler | build error: a handler needs a server |
$middleware.js | listed by the path it guards | build error | runs per request | build error: middleware needs a server |
| callable server action reached by the browser | endpoint in the server build | build error unless an adapter or compiled server is also emitted | endpoint in the server build | build error: the endpoint has nowhere to run |
generateStaticParams is an enumeration, not a proof. If it returns three
slugs, those three URLs are files. A static host will 404 the fourth; a server
deployment can still render it when it receives a request. No build can know
whether the fourth slug exists in your data.
dynamic = "force-dynamic" is for the opposite case: a route with no
parameters that still depends on the request. A dashboard at /account, a page
that reads a cookie, or a page whose loader redirects based on headers cannot
use generateStaticParams to opt out of the prerender, because there are no
params to generate. force-dynamic leaves it to the server under the default
plan and is refused by a static-only plan.
A page that needs force-dynamic and does not say so is refused rather than
prerendered. When a prerendered route's render reaches cookies(), headers()
or draftMode() and modes does not allow ppr, uf build fails, naming the
route, the function, and the chain of imports that reaches it. When it allows
ppr, as the default does, a read inside a <Suspense> boundary is what
ppr is for, and a read outside every
boundary fails the build, naming the route and what it read. A route cache that
states a lifetime keeps the same rule.
The only other value uf accepts is
"auto"; Next.js's "force-static" and "error" are constraints uf does not
check yet, so they are refused rather than silently treated as "auto".
That is the whole deployment contract. A route handler answers a request, a
middleware runs before a request is resolved, and a callable server action is a
POST endpoint the browser can dial. A directory of files has no answer for
any of them.
isr: pages that regenerate
A prerendered page can say how long its document stays true, from inside its own render:
// @flow
import { cacheLife, cacheTag } from "@uniflowed/server/cache";
export default async function Prices() {
cacheLife({ revalidate: 300 });
cacheTag("prices");
return <PriceTable prices={await loadPrices()} />;
}
When app.rendering.modes allows isr (the default list does),
rendering.cache.route is on, and the build leaves a server
behind, uf build writes such a page for regeneration:
- The first request is answered with the document the build wrote, without a render.
- Once the lifetime has passed, counted from when the build rendered the page, the next request still gets that document, and the server regenerates the page in the background.
- When the regeneration succeeds, the new document replaces the old one in a single step, in memory and in the durable store. There is no rebuild.
revalidateTag("prices")orrevalidatePath("/prices")in a route handler or a server action expires the page. The next request renders it, and it never goes back to the build's copy.
The document is written under dist/__uf/regenerate/ rather than at the page's
own URL, so no static file server answers the page before the server does.
A page stays the plain prerendered document it always was when it states no
lifetime, calls noStore, or answers something other than 200. A tag alone
is not enough: the route cache keeps nothing without a lifetime, and a
regenerated page with no end is one another process could go on serving after a
tag was revalidated. isr allows what ssg does, so a page that states no
lifetime is prerendered under ["isr"] too.
Where it works
| Where the build runs | What keeps a regenerated page |
|---|---|
uf start, uf preview | the filesystem store under .uf/cache/route, unless rendering.cache.store names another |
--adapter node, bun, deno, container | the filesystem store under .uf/cache/route in the server's working directory, unless rendering.cache.store names another |
--adapter edge | Workers KV, through @uniflowed/server/cache/kv, bound as UF_CACHE in the wrangler.json the build writes |
--adapter serverless | a provider module named in rendering.cache.store. Without one the build is refused by name, because an instance's memory and its /tmp go with the instance |
--adapter static | nothing: the adapter refuses a regenerating page by name, because a static host has no server to regenerate it |
build.staticBuild | nothing: no server is left to regenerate a page, so every page is a plain document |
uf build --compile | nothing: a compiled binary carries no route cache, so it renders a regenerating page for every request |
rendering.cache.store: "memory", said explicitly, keeps regenerated pages in
each process's memory, and a restart starts every page from the build's copy
again. Caching has how the store
treats a regenerated page, and why Workers KV rather than the Cache API.
ppr: a static shell with holes
Most of a page that reads the request usually does not depend on it. The heading, the article and the navigation are the same for everybody; the line that says who is signed in is not. Partial prerendering prerenders the first part and leaves the second to the request:
// @flow
import * as React from "@uniflowed/react";
import { cookies } from "@uniflowed/server";
async function Session(): Promise<React.Node> {
const session = await loadSession(cookies().get("session"));
return <p>{`signed in as ${session.name}`}</p>;
}
export default component Account() {
return (
<article>
<h1>Your account</h1>
<React.Suspense fallback={<p>checking who you are</p>}>
<Session />
</React.Suspense>
<AccountHelp />
</article>
);
}
When app.rendering.modes allows ppr (the default list does), the build
leaves a server behind, and routes render as Server Components (app.rsc, on by
default), uf build writes such a page as a static shell:
- The build renders the page without a request.
cookies(),headers()anddraftMode()do not answer there, and each<Suspense>boundary whose content read one of them is left as a hole, with its fallback in the shell. Everything else the page renders — including what it awaits — is rendered once, by the build. - The shell is recorded beside the server bundle, in
.uf/build/server/partial-prerender.json, with React's record of where the holes are. Nothing is written at the page's URL underdist/, neither a document nor a payload: a shell on its own is a page whose holes never fill, so a file server must not answer the page. - A request for the page is answered with the shell as the first bytes of the
response, before anything is rendered for it. The server then renders the
route for the request and React's
resumestreams the holes into the same response, rendered from the request's cookies and headers. The browser hydrates from the request's payload, written into the document after the shell, the way it hydrates every streamed page.
A page that reads nothing is prerendered whole, which is why ppr allows what
ssg does. A read outside every <Suspense> boundary leaves no shell to
write, and the build fails naming the route and what it read:
/account reads cookies() outside any <Suspense> boundary, so it has no static
shell to prerender. Move the part of the page that reads it inside a <Suspense>
boundary, which leaves that part for the request, or export
`dynamic = "force-dynamic"` from the page to render all of it per request.
Without ppr in the list, the same page is refused before Vite runs, by the
import-graph check above, because nothing else could prerender it.
Where it works
| Where the build runs | What answers a page with a static shell |
|---|---|
uf start, uf preview | the server: the shell first, then the holes |
--adapter node, bun, deno, container | handler.js, which carries the shells: the shell first, then the holes |
--adapter edge | the Worker, the same way; nothing for the page is in its static assets |
--adapter serverless | nothing: the build is refused, naming the pages, because a Lambda's response is buffered whole, so the shell would arrive with its holes rather than before them |
--adapter static | nothing: the adapter refuses the page by name, because a static host has no server to fill a hole |
build.staticBuild, or modes without ppr | nothing: no page is written as a shell, and a prerendered page that reads the request fails the build |
uf build --compile | the binary renders the page whole, per request, and streams it like any page rendered per request |
uf dev | the page is rendered per request, as every page is in development |
What it measured
On the ppr-app test fixture — a page with a heading, 24 paragraphs, a client
component, and one boundary that reads a cookie and then waits 400 ms — asking
the --adapter node artefact's handler.js in process (Node 24, no socket, 30
requests after 5 to warm up, median):
| first byte | first chunk | whole response | |
|---|---|---|---|
the same page with dynamic = "force-dynamic" | 2.1 ms | 4,287 B | 18,407 B, 402 ms |
| static shell | 1.4 ms | 5,482 B (the whole shell) | 17,484 B, 402 ms |
the page also awaiting 100 ms of static data, force-dynamic | 102.7 ms | 4,287 B | 18,594 B, 402 ms |
| the same, static shell | 0.9 ms | 5,524 B (the whole shell) | 17,671 B, 402 ms |
The last byte is the hole's, and it arrives when the hole is ready either way. What moves is the first: a page rendered per request cannot send its shell until everything outside its boundaries has rendered, and a static shell was rendered by the build. Over a network the difference is the same number of milliseconds before the round trip, not a fraction of it.
What it does not do
- A read inside a component that catches errors is not a hole. During the
build the three reads throw, which is how the build finds the holes; a
try/catcharound one renders whatever thecatchdoes into a shell every request is sent. nonce()andrequestId()are not left for the request. Both name one response, and both still fail the build when a prerender reaches them.- The shell carries no nonce, like every prerendered document: under a nonce-based Content-Security-Policy its script tags need hashes instead. The scripts that fill the holes carry the request's nonce.
- A draft request is rendered whole. The shell is an answer from before the draft existed, so a request in draft mode renders the page per request.
- Search parameters are the request's in the holes and the build's in the shell. The shell was rendered for the path alone.
- The render's instant and seed are the build's. The client reads them from
the shell, so a client component inside a hole that renders
useRenderedAt()oruseRandom()can differ from what the browser renders, and React reports the mismatch. - The build decides what is static by rendering. It stops once everything that is not waiting on the request has rendered; a part that is still loading then is a hole, which is slower for the request and never wrong.
app.rsc: falsehas no shells. A route rendered from its modules is prerendered whole or not at all.
Reading the build
The summary says the plan in words:
rendering prerender what can be prerendered; the rest is answered by a server
answered by a server
/posts/:slug — it has parameters and its page exports no `generateStaticParams`
/search — its page exports `dynamic = "force-dynamic"`
/api/health — it is a route handler, and a handler answers a request rather than producing a file
The same facts are written to .uf/build/meta/uf-build-manifest.json, where a
deploy step can read them without scraping the terminal:
{
"rendering": {
"prerender": "possible",
"server": true,
"perRequest": ["/posts/:slug", "/search", "/api/health"]
}
}
uf explain build prints the same choice before the build runs: which builder
will make the decision, whether prerendering is everything, possible,
nothing, or the csr shell, and which config key asked for it. Use that when
the question is "what would this project deploy?" rather than "what did this
particular build write?"
If a middleware guards a route that was also prerendered, the ordinary build
warns instead of choosing for you. A server deployment can use the file as a
cache and still run the guard on requests it handles; a static upload serves
the file without running the guard at all. uf build --adapter static,
build.staticBuild: true, and rendering.modes: ["ssg"] turn that ambiguity
into a refusal, because those builds have been told that files are the whole
deployment.
Choosing a deployment shape
| Goal | Config shape | What to verify |
|---|---|---|
| A docs, blog or marketing site that is only files | build.staticBuild: true, or app.rendering.modes: ["ssg"] | Every parameterized page exports generateStaticParams; there are no route handlers, middleware, callable actions, or app.router redirects, rewrites and headers |
| A static site whose links behave like ordinary web pages | modes: ["ssg"], navigation: "document" | Same static checks, plus client state is not expected to survive a navigation |
| A server app with cached documents where possible | the default ["ppr", "ssr", "ssg", "isr"] | The answered by a server list is deployed with a Node, Bun, Deno, edge, serverless or container adapter; a page with a static shell, with any of them but serverless |
| A server app that must never prerender | app.rendering.modes: ["ssr"] | generateStaticParams will not run during the build; route data is read at request time |
| A single-page application behind an SPA rewrite | app.rendering.modes: ["csr"] | The app has no route handlers, middleware, callable actions, app.router rules, or reachable server-only modules |
Use Routing for the files that create routes, middleware and handlers, Server Components for the client/server split that decides whether an action is callable from the browser, Server actions for the endpoint itself, and the config reference for the exact defaults.
How a page navigates
A document React Server Components rendered carries the Flight payload its tree
was rendered from, and the browser hydrates that payload rather than rendering
the route's modules again. A link after that fetches the next route's payload
from <route>/__uf.flight — /guide/rendering/__uf.flight for this page — and
renders it in place:
- a prerendered route has its payload beside its document, so a static host
serves navigation from files, with no rewrite rule, as
text/x-component; - a server-rendered route answers the payload per request, after the middleware that guards its path and before any route handler;
- anything that is not a payload is a document load: a redirect off the
site, a static host's
404.htmlfor a file it lacks, a middleware's own refusal. The browser navigates the way a link without JavaScript would.
app.rsc: false turns this off, and a navigation imports the next route's
modules instead. That mode runs on React 19.2.3 or newer without
react-server-dom-parcel; the payload needs React 19.3 (see
Server Components).
navigation: "document"
// uf.config.js
export default defineConfig({
app: {
rendering: {
modes: ["ssg"],
navigation: "document",
},
},
build: { staticBuild: true },
});
What changes:
- A
Linkis an ordinary anchor. No handler of uf's on it, nothing callingpreventDefault, no prefetch on hover or focus. The element the browser gets is the one<a href>would have produced. useRouter().pushand.replacehand the URL to the browser, aslocation.assignandlocation.replace.refresh()is a reload.- No
popstatelistener is installed. Nothing pushed a history entry, so the back button asks the browser for the previous document — which is the one it already has.
What does not change:
- The page still hydrates. A
"use client"component is still a"use client"component: a search box still filters, a theme toggle still toggles. This mode is the client router minus the takeover, not the browser minus JavaScript. - Every route is still prerendered or rendered exactly as
modessays. The documents in the output directory are the same documents. uf devbehaves the way the deployment does. The mode is written into the client entry with noisProductionbeside it, because a development server whose links resolve in the page and a deployment whose links fetch a document are two applications, and the one on the screen is not the one being shipped.
uf build says so in its summary:
rendering every route prerendered
navigation a document request; this build ships no client router
and uf explain build names it on the bundle stage. .uf/build/meta/uf-build-manifest.json
carries it as rendering.navigation, for a deploy step that needs to know
whether the output directory is answered by the browser or by the documents
alone.
What it does not buy you
The honest limit, because it is the thing people expect and do not get.
It does not empty the bundle. uf hydrates the whole document rather than
per-island, so a route with a "use client" boundary anywhere in it still
loads React and still loads that route's components. What document navigation
removes is the navigation: the history listener, the prefetching, and resolving
a route in the browser. A route that reaches no client boundary already
ships no page at all — that is the server-component split, and it is on by
default in both modes — so a content site written in server components was
already close to zero JavaScript before this key existed, and this key is what
stops the router taking its links over.
It is not a separate runtime. @uniflowed/router is one package with one
client entry, and the mode is a constant the entry passes to it. Making the
navigation code absent from the bundle rather than unreachable would mean
RouterProvider taking its navigator from the entry rather than from module
state; that is a real change and it is not this one.
It is not faster on its own. A document request is a round trip to the origin or the CDN. What it buys is a page that behaves the way the web does without uf being involved: the browser's scroll restoration, the browser's history, the browser's handling of a middle click, and one less thing that can be wrong.
csr: a single-page application
Try the SPA task board for a complete example with client routing, shared state, forms and a static-host rewrite.
The complement of document navigation, on the other axis. Where
navigation: "document" keeps every document and takes the router away, csr
keeps the router and takes every document away:
// uf.config.js
export default defineConfig({
app: {
rendering: {
modes: ["csr"],
},
},
});
uf build then writes one document — an empty root, the stylesheets and
the module script — and the client router resolves and renders every route from
it. No route is prerendered, no server renders one, and no server bundle
survives the build.
"csr" cannot share the list. Every other value answers "where does this
route's document come from", which is what makes the list a set the build
picks from per route; this one answers it for the whole application with a
document that belongs to no route, and once it is chosen there is no per-route
decision left. ["csr", "ssg"] is refused while the config is read, because its
two honest readings — "prerender what you can and fall back to the shell" and
"the shell, and never mind the rest" — are different applications. It is also
not in the default list, which everything else is: a build that decided to be a
single-page application because nothing forbade it would be the largest
semantic change a default has ever made.
The two files, and the rule your host needs
The build writes the shell twice:
| File | What serves it |
|---|---|
index.html | the host, for / |
404.html | the host, for every path it has no file for |
The second is the one that matters. Under this plan /orders has no file, so a
plain static host would 404 it — a dist/ with a hole in it. Writing the shell
as the error document covers the hosts that have one (GitHub Pages, Netlify,
an S3 bucket with an error document), and the 404 status it comes with is the
right status for a URL this application does not have — the not-found boundary
is what the browser then renders into it. A host with a proper SPA rewrite
serves index.html and never looks at 404.html.
uf preview applies that rewrite itself, so the preview is right about a
deployment that is configured, rather than right about one nobody does.
A route that needs a server is refused
Every other plan finds these by trying: a build that prerenders a page calls
cookies() where there is no request, and the render fails with the route
named. This plan renders nothing, so the same page would build, deploy, and
fail in a browser.
So uf build refuses it before the bundle, from the server-component analysis
it has already run, naming the route:
2 things this project needs a server, and `app.rendering.modes` is ["csr"], so
this build writes one shell and every route is rendered in the browser
/orders (session.js) — it imports `@uniflowed/server`, which only runs on a
server — `cookies()`, `headers()` and `draftMode()` all read a request, and
there is none
/api — it is a route handler, and a handler answers a request rather than
rendering a page
Four things count: a $route.js, a $middleware.js, a callable server
action, and any module the application reaches that imports a server-only
package (@uniflowed/server, @uniflowed/db, server-only) or is named
*.server.js. Dead code does not: a module nothing imports is not a reason to
refuse a build.
A module no page reaches — a layout, most often, because no page imports the layout it renders inside — is named by its file rather than by a route, because naming a route it does not belong to would be worse than naming none.
What it costs
The document says nothing. No <title>, no description, no content. A
crawler that runs no JavaScript sees an empty page for every URL, and a reader
sees nothing until the bundle has loaded and the route has resolved. That is
what a single-page application is, and it is why this is a declaration a project
makes rather than something a build falls back to.
The loader runs in the browser. There was no server render to run it in, so every route's data is fetched after the bundle has loaded — which is a round trip after a round trip.
If either of those is unacceptable for some routes and not others, the answer
is not this mode: it is ["ssg"] or the default, which decide per route.
When to choose document navigation
- The site is documents — a manual, a blog, a marketing site — and the client router is buying an animation nobody asked for.
- Something downstream needs a full document per URL: a caching layer that keys on the URL, an analytics tag that runs on load, an embed that expects a page load.
- The application is being served behind something that rewrites documents.
And when not to: an application with state that has to survive a navigation — a media player that keeps playing, a form half filled in, a list scrolled to where the visitor left it — is asking for the client router, which is why it is the default.
Where to go next
Answering requests is next: middleware, route handlers and the other files that answer a request without rendering a page. Server Components is the boundary each mode renders across.
Edit this pagedocs/app/guide/rendering/$page.mdx