Readiness: Implemented
Why uf
uf compared
Against the four things people actually arrive from. The rows uf loses are in the same tables as the rows it wins, because a comparison that only lists strengths tells you nothing you could not have guessed from the home page.
What you will be able to do: set uf beside the tool you use today —
create-react-app, Vite and React, Next.js or Bun — reading the rows uf loses
before the rows it wins.
What you need first: nothing installed. If you write TypeScript, read Flow and TypeScript first: it decides most of this page.
The one-screen version
create-react-app | Vite + React | Next.js | Bun | uf | |
|---|---|---|---|---|---|
| Status | deprecated by React (Feb 2025) | maintained | maintained | maintained | 0.0.0-alpha prereleases |
| Language | TypeScript or JS | TypeScript or JS | TypeScript or JS | TypeScript or JS | Flow |
component / hook / renders / match | Babel preset | Babel preset | Babel preset | no | native |
| Dev server and build | webpack | Vite | Turbopack (or webpack) | bun build | Vite 8 |
| Test runner | Jest | you add one | you add one | bun test | uf test |
| Formatter | you add one | you add one | you add one | none | in the binary |
| Linter | ESLint, wired in | you add one | you add one — next lint was removed in 16 | none | in the binary |
| Type checker | tsc | tsc | tsc | tsc | uf check (Flow) |
| Production server | — | — | next start | Bun.serve, you write it | uf start, adapters, or --compile |
| Config files | none, then all of them after eject | one per tool | one per tool | one per tool | uf.config.js, and only it |
Two rows in that table decide most of the question. If you write TypeScript, the syntax row is worth nothing to you and every other row is a downgrade. If you need a production server with years of deployments behind it, the production-server row still belongs to Next.js.
Read the losses first
| What uf loses | To | By how much |
|---|---|---|
| Test throughput | bun test | 125 ms against 58 ms on 50 files, 1,000 tests, 2,000 assertions (Testing) |
| Deployment maturity | Next.js | seven adapters and --compile are implemented, and CI starts the generated Cloudflare Worker under Wrangler's local runtime, but no server adapter has been deployed in anger; serverless buffers streamed documents, and every hosted target still needs real-world proof (#956, Commands) |
| Server Components | Next.js | routes render through React's own Flight payload, so a Server Component never reaches the browser; an inline "use server" closure in a server module is callable with its captures sealed, and one written in a "use client" module is not an endpoint (Server Components) |
| The typed ecosystem | everything | a package with no Flow libdef is any (Flow and TypeScript) |
| Runtime coverage | Bun, Next.js | Node.js, Bun and Deno 2.8 or newer run every command, but uf test --coverage is Node's alone; edge and serverless are deploy artefacts with target limits rather than runtimes you develop on (#246, Commands) |
| Being three years old | all four | pre-release, no stability guarantee, interfaces move without warning |
What uf does not do is the complete version of that list, sorted into refusals and gaps. Benchmarks is the measured version: every uf command timed beside Vite+, Next.js, Bun and the others on one machine, with the stages uf loses listed first.
Against create-react-app
CRA is the honest ancestor: an all-in-one tool that hid a build behind one dependency. React deprecated it in February 2025 and recommends a framework or a build tool instead.
What uf takes from it: the shape. One command, no configuration to copy, a project that runs before you have made a single decision.
What uf takes from its failure: everything in
docs/red-lines.md.
CRA's three failures were an eject cliff with a one-way door, a single
package serialising every upgrade in the ecosystem, and solving the build and
stopping. uf answers the first with a continuum and no eject, the second by
owning orchestration rather than implementation, and the third by being a
framework as well — which is the one of the three uf has not finished.
What CRA still has that uf does not: a decade of tutorials that assume it, and the fact that it runs on stable, published, installable software.
Against Vite + React
This is the closest comparison and the least adversarial one, because uf's
dev server and build are Vite — npm/vite/package.json depends on
vite: "^8.2.2" and uf drives it over a JSON protocol. Bundling performance is
not a row: it is the same bundler.
What uf adds. Flow's React syntax with no Babel anywhere in the pipeline;
one config file instead of a vite.config.ts plus a test config plus a
formatter config plus a linter config; and a test runner, formatter, linter and
type checker that read the same syntax tree the build does.
What plain Vite keeps that uf gives up.
- TypeScript, and with it every typed package on npm.
- A build tool and nothing more. Plain Vite does not decide your router, action protocol, renderer, test runner or formatter for you.
- Direct ownership of
vite preview.uf previewuses Vite's preview server and mounts uf's request handler behind it, but the command you operate is still uf's (Commands). if (import.meta.hot)as a guard.uf checkdeclares Vite's client API —import.meta.glob,import.meta.hotand asset imports check — but Flow cannot refine a test onimport.meta, so the guard Vite's own documentation writes is an error here, and uf's lint asks forimport.meta.hot?.accept()instead (Flow and TypeScript).- Any Vite version you like. uf pins the line it tests against.
What does not change is the escape hatch: the vite key in uf.config.js is
merged over what uf generates, and plugins is a list of ordinary Vite plugins.
Against Next.js
The largest gap, and the one worth being blunt about.
next build produces a running application: next start serves it,
output: 'standalone'
writes a self-contained server.js, and output: 'export' is the opt-in for
the fully static case.
uf now has the same categories of output — a local preview, a production server, server adapters, a static adapter and a single executable — but that is not the same thing as having Next.js's deployment record. The comparison below therefore separates "there is code" from "this is mature". uf and Next.js is the full audit, one App Router feature per row.
| Capability | Next.js | uf today |
|---|---|---|
| File-system routing, nested layouts | yes | yes (Routing) |
| Data loading before render | yes | loader exports |
| Route handlers | yes | yes under uf dev, uf preview, uf start and server adapters; the static adapter refuses them by name (Commands) |
| Server Components, server actions | yes | Server Components through React's Flight payload, a navigation that fetches the next route's payload, and callable exported actions, imported or passed down as props; forms that submit before hydration; no inline action references (Server Components, Server actions) |
| Streaming, Suspense boundaries | yes | HTML streaming on Node, Bun, Deno, container and edge; static prerender waits, and serverless buffers the response (Async React, Commands) |
| ISR, caching, revalidation | yes | route and fetch caches, opt-in, with time-based and on-demand revalidation; a prerendered page that states a lifetime is served from the build and regenerated in the background once it passes (isr), kept on disk, in Workers KV, or in a provider you name. A page that reads the request inside a <Suspense> boundary is prerendered as a static shell and its boundary streamed per request (ppr) on every adapter that streams. Data and action caches are refused (Caching, rendering.modes) |
| Middleware | yes | request middleware runs before route resolution on server targets; static output cannot run it and is refused when it would be required (Commands) |
HTTP QUERY | no: a route handler exports GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS | a route handler exports QUERY and @uniflowed/fetch sends one; a proxy with its own list of verbs can still refuse it (Routing) |
| Image optimisation | yes, plus a request-time optimizer | an imported image is resized at build time to the declared widths and gets a real srcset, sizes, intrinsic dimensions and a blur placeholder; a remote image goes through /__uf/image, behind remotePatterns, and comes back as AVIF where the browser takes it. No AVIF at build time and no lossy WebP, and a deployed node-family directory encodes with a module you name (Images, fonts, icons and cards) |
| Font self-hosting | yes, downloaded and subsetted at build time | an imported font is self-hosted, content-hashed, declared with font-display and paired with a size-adjust fallback computed from its own metrics, and app.builtins.fonts.subset: "ranges" splits it by script with exact unicode-ranges. Not downloaded from a provider, written as WOFF rather than WOFF2 when it is split, and not cut down to the text a page uses (Images, fonts, icons and cards) |
| Error boundaries | yes | $error.js, resolved by the nearest ancestor the way $not-found.js is; unauthorized(), forbidden() and a thrown error reach it as one RouteError, answered 401, 403 and 500 (Routing) |
| Deployment adapters | many | node, bun, deno, container, edge, serverless, static, plus --compile; server adapters are tested for identical answers, not proven by real deployments (Commands) |
What uf has that Next.js does not. Flow's React syntax, checked by Flow's
own inference. A formatter, a linter and a test runner in the same binary,
reading the same tree — Next.js 16 removed next lint, and next build no
longer lints, so all three are yours to bring. A server compiled into one
executable file with the runtime inside it, where output: 'standalone' writes
a directory that still needs Node. And a framework whose server is optional at
the route level, which is a feature exactly when part of the application really
is files.
What uf does about the Next.js failures that became CVEs. A server action's
id is an HMAC of the build, its arguments are a closed JSON grammar with no tag
that names a constructor, and its endpoint refuses any call whose Origin is
not its Host; which middleware runs is decided by the request path and never
by a request header.
docs/security.md
maps each decision to the CVE it answers, CVE-2024-34351 and CVE-2025-29927
among them. None of that is proof against a bug of uf's own, and
Server actions is plain about the one guarantee uf
cannot give: that whoever made the call was allowed to.
If you are building a TypeScript application that must render per request in production this quarter, use Next.js. uf can run that shape now, but it has not earned the boring answer yet. The point of documenting the adapters this bluntly is that a missing deployment story should be visible before deploy time, not discovered by a 2 a.m. incident.
Against Bun
Bun and uf overlap less than the feature lists suggest: Bun is a runtime that brings a package manager, a bundler and a test runner; uf is a toolchain that runs on a runtime — Bun included.
Where Bun wins, measured. bun test runs the same suite in 58 ms against
uf test's 125 ms warm and 429 ms cold, on 50 files, 1,000 tests and 2,000
assertions, the median of 25 interleaved rounds on an 8-core Apple M3. That is
about 2.2 times faster, and the reason is structural: Bun runs everything in
one process with the runner written into the engine, while uf starts worker
processes, gives each worker one file at a time, and pays the start-up once per
worker. Both runners have
snapshots, coverage and a JUnit reporter —
Bun's, and uf test -u, --coverage and
--reporter junit — with one difference that matters on this comparison:
uf test's coverage is Node-only, so a suite run on Bun measures nothing
(Testing).
Where uf wins the same comparison. uf test is about ten times faster
than vitest run on that suite (1,310 ms), it runs on Node.js as well as Bun,
and it runs Flow — bun test has no component or hook syntax at all.
Package management. uf does not implement one. uf install, uf add,
uf remove, uf update, uf why, uf ls, uf audit, uf search,
uf patch and uf catalog detect the project's package manager — Bun's
included — and run it, and each says so when that manager has no such command
rather than reaching for a different one. Lifecycle scripts are refused on the
way in: a manifest that declares scripts is rejected, and --ignore-scripts
goes to whatever runs unless uf pm approve-builds has named the dependencies
that may build — on pnpm, Bun and Yarn 2+, which keep such a list; npm and Yarn 1
do not. uf install --frozen-lockfile is the CI install. Bun's installer is a finished product — a
resolver, a global cache, a lockfile format; uf's is a policy in front of yours,
and deliberately: writing a resolver would make uf the chokepoint every
ecosystem upgrade has to pass through.
And Vite+
Vite+ is the toolchain uf will be compared to most often, and it has its own page: uf and Vite+. The short version is that they answer different questions — Vite+ unifies the TypeScript toolchain, uf makes Flow's React syntax usable — and Vite+ has a company and a support contract, which uf does not.
So which one
- Writing TypeScript, shipping to production this quarter: Next.js if you need a server, Vite if you do not. uf is not competing for this.
- Writing TypeScript, unhappy about the number of config files: Vite+.
- Writing Flow, or wanting to: uf, and there is currently nothing else — which is the whole reason it exists.
- Building a static site, curious, willing to be early: uf builds this one.
uf buildprerenders every page of this documentation, and the output is what you are reading.
Where to go next
uf and Next.js is next: every App Router feature, one row each, with what uf has in its place. Migrating to uf is what moving from any of the above costs.
Edit this pagedocs/app/guide/compare/$page.mdx