Readiness: Experimental
Targets
Deploy a web build
The build is the thing you deploy. What changes is what the machine answering requests already has: this checkout, a JavaScript runtime, a platform runtime, a static file host, or nothing but one executable.
What you will be able to do: choose between the local servers, adapter outputs, a static host and a compiled binary, know what a static deployment refuses, know what a tab opened before a deploy does after it, write an adapter for a platform uf does not ship one for, and know which environment variables are read when the server starts.
What you need first: a web project with dependencies installed — Your first project, then Dependencies.
Choose the shape
Every path starts with uf build. After that, choose
the smallest thing the host can honestly run:
| The host has | Use | What travels |
|---|---|---|
| this checkout, dependencies and Vite | uf preview | nothing; it checks the build in place |
| this checkout, dependencies and a JavaScript runtime | uf start | dist/, .uf/build/server, and node_modules |
| a platform runtime | uf build --adapter TARGET | .uf/deploy/TARGET/ |
| only a static file host | uf build --adapter static | .uf/deploy/static/ |
| nothing but a file it can execute | uf build --compile | dist/NAME |
The commands on this page were run in a fresh project outside this repository,
using a uf binary built from main. Timings and byte counts will move; the
shape of the output is the contract.
Build once
uf build writes the browser assets, prerendered
documents and server bundle. In a fresh web-deploy app, its summary ended
with:
output
web-deploy
|- .uf
| `- build
| `- meta
| |- openapi.json
| |- uf-build-manifest.json
| |- uf-bundle-report.json
| `- uf-rsc-manifest.json
|- dist
| |- 404.html
| `- index.html
`- router.js
+ build succeeded in 14.66s
dist/ is what a browser can fetch. .uf/build/meta/ is for you and tooling.
The server bundle is what uf preview, uf start, the server adapters and a
compiled binary run when a request needs more than a file.
Check it before it leaves
Use uf preview when you still have the project
and want Vite's preview server around the production build:
uf preview web-deploy
----------------------
serving the production build, through Vite's preview server
host node
mode production
local http://127.0.0.1:4173/
routes 1 route
handlers 0 route handlers
+ serving the production build
Use uf start when the checkout is the deployment
shape: no Vite process, just uf's server over the built output.
uf start web-deploy
--------------------
serving the production build, with no bundler in the process
host node
mode production
local http://127.0.0.1:3000/
routes 1 route
handlers 0 route handlers
+ serving the production build
Both answered / with the same 1,643-byte HTML document in the checked project.
That is the promise: assets first, then route handlers, then a render, whichever
front door you used.
Package it for the host
uf build --adapter TARGET writes a
directory that can leave the checkout. The server targets share the same
@uniflowed/server/fetch handler; the target-specific file is only the host
half.
| Target | Checked command | Output summary |
|---|---|---|
| Node | uf build --adapter node | .uf/deploy/node, 17 files, cd .uf/deploy/node && node server.js |
| Bun | uf build --adapter bun | refused here: bun: 1.3.13 is older than 1.3.14 |
| Deno | uf build --adapter deno | .uf/deploy/deno, 17 files, deno run --allow-net --allow-read --allow-env --allow-write=.uf server.js |
| Container | uf build --adapter container | .uf/deploy/container, 19 files, docker build -t web-deploy .uf/deploy/container && docker run -p 3000:3000 web-deploy |
| Edge | uf build --adapter edge | .uf/deploy/edge, 18 files, cd .uf/deploy/edge && npx wrangler deploy |
| Serverless | uf build --adapter serverless | .uf/deploy/serverless, 17 files, cd .uf/deploy/serverless && zip -r ../function.zip . |
| Vercel | uf build --adapter vercel | .uf/deploy/vercel (its .vercel/output), cd .uf/deploy/vercel && vercel deploy --prebuilt; added after the rest of this table was checked |
| Static | uf build --adapter static | .uf/deploy/static, 14 files, upload the contents of .uf/deploy/static to a static host |
The successful adapter commands built the directories. They did not deploy them to Cloudflare, AWS, a container registry, a Deno host or a Bun host.
The Deploy parity workflow (.github/workflows/deploy-parity.yml) is written
to do that: on a schedule and on every release tag, it deploys the
served-app fixture as a container on Fly.io (node/container), as a
Cloudflare Worker (edge) and as an AWS Lambda function (serverless), then
asks each deployment the adapter-parity questions beside uf start over the
same build. Each platform job needs that platform's repository secrets and
skips by name without them. None of the three has run against a real platform
yet: the repository has no credentials for any of them, so no server
adapter's output has been proven on a real platform by uf's own pipeline, and
#956 stays open for that
evidence. What does run on every such change is the workflow's harness, which
asks a node artefact on the runner the same questions and proves the
comparison itself.
What each target is tested for
Every server adapter is also built and served on its platform's own runtime or
local emulator, on every change to the adapters, the router or the server, and
in the release queue: CI's Deploy matrix job builds one fixture with every
rendering mode (tools/deploy-matrix/fixture) for each target, starts the
directory uf build --adapter wrote under that target's host, and asks it
over HTTP — status, headers including cache headers, bodies, when each part of a
streamed body arrived — and in Chrome, whether the page hydrates and navigates
on the client. A mode a target cannot serve is built too, and the build must
refuse it by name.
These are emulators, not the platforms: Wrangler's local workerd is not
Cloudflare's edge, and Kumo is not AWS. What a cell marked Verified in CI
establishes is that the generated directory behaves correctly under that host
in the run that passed, not that a deployment to the cloud does — that is the
Deploy parity workflow's job, below.
| Mode | Node | Bun | Deno | Container | Cloudflare Workers | AWS Lambda | Static host |
|---|---|---|---|---|---|---|---|
Static (SSG), incl. generateStaticParams | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI |
| SSR per request | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Unsupported (rejected) |
| Streaming SSR with Suspense | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI [1] | Unsupported (rejected) |
Static shell with holes (ppr) | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Unsupported (rejected) [2] | Unsupported (rejected) |
| ISR, time-based | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI [3] | Verified in CI [4] | Unsupported (rejected) |
ISR, on demand (revalidateTag) | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI [3] | Verified in CI [4] | Unsupported (rejected) |
| RSC navigation (Flight payload) | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI [5] |
| Server action, JSON call | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Not emulated [6] | Unsupported (rejected) |
| Server action, form before hydration | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Not emulated [6] | Unsupported (rejected) |
| Route handlers | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Unsupported (rejected) |
| Cookies in and out | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Not emulated [7] | Unsupported (rejected) |
| Middleware rewrite | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Unsupported (rejected) |
| Redirects, rewrites, headers | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Unsupported (rejected) |
| Not-found boundary (404) | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI |
| Error boundary (500) | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Unsupported (rejected) |
| Hashed client assets | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI |
| Hydration and client navigation (browser) | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI | Verified in CI |
Image optimiser (/__uf/image) | Planned [8] | Planned [8] | Planned [8] | Planned [8] | Planned [8] | Planned [8] | Planned [8] |
- A Lambda response is one JSON value, so a streaming page arrives whole; the check asserts it is complete and that the fallback and the content arrive together.
uf build --adapter serverlessrefuses a page prerendered as a static shell, naming it, because a buffered response would send the shell with its holes.- Regenerated pages and invalidations are kept in Workers KV (
UF_CACHE, the bindinguf build --adapter edgewrites), in Wrangler's local KV; the check restarts the Worker and reads them back. - With a project provider in
rendering.cache.store(the fixture'ss3-cache.js, over an S3 bucket in Kumo); the check restarts the function container and reads the page back from S3. Without a provider the build refuses the regenerating pages by name, which is checked too. - Cloudflare's asset server sends a prerendered
__uf.flightwith no content type. The router accepts an untyped200for the payload URL whose first line is a Flight row (#1508), so navigation stays on the client; the check asserts that shape. The deploy guide gives the_headersrule that types it. - Kumo 0.30.0's HTTP API event carries no
hostheader and norequestContext.domainName, so uf's same-origin check refuses every action (as it should for a request that names no host). Real API Gateway sends both. The check runs and is expected to fail until Kumo does. - Kumo 0.30.0 drops the
cookiesarray of a payload format 2.0 response, which is where@uniflowed/server/lambdaputs everySet-Cookie(as API Gateway requires). The check runs and is expected to fail until Kumo does. - The remote image optimiser needs an image origin the target may fetch and, on
edge, Cloudflare's Images binding; it is covered in process bynpm/server/image.test.js, not on these hosts yet.
| Target | Adapter | Run as |
|---|---|---|
| Node | node | node server.js, Node 24 |
| Bun | bun | bun server.js, Bun 1.3.14 |
| Deno | deno | deno run --allow-net --allow-read --allow-env --allow-write=.uf server.js (the command uf build prints), Deno 2.9.7 |
| Container | container | docker build + docker run of the generated Dockerfile |
| Cloudflare Workers | edge | wrangler dev --local (workerd), Wrangler 4.128.0, local Workers KV |
| AWS Lambda | serverless | API Gateway HTTP API in Kumo 0.30.0, function in the AWS Lambda Node 24 image (RIE) |
| Static host | static | Workers static assets under wrangler dev --local (html_handling: drop-trailing-slash, 404-page), no Worker script |
Run one column yourself with node tools/deploy-matrix/run.mjs --target edge
(it needs Docker for container and serverless, and UF_BROWSER for the
browser row), or --build-only on a machine that cannot open a socket, which
still checks every refusal. tools/deploy-matrix/matrix.json is the table
above; CI fails when a cell does not behave as it says, and the library suite
fails when this page does not match it.
Static is only files
A static host cannot run a route handler, middleware, a callable server action,
an app.router redirect, rewrite or header, or a parameterized route that has
no generateStaticParams. uf build --adapter static
refuses those instead of writing a directory that only answers part of the app.
After adding app/api/health/$route.js to the checked project, the command
printed:
error: this project cannot be served by a static host, so `uf build --adapter static` would have written a directory that answers most of it and silently dropped the rest
/api/health (app/api/health/$route.js) — a route handler answers a request, and a static host has only files
Serve it with `uf build --adapter node`, `--adapter container`, `--adapter edge` or `--adapter serverless` — the application is the same file in all four — or remove what needs a server and build again.
https://github.com/ubugeeei-prod/uf/issues/335
The fix is a deployment choice, not a flag: remove the server-only feature,
give a dynamic route every path through generateStaticParams, or choose a
server target.
A host that knows the payload's type is faster, not required. Each
prerendered page has an RSC payload beside it, <route>/__uf.flight, which a
client-side navigation fetches instead of the document. Its type is
text/x-component, and many static hosts do not know the extension: they send
it untyped or as application/octet-stream. The router accepts such an answer
when it is a 200 for the payload URL itself and its first line is a payload
row, so navigation stays on the client. A host that can be told the type
should be; on Cloudflare (Workers static assets, Pages) and Netlify that is a
_headers file in the uploaded directory:
/__uf.flight
Content-Type: text/x-component
/*/__uf.flight
Content-Type: text/x-component
uf does not write it into .uf/deploy/static, because a platform's file in
that directory is a file every other host serves at a URL the site never
mentions.
A tab opened before the deploy
A deploy replaces the server while browsers still hold pages from the previous build. Every build mints new server action ids and new chunk names, so a tab opened on build N that calls an action or opens a lazy route after build N+1 is live would otherwise call an action the server has never heard of, or ask for a chunk that is gone.
uf build gives each build a deployment id and carries it three ways:
| Where | How |
|---|---|
| the document | <meta name="uf:deployment" content="…"> in every page's head, prerendered or rendered |
| the action endpoint | every server action call sends uf-deployment: <id> |
| payload and chunk requests | every navigation payload request sends the same header, and every payload says in its root which build rendered it |
The id is a 16-character digest of the build id, not the build id itself: the
build id is the key action ids are derived from and is never published. Two
artefacts that are one build — UF_BUILD_ID set to the same value — publish
one deployment id.
On a mismatch, nothing of the new build runs. Every front door — uf start,
uf preview, every adapter and a compiled binary — answers a request naming
another build with 409 Conflict, cache-control: no-store, and a
uf-deployment header naming the build that is live. That happens after the
host's files and before middleware, server actions, route handlers and
rendering, so a stale action id is never looked up. The browser then makes a
hard navigation instead of calling a stale action or rendering a stale
payload:
- an action call that is refused loads the current page's document again, and the promise it returned never settles, because the page is being replaced;
- a navigation whose payload is refused, or whose payload another build rendered — a prerendered payload is a file, and a file host refuses nothing — loads the target's document instead of rendering it;
- a navigation whose chunk cannot be fetched loads the target's document instead of showing the route's error boundary. A module that loaded and then threw still reaches the error boundary: a reload would not fix it.
A page with no deployment id — uf dev, or a build from before this existed —
sends no header and compares nothing.
The previous build's static assets stay servable for one build. Before the
bundler empties the output directory, uf build sets aside every hashed file
the previous build's .vite/manifest.json names; after measuring what the new
build ships, it puts back each one the new build did not write, and records them
in .uf/build/meta/carried-assets.json. So build N+1's dist/ — and every
adapter's static/, and a compiled binary — still serves build N's chunks, and
a tab on build N keeps loading them. Build N+2 does not carry them again: a tab
two deploys behind falls back to hard navigations. The summary says
kept from the last build when it kept any.
That window depends on the output directory surviving between builds. A CI job
that builds on a fresh checkout has no previous build to carry, and a platform
that replaces its files wholesale on upload keeps only what that upload held;
both fall back to the hard navigation for a chunk that is gone. Restore the
previous dist/ before building (a CI cache) to get the window there too.
crates/uf_cli/tests/vite.rs's
a_tab_on_the_previous_build_keeps_working_or_loads_the_document_again builds
the rsc-split-app fixture three times and asks uf start what a tab on the
first build would ask: build N's chunks answer 200, build N's action call is
refused with a 409 naming build N+1 without running, the prerendered payload
names build N+1, and build N's chunks are gone after build N+2.
npm/router/deployment.test.js drives the browser's half.
The adapter contract
A platform uf ships no adapter for can be served by writing the one file an
adapter is. Every server adapter in this repository is the same handler.js
with a few lines around it, and those lines are the contract.
What the platform receives is the directory
uf build --adapter node writes, or
--adapter edge for a runtime with Web-standard APIs and no Node built-ins:
| Path | What it is |
|---|---|
handler.js | the application: export default { fetch, beginRequest, routing } |
static/ | the output directory: hashed assets, prerendered documents and payloads, and the previous build's hashed assets for one build |
chunks/ | modules handler.js imports, when the bundle split |
package.json | {"type": "module"} |
fetch takes a Request and returns a Response. It touches no filesystem
and holds no socket; it runs middleware, server actions, payloads, route
handlers and rendering in that order, and it refuses a request from another
build by itself.
What the platform's entry owes it, for every request, in this order:
- Serve files first. A
GETorHEADfor a file understatic/is answered with it before any application code runs; a request with any other method never is. A miss falls through to the application, whose 404 is the project's own page. - Apply
routing's redirects and headers in front of those files, if the entry serves them itself. A request that reachesfetchhas them applied there. - Begin one request with
handler.js's ownbeginRequest, never with a copy of@uniflowed/serverinstalled beside the entry: the request store belongs to the copy bundled into the application. - Run
fetchinsiderun, and callsettleafter the body has been sent, or hand it to the platform'swaitUntil. - Keep every
Set-Cookieseparate, and stream the body where the platform can. - Answer a thrown error with a bare
500, with nothing of the error in the body.
@uniflowed/server/adapter is that contract as code: the HandlerModule type,
handlerModule(loaded), which refuses by name a module that is not a uf
handler, and answerWith(handler), which does steps 3, 4 and 6 and hands the
settle to the entry. @uniflowed/server/node's createStaticHandler and
@uniflowed/server/edge's createWorkerFetch are the reference for steps 1
and 2 on a filesystem and behind an assets binding.
How to know it conforms. Build crates/uf_cli/tests/fixtures/served-app
with the adapter, deploy it, run uf start over the same build, and run:
node tools/ci/deployed-parity.mjs --reference http://127.0.0.1:3000 --deployed https://your-deployment.example
It asks both the adapter-parity questions — route handlers, a render, a
loader's redirect, the project's 404, app.router rules, a middleware rewrite,
a prerendered page, and the two version-skew answers — and fails on the first
that differs.
Compile one file
uf build --compile writes one executable
that carries the runtime and the assets. In the same fresh project it added:
standalone
binary dist/web-deploy
runtime bun 1.3.13
bytes 66.80 MB
embedded assets 14
embedded bytes 1.76 MB
+ build succeeded in 23.75s
That file can be copied by itself. The trade is the obvious one: the runtime and assets are fixed inside it, so changing either means building again.
Environment at start-up
Values used to choose the socket — HOST, PORT, and their command-line
overrides — are read when uf start, the
node/bun/deno/container adapter server, or a compiled binary starts.
Server-only values read through process.env are start-up values too, as long
as the code that reads them runs on the server.
Values shipped to the browser are different: they are part of the client bundle
and need a rebuild. Environments is the longer contract, including
which .env files each command loads and why an already-set process variable
wins over a file.
What is established
uf preview, uf start, server-adapter directories and --compile use the
same request handler. The static adapter deploys generated files only. The
checked project confirmed the local servers answered the same document. The
tests go further by driving adapter outputs in process, and they drive version
skew across two real builds under uf start. The deploy
matrix serves every adapter's directory under
its platform's runtime or local emulator — Node, Bun, Deno, Docker, Wrangler's
workerd, Kumo with the AWS Lambda runtime image, and Workers static assets — and
checks each rendering mode there, in a browser as well as over HTTP.
What is not established is a successful real deployment of the server adapters.
The Deploy parity workflow is written to produce that evidence and has not
yet run with credentials. When it does, write down the platform, the generated
directory and the runtime version beside
#956. The previous build's
assets are kept only where the output directory survives between builds;
version skew is not tested in a real browser.
Edit this pagedocs/app/guide/deploy/$page.mdx