Getting started
Migrating to uf
From create-react-app, from Vite + React, or from Next.js: what survives, what
you rewrite, what you lose, and roughly what it costs. There is no uf migrate
command and there is not going to be a false number here either — nobody has
moved a large application to uf yet, and what npm holds is the closure a new
project needs rather than everything this repository implements
(#210).
Three questions that mean "not yet"
Answer these before you read the rest. Each one is a wall, not a speed bump.
Where do you deploy? A build renders per request, serves route handlers
and streams — through uf preview, uf start, the directory
uf build --adapter node writes, or the file uf build --compile writes. What
is missing is the targets: node is the only one of the seven deploy
adapters implemented, so a project that needs a Cloudflare Worker or a Lambda
has an entry file to write and no help writing it yet
(#391). If that is you, read
uf compared before you start.
Do you have TypeScript you cannot convert? uf transforms .js, .jsx,
.mjs and .cjs in your project and nothing else. A .ts file is handed to
Biome for formatting and is invisible to everything else uf does. Migration
means converting the whole application, not adding uf beside tsc.
Do you depend on typed packages? Every dependency without a Flow
libdef is any. Count them now: that
number, not the file count, is what decides whether this is worth doing.
Flow and TypeScript is the long version.
What every migration has in common
Files you delete
| You have | uf replaces it with |
|---|---|
babel.config.js, @babel/preset-flow, the syntax plugins | nothing — Flow reaches JavaScript inside the binary |
tsconfig.json | nothing — the type system is Flow |
.prettierrc | fmt in uf.config.js |
.eslintrc | lint in uf.config.js, alongside Flow's own lints |
jest.config.js, vitest.config.ts | test in uf.config.js |
vite.config.ts | uf.config.js, with a vite key for everything uf has no opinion about |
scripts in package.json | tasks in uf.config.js — uf install refuses a manifest that declares scripts |
index.html | the root layout: a component that renders <html> |
uf new my-app in a scratch directory is the fastest way to see
the shape you are aiming at — nine files, and
Your first project walks through them.
What stays exactly as it is
public/. Served from the root, copied into the build untouched.- CSS. Plain imports,
.module.css, and the preprocessors Vite supports. - Vite plugins.
pluginsinuf.config.jsis a list of ordinary Vite plugins, appended to uf's own. - React itself. React 19, the same hooks, the same rendering model. uf
compiles
componentandhookdeclarations to plain functions and hands them to the official React Compiler.
Converting the types
Rename .ts and .tsx to .js, put // @flow on the first line, then work
through the constructs. Most of them are a one-line change:
| TypeScript | Flow |
|---|---|
interface Props { … } | a type alias — and Flow's object types are exact by default |
function C({a, b}: Props) | component C(a: A, b: B) — the parameter list is the props |
React.FC<Props> | component |
| a custom hook | hook useThing(…) — the rules of hooks become type errors |
unknown | mixed |
readonly x: T | +x: T (Flow also accepts readonly) |
x as T | (x: T), or a documented $FlowFixMe |
a .d.ts you wrote | a libdef, or Flow types in the source |
switch with a default: never | match, which is exhaustive by construction |
Two things that will surprise you. uf check resolves no modules yet: every
import is any and the report names them, so a converted file can look clean
and still be wrong across a boundary
(#248). And a file with no
import and no export is read as a script, where await is an ordinary
identifier rather than an operator, so a top-level await in one is refused;
uf decides the goal symbol from the file's own syntax
(#204).
From create-react-app
The best-supported path, because CRA applications are usually a client-rendered bundle and that is what uf builds.
uf new ../my-app-uf, then copysrc/into it.- Delete
react-scripts. Itsscriptsblock goes with it —uf installrefuses a manifest that declares one. - Move the contents of
public/index.htmlintoapp/_uf.layout.js. The<div id="root">goes away: uf renders the whole document. - Turn each screen into a route directory under
app/. uf's file-system router is the routing uf builds against;app.router.enabled: falsemakes the project a library rather than an application, so it is not a way to keep React Router. - Convert the types, if the project had any.
uf fmt, thenuf lint, thenuf check, thenuf test.
What you lose. Jest — uf test has the API and no coverage
(#280), so a suite that
reports coverage does not come across whole. jest.mock has a destination:
uft.mock replaces a module on Node, though it is not hoisted, so a mock has to
be written above the await import(…) that reaches it rather than anywhere in
the file. REACT_APP_* environment variables need renaming: uf reads the same
.env cascade, but the prefix that reaches the browser is VITE_ — see
Environments, and vite: { envPrefix: "REACT_APP_" } if you would
rather not rename them.
Cost. The route and entry work is a day's shape regardless of size; the
long pole is the test suite, in proportion to how much of it reports coverage
or leans on a hoisted jest.mock.
From Vite + React
The mechanics are smaller — you are already on the same bundler — and the language change is the whole job.
vite.config.tsbecomesuf.config.js. Options uf owns (dev.port,build.outDir) have names; everything else goes under thevitekey unchanged, includingresolve.aliasandserver.warmup.index.htmlbecomesapp/_uf.layout.js. Vite runs inappType: "custom"under uf: there is no HTML entry to fall back to, and the document comes from the layout.- Your entry module becomes
app.jsplus a page underapp/. - Convert the types.
- Replace Vitest with
uf test— thedescribe/it/expectsurface is the same, andexpecthas 25 matchers,.not,.resolves,.rejectsandfn().
What you lose.
vite preview. uf has no equivalent (#255).vite/client's types.import.meta.glob,import.meta.hotand asset imports are type errors oranyunderuf check— five errors in a five-line file (#264). The code still runs; the checker cannot see it.- Choosing your Vite version. uf depends on
vite: "^8.2.2".
Cost. A day for the configuration and the entry, then the type conversion, which is the part that scales with the codebase.
From Next.js
Read What uf does not do first, then this.
A Next.js application that needs a server cannot move to uf today. Server
Components are analysed and never executed, so every page module ships to the
browser (#252); server actions
have a registry and no dispatcher; nothing streams
(#254); there is no
revalidation (#277); and
middleware.ts has an exact counterpart in uf that is discovered, reported by
uf inspect --json, and never called
(#260).
What can move is an application you could already build with
output: 'export' — static pages, client-side data fetching, no per-request
rendering.
The conventions line up more closely than the capabilities do:
| Next.js | uf |
|---|---|
app/page.tsx | app/_uf.page.js |
app/layout.tsx | app/_uf.layout.js |
app/[slug]/page.tsx | app/[slug]/_uf.page.js |
app/[...rest]/ | app/[...rest]/ |
app/(group)/ | app/(group)/ |
app/api/x/route.ts | app/api/x/_uf.route.js — Request in, Response out, uf dev only |
not-found.tsx | _uf.not-found.js, a segment file resolved by the nearest one above the path; a project that declares none gets uf's page inside the root's layouts |
template.tsx | _uf.template.js — a layout that remounts, keyed on the pathname |
@slot/ | refused by name. uf has no parallel routes, so there is no second place for a route to render (#267) |
(.)folder/ | refused by name. uf has no intercepting routes: a navigation carries where it is going and not where it came from (#267) |
default.tsx | nothing — it exists to resolve a slot a URL says nothing about, and there are no slots |
middleware.ts | _uf.middleware.js, never called |
generateStaticParams | generateStaticParams |
export const metadata | metadata on a page or layout, merged along the path with the page last |
next/link | Link from @uniflowed/router, prefetching on intent |
next/image | Image from @uniflowed/web — layout-stable markup, no pipeline (#273) |
loading.tsx | _uf.loading.js — a <Suspense> boundary per segment, nested; the loader is still awaited before the render (#373) |
error.tsx | _uf.error.js, one file for 401, 403 and 500 as cases of RouteError |
Cost. For a statically exportable site, the same as the Vite path plus the route renaming. For anything else, it is not a cost — it is a blocker, and the issues above are where to watch for it to lift.
Estimating your own migration
There is no measured migration to quote, so here is the arithmetic instead. The job is three numbers you can count today:
- Modules to convert. Mechanical, and mostly the table above.
- Dependencies with no Flow libdef. Each one is
anyat the boundary, and this is the number that decides whether the type system pays you back. - Tests reporting coverage. These have no destination yet. Tests using snapshots or module mocks do, though a mock has to move above the import it affects.
And one number you can produce: convert a single leaf directory, run
uf check, and read the count. That is the real conversion rate for your
codebase, and it beats any figure this page could assert.
Where to go next
Your first project is the shape you are converting into. What uf does not do is the list of walls, kept current with issue numbers. If the answer turns out to be "not yet", that is a supported answer — it is why the list exists.