Readiness: Implemented
Build an app
Server Components
Every module in a uf application belongs to the server until it says otherwise,
and "use client" is how it says otherwise. What that directive is worth is
decided by a graph rather than by a convention: uf resolves the whole import
tree, finds every place the server hands off to the browser, and reports every
way an application can break the contract. What it does not do yet is split
anything smaller than a route, and that is written down here rather than left
for you to discover by measuring dist/.
What you will be able to do: mark a client boundary, read what the module
graph decided, know what reaches the client bundle and what stays on the server,
and know what uf reports and where uf lint fits.
What you need first: a project with routes — Routing — and
Rendering modes for where a document comes from. Server
Components also need React 19.3 or newer, with react-dom and
react-server-dom-parcel from the same release.
@uniflowed/router itself installs beside React 19.2.3, the React that Expo SDK
57 and React Native 0.87 ship, and lists react-dom and
react-server-dom-parcel as optional peers. A native app, or an application
rendered from its modules, does not need them. On a React older than 19.3,
uf dev and uf build stop before they render and name the React they found,
and the router's Server Components entries (@uniflowed/router/rsc,
@uniflowed/router/rsc/client and @uniflowed/router/rsc/ssr) refuse to start
with the same advice.
The current contract
Routes render as React Server Components by default. The table says what that covers and what it does not yet.
| Area | Today | Still missing |
|---|---|---|
| Directive parsing | "use client" and "use server" are read by the same directive-prologue rules React and bundlers use | no known gap |
| Diagnostics | invalid directives, server-only imports in client graphs, client APIs in server modules, and project hooks that reach one are named | a hook from a package the analysis cannot read is a warning, not a verdict |
| The split | a Server Component never reaches the browser: the browser gets the payload it rendered and the "use client" modules that payload names | no known gap |
| HTML streaming | the document streams, and carries the Flight payload its tree was rendered from; a Suspense boundary resolves from the rows that arrive later in the same response | no known gap |
| Navigation | a navigation fetches the next route's payload at <route>/__uf.flight; a prerendered route has that file beside its index.html; an intercepted navigation asks for the page underneath with the interception in its slot | no known gap |
| Server actions | exported async functions in "use server" modules are callable by id, from an import or passed down by a Server Component as a prop, bound or not (#1359) | inline action references (#1360); a form works before hydration with an imported action, not with one passed as a prop (Server actions) |
| Flight payload | React's own renderer and client (react-server-dom-parcel) in a second Vite environment resolved under react-server | no known gap |
app.rsc: false in uf.config.js turns the model off: routes render from their
modules and the browser hydrates by rendering them again, as every uf
application did before #519.
Three kinds of module
A module's environment is decided by its first statement and by nothing else.
| First statement | Environment | What it means |
|---|---|---|
| nothing | Server | A Server Component, or ordinary server code. The default. |
"use client"; | Client | A client bundle root. Its code is the browser's, and everything it imports comes with it. |
"use server"; | Server actions | Every export is a function the browser may call by id. See Server actions. |
A directive is honoured under the ECMAScript directive prologue rules, which is what React and the bundlers implement:
- a UTF-8 BOM, a
#!line, comments and blank lines may come before it; - it must be a plain single- or double-quoted string literal, so a template literal or a concatenation is not one;
- it must end at
;, at a line break, at the}that closes its block, or at the end of the file; - the text between the quotes is compared byte for byte, so
"use client"with two spaces is an ordinary string statement — exactly as"use strict"behaves in the language.
A directive uf cannot honour is reported rather than ignored. A "use client" further down the file, one built by concatenation, a module declaring
both directives, a "use client" inside a function body: each is a named
diagnostic. Silently dropping one is how a module its author believed was a
Client Component ends up rendered only on the server, which is the bug this
pass exists to prevent.
A "use server" at the top of a function body is a different and supported
construct — it marks that one closure as an action rather than changing the
module. Those are collected separately, and the limit on them is in
Server actions.
The boundary is an import
// @flow
// app/counter/$page.js
import * as React from "@uniflowed/react";
import Counter from "./_components/Counter.js";
export default component CounterPage() {
return (
<section>
<h1>rsc-split-app counter</h1>
<Counter />
</section>
);
}
"use client";
// @flow
// app/counter/_components/Counter.js
import * as React from "@uniflowed/react";
import { useState } from "@uniflowed/react";
export default component Counter() {
const [count, setCount] = useState<number>(0);
return (
<button type="button" onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
That import is the boundary: a server module importing a "use client" module.
The "use client" module becomes a client bundle root, and the analysis records
the edge. Both files are trimmed from
crates/uf_cli/tests/fixtures/rsc-split-app, which is the project uf's own build
tests read the answer out of rather than asserting it.
What the graph decides
For every module in the project, the analysis answers four questions.
| Question | Answers |
|---|---|
| Which environment does it run in? | server, client, server-actions |
| Which halves reach it? | unreachable, server-only, client-only, server-and-client |
| Can a closure it defines cross into the browser? | isolated, reaches-boundary |
| Does the browser have to evaluate it? | yes when it declares "use client", and yes when it reaches a boundary |
The last one is the split, and it is two facts joined by an or. A
"use client" module is a bundle root by definition. A module above a
boundary is in the browser too, and that second half is uf's client renderer
talking rather than React's: npm/router/client.js hydrates by
re-rendering the matched tree from the same modules the server rendered it
from, so a Server Component above a boundary is a module React needs in order
to reach the boundary at all.
Which leaves the module that reaches no boundary. Nothing under it is ever rendered in the browser, so nothing under it has to be shipped — and that is the whole of the split uf performs today.
The split is at the route
uf build drops a route's page from the client route table when no client
boundary is reachable from that route's page, its layouts, its $loading.js
and $template.js modules, or the not-found and error boundaries that cover
it. Nothing in the browser imports the page, so nothing only the page reached is
emitted.
Three properties of that are worth knowing before you rely on it.
A dropped route is not hydrated, and a Link into one is a document
navigation. There is no page for the client router to render, so the browser
fetches the document — which is what the anchor would have done on its own. See
Routing.
A dropped page is still imported for its side effects. A uf build links the stylesheets it finds in the client module graph, and those are the whole graph's — so a route removed from it outright would take its rules off every page of the site. The import stays, nothing is read from it, and the code is tree-shaken rather than never resolved.
Unknown means ship it. The analysis scans .js. A page written as .mdx, a
.jsx module, a file past the scanner's size limit: none of them is in the
manifest, and the honest reading of a module the analysis never saw is that it
might reach a boundary. Every unknown answers "keep it", so the split can only
remove a route uf has positively decided needs no browser — and a manifest that
is missing, unreadable, or written by an older uf removes nothing at all. A
project driving Vite itself, without uf build or uf dev, gets no split.
Seeing what moved
uf devThe analysis is rerun on every save, and what moved across the client bundle
since the last scan is reported under a client bundle heading. A module that
entered it is named with the shortest chain of imports that put it there —
app/counter/$page.js imports app/counter/_components/Counter.js, which declares "use client" — and a module that left is named with no chain, because
there is none to give. So a page arriving in the browser's bundle is a line in
the terminal on the save that did it, rather than a number in a bundle report a
week later.
The whole project is rescanned rather than one module patched, because whether a module is client-only depends on which entries reach it — there is no per-module answer that is also a complete one.
uf buildThe build's summary counts what the analysis found:
modules 11
client components 3
server actions 0
rsc diagnostics 0
and adds a pages in the client bundle 2 of 5 row only when the split
removed something. The absence of that row means what it meant before the split
existed: every page went to the browser.
.uf/build/meta/uf-rsc-manifest.json carries the decision per module as
proximity, along with every boundary, every bundle root, and every callable
action. It does not carry the build id, only a fingerprint of it —
Server actions says why that matters.
What it reports
Every one of these is an error, and uf build prints them before it runs the
bundler and then fails: a module that breaks the contract is not going to be
fixed by bundling it. The one exception is the last row.
| Rule | What it means |
|---|---|
rsc/server-only-import-in-client | A module the client graph reaches imports @uniflowed/db, @uniflowed/server, server-only, a subpath of one of those, or a *.server.js file. The message names the chain of imports from the client boundary down to that module — `app/Counter.js` → `app/format.js` → `app/session.js` — because the import to cut is on that path, and rarely in the module that did the importing |
rsc/client-only-api-in-server | A Server Component calls useState, useEffect, createContext and the rest, or reads window, document, localStorage, sessionStorage, navigator or alert |
rsc/client-only-hook-in-server | A Server Component calls a hook @uniflowed/hooks declares as browser-only, or useRouter / useLinkStatus from @uniflowed/router |
rsc/server-action-not-async | A "use server" export React cannot call, because it is not an async function |
rsc/server-action-not-a-function | A "use server" module exporting something that is not a function at all |
rsc/import-escapes-project-root | An import that resolves outside the project |
rsc/module-outside-project-root | A module whose own path is not inside the project |
rsc/directive-not-in-prologue | A directive that is not the module's first statement |
rsc/directive-not-a-string-literal | A directive built from a template literal or a concatenation |
rsc/conflicting-directives | One module declaring both "use client" and "use server" |
rsc/client-directive-in-function | "use client" inside a function body, which React does not support |
rsc/unclassified-hook-in-server | A warning. The check matched names and this hook is not one of them |
That last row is the only thing in the analysis that is not a verdict. It is
a warning because it is a statement that the analysis stopped. A hook you
wrote in the project is followed into its body: one that reaches useState or
another client-only API is rsc/client-only-hook-in-server, naming the module
that defines it, whatever it was imported as
(#388). The router's
useRoute, useLoaderData, useSeo and useIsServer have server
implementations and pass. A hook whose body the analysis cannot read, such as
one from a third-party package, gets the warning instead. Asking the question is more honest than
staying silent, because silence reads as "checked and fine", which is the one
thing it is not.
uf dev prints the same list and changes no exit code. A dev server that
refused to serve a module over a contract violation would be a dev server you
could not use to fix one.
Where uf lint fits
Four of uf's lint rules are about this boundary. They read one file at a time, by text, with no graph — which is why they can run on a git hook over the staged files, and why they are not a substitute for the analysis above.
| Rule | What it matches |
|---|---|
server/no-server-only-import-in-client | A "use client" module whose import, export … from, or require specifier is @uniflowed/server or a *.server.js file |
server/no-client-secret | A "use client" module reading process.env or import.meta.env when the name has a SECRET segment or contains PRIVATE_ |
server/use-client-directive-position | A "use client" or "use server" statement that is not the module's first statement |
server/use-server-actions | A module under server/ or named *.server.js that names the serverAction helper and does not open with "use server"; |
All four are errors by default. The first and third answer a narrower version of
an rsc/ question sooner — one file, no imports resolved, no graph. The second
has no counterpart in the graph, because a secret read from a client module is a
string and not an edge. The fourth is about where a project keeps its action
modules rather than about the boundary at all.
What is not here
How it is built. React's Flight renderer only loads where react resolves
under the react-server export condition, and the HTML renderer and the browser
need the other build, so a uf application is three module graphs, each a Vite
environment: rsc, which holds the route table and every server component;
ssr, which renders the payload into HTML and holds the server copy of every
client module; and client, which hydrates the payload and holds only the
client modules. In the rsc graph a "use client" module is replaced by one
client reference per export, which is why none of its code runs there and none
of a server component's code reaches the browser.
All of your server code runs in the rsc graph: pages, layouts, loaders,
server actions, route handlers ($route.js) and middleware ($middleware.js).
A module that any two of them import is one instance per process. What an
action or a POST handler writes to an in-memory store is what the next render
reads, including the page a form posted before hydration gets back. A
module-level cache, rate limiter or connection pool is likewise shared by
everything that imports it
(#1469,
#1487). One instance per
process only: a deployment with more than one process still needs shared
state outside the process.
The cost is that a route handler and a middleware resolve their imports under
the react-server condition, the way the pages do. A package that ships a
different build under that condition gives them that build. In particular,
react-dom/server does not load there, so a handler cannot call
renderToString to render an email. Do that rendering outside the handler, for
example in a service that the handler calls.
What that asks of a project.
$error.jsis a client module. An error boundary catches a throw while the browser renders, so its component runs in the browser; a boundary module without"use client"failsuf build(anduf dev's route table) with its file named, before any page can throw.- A client module names its exports. A client module is one reference per
export, so
export * fromin one is refused with the fix in the message. - A server component edit reloads the page under
uf dev. The browser holds no module for a server component, so there is nothing for Fast Refresh to replace; a client module keeps Fast Refresh.
What is not here.
- A form that posts before hydration, with an action passed as a prop. Passing an action down works once the page has hydrated. A form that has to work before that imports the action instead (Server actions).
- An inline
"use server"closure written in a"use client"module. Sealing its captures would mean shipping the build id. Write the closure in a server module, or export an async function from a"use server"module.
What uf does not do lists these beside every other gap.
Where to go next
Server actions is next: the other half of the server boundary, the functions a form or a transition calls on the server. Async React is what a reader sees while that work is unfinished.
Edit this pagedocs/app/guide/server-components/$page.mdx