Writing code
Routing
Files under app/ are the route table. There is no route configuration to
write and no list to keep in step with the directory.
The file names
Eight names are reserved. Everything else in app/ is an ordinary module.
| File | What it is |
|---|---|
_uf.page.js | The page at this path. Its default export is a component. |
_uf.layout.js | Wraps this path and everything under it. Receives the page as children. |
_uf.template.js | The same wrapper, rebuilt on every navigation instead of persisting. |
_uf.not-found.js | Rendered when nothing under this path matches. |
_uf.error.js | Rendered when something under this path throws. |
_uf.loading.js | Rendered while something under this path is still resolving. |
_uf.middleware.js | Runs before anything under this path answers — auth, redirects, logging. |
_uf.route.js | Answers a request instead of rendering a page. |
A page, a layout and a not-found may be .mdx instead of .js. A directory whose name starts with
_ is not a route, which is where shared components belong — this site keeps
its design system in app/_design/.
uf routes add writes them, so the names above are a reference rather than
something to type from memory:
uf routes add /articles/[slug] --loader
uf routes list
Both read the same grammar the build discovers routes with — see
uf routes.
Paths
A directory is a path segment:
app/_uf.page.js → /
app/guide/_uf.page.mdx → /guide
app/guide/install/_uf.page.mdx → /guide/install
A segment in brackets is a parameter, and a segment in brackets with a spread captures the rest:
app/posts/[slug]/_uf.page.js → /posts/anything
app/docs/[...path]/_uf.page.js → /docs/a/b/c
Two spellings that are refused
A directory named @team is a parallel-route slot and one named
(.)photo, (..)photo, (...)photo or (..)(..)photo is an intercepting
route. uf has neither feature, and both are refused by name — by uf build,
by uf dev and by uf lint.
They are refused rather than ignored, and the difference is the point. Until
#267 neither name meant
anything to the router, so both fell through to "an ordinary URL segment":
app/@team/_uf.page.js served /@team, app/feed/(.)photo/_uf.page.js served
/feed/(.)photo — a (group) is a segment that ends in ), which
(.)photo does not — and both went into the generated RoutePath, so
route("/@team", …) type checked. A person migrating from Next.js got a
project that looked like it worked.
There is no escape hatch for a URL segment that really begins with @;
capture it with a [param] instead. A (group) is untouched: it ends in )
and always did.
"The rest" is the whole rest, so a spread is the last routing directory on the
way to a page. app/docs/[...path]/edit/_uf.page.js would be a page no URL can
reach — [...path] has already taken every segment, and there is none left to
be edit — so uf build and uf dev refuse it by name rather than serving a
route that never answers. A (group) below a spread is fine: it is not a
segment.
Parameters arrive as props:
// @flow
import type { PageProps } from "@uniflowed/router";
export default component Post(params: {| +slug: string |}) {
return <article>{params.slug}</article>;
}
Layouts nest
Every layout on the path wraps the page, outermost first. This site has two: the
root layout renders the document and the masthead, and app/guide/_uf.layout.js
adds the sidebar and the prose column. /reference re-exports the second one
rather than copying it.
A layout does not re-render when navigating between pages that share it, so scroll position and state in a sidebar survive navigation.
Templates remount
_uf.template.js is the same wrapper with the opposite answer to the same
question. A layout persists; a template is thrown away and built again on every
navigation.
// @flow
// app/guide/_uf.template.js
import * as React from "react";
export default component Template(children: React.Node) {
return <div className="fade-in">{children}</div>;
}
It receives the same props a layout does — children and the route's params
— so LayoutProps types one.
It sits inside its own segment's layout and outside everything below it, so a remount means "this segment and what is under it" and never the frame around it:
app/guide/_uf.layout.js the sidebar — mounted once
app/guide/_uf.template.js rebuilt on every navigation under /guide
app/guide/[slug]/_uf.page.js
That is what an enter animation needs, and what a useEffect that should run
per page needs. Persistence is the right default and stays the default: a
segment with no _uf.template.js gets no wrapper at all.
The key is the pathname, so a navigation that changes only the query string does not remount. A filter written into the URL keeps the page it filters.
Not found nests too
_uf.not-found.js is a segment file like _uf.layout.js: any directory may
declare one, and a path that matches nothing gets the nearest one above it,
inside that directory's layouts.
app/_uf.not-found.js → answers /anything
app/guide/_uf.not-found.js → answers /guide/anything
This site has both. /guide/nope renders app/guide/_uf.not-found.js with the
manual's sidebar and prose column still around it, so a reader who followed a
stale link keeps the table of contents. /reference/nope has no boundary of its
own and falls back to the root one, which is the same nearest-ancestor rule
layouts already follow.
notFound() thrown from a loader lands on the same boundary — the nearest one
above the path being rendered, not the root one.
A project that declares none anywhere still gets a usable 404: uf's own page
renders inside the router root's layouts, so the masthead and the stylesheet are
there and the reader can leave. Writing app/_uf.not-found.js replaces the page;
it is not what makes the site appear around it.
When a page throws
_uf.error.js is the other boundary, resolved by the same nearest-ancestor
rule. It renders in place of the subtree below it, and everything above stays
mounted: the layouts, the navigation, whatever state they hold.
// @flow
// app/guide/_uf.error.js
import type { RouteError } from "@uniflowed/router";
export default component Error(error: RouteError, reset: () => void) {
return (
<section>
<h1>
{match (error) {
{kind: "unauthorized"} => "Please sign in",
{kind: "forbidden"} => "You cannot read this page",
{kind: "thrown"} => "This page did not load",
}}
</h1>
<button type="button" onClick={reset}>Try again</button>
</section>
);
}
One file, not three. unauthorized() and forbidden() are thrown the way
notFound() is, and they arrive here as cases of one RouteError rather than
as _uf.unauthorized.js and _uf.forbidden.js — so match says all three in
the place a reader looks for the answer, and Flow says which one you forgot.
The statuses are 401, 403 and 500.
reset() re-renders the subtree the boundary replaced. When the failure was a
loader's, it re-runs the resolution; when it was the browser rendering, it
mounts the page again.
A project that declares no _uf.error.js still does not lose the document: uf
renders its own page, inside the router root's layouts for the same reason the
404 is. What it does not do is show the exception — that text is written for
whoever deployed the application, so it goes to uf dev's terminal instead, and
uf build fails the route it belongs to.
A route that throws during uf build fails that route: the build reports the
URL, writes every route that did render, and then exits non-zero. It does not
write an error page into dist/.
While a page is still resolving
_uf.loading.js is a <Suspense> boundary around this segment's page and
everything under it. Its default export is a component that takes no props —
there is nothing to hand it, because what it stands in for has not happened yet.
// @flow
// app/guide/_uf.loading.js
export default component Loading() {
return <p aria-busy="true">Loading…</p>;
}
It is not resolved by the nearest-ancestor rule the other two boundaries use,
and the difference is what a fallback is. A not-found or an error boundary is
chosen: one of them renders. Loading boundaries nest — app/_uf.loading.js
and app/guide/_uf.loading.js are two boundaries on one route, one inside the
other — so a route carries every one declared above it, and the deepest is
closest to the page.
The boundary goes inside its own segment's layout and outside everything below it, which is what makes the shell arrive first: the server sends the layouts and the fallback while the page is still resolving, and the page replaces the fallback when it is ready. That is the whole reason the renderer streams.
app/_uf.layout.js the masthead — sent immediately
app/guide/_uf.layout.js the sidebar — sent immediately
app/guide/_uf.loading.js the fallback — sent immediately
app/guide/[slug]/_uf.page.js arrives when it resolves
uf build writes the resolved page rather than the fallback. A prerendered file
has nothing to wait for, so it contains the finished document — which is what
makes it readable by a crawler and by curl rather than only by a browser
running scripts.
A segment with no _uf.loading.js gets no boundary at all. uf does not insert an
invisible one: a page that suspends with nothing above it fails the way React
says it should, rather than rendering as a blank document.
A page's loader streams too. When a route declares a _uf.loading.js, the
server hands the page the loader's promise rather than its value and unwraps it
inside the innermost boundary — so the layouts and the fallback go out while the
loader is still running, which is what the fallback beside a slow loader was
always meant to be for.
Two rules come with that, and both are worth knowing rather than discovering:
- A route that generates metadata from its data waits.
generateMetadatareads the loader's answer, metadata goes in the head, and the head is written before the body. A page that wants to stream keeps its metadata static. - A deferred loader has no say in the response. The status line goes out
with the shell, so
notFound()andredirect()thrown from a loader that was deferred arrive at the error boundary rather than at the 404 page, and the document is a 200. Nothing can undo bytes that have already been sent. A route whose loader decides the response should not declare a_uf.loading.js.
uf build never defers: a file being written to disk has no first paint to
improve, so prerender resolves the loader and writes the finished page.
uf dev also collects the document rather than streaming it, because Vite's
HTML transform takes a whole one —
#374. uf start, uf preview
and the deploy adapters stream.
What none of this covers is a route handler. _uf.route.js answers a request
itself, and a handler that throws is still a bug for the host's error reporting
to see — "Route handlers", below, says why.
Loading data
A page or layout may export a loader, which runs before the component and
before the response is sent:
export async function loader({ params }: LoaderArgs) {
return { post: await readPost(params.slug) };
}
export default component Post(data: {| +post: Post |}) {
return <h1>{data.post.title}</h1>;
}
redirect(), permanentRedirect() and notFound() are thrown from a loader
rather than returned, so the type of the data does not have to admit "or a
redirect".
Metadata
A page or layout may export a metadata object, or a generateMetadata
function when it needs the loaded data:
import type { Metadata } from "@uniflowed/router";
export const metadata: Metadata = {
title: "Install · uf",
description: "One command, no plugin assembly.",
canonical: "/guide/install",
openGraph: { images: ["/brand/uf.png"] },
twitter: { card: "summary_large_image" },
};
Layouts merge outermost first, then MDX front matter, then the page's own
metadata, then generateMetadata. The merge is per key, so a page that
declares only canonical keeps the title its layout set. The router renders
the result as hoistable head elements before it renders anything else, which is
why it works for a crawler that runs no JavaScript — unlike useHead from
@uniflowed/web, which is a browser-only escape hatch for a component that is
already on screen.
The tags land in <head> whether or not the application renders its own
<html>. React hoists them into a document it rendered itself; with uf's shell
there is no such document, so uf lifts them into the head it wrote — which is
the same head, in the same place, for both shapes. It used to be neither: on the
shell every og: tag and the canonical link stayed in the body, where Google
ignores a canonical.
metadataBase is the site's origin, and it is what makes the rest correct.
Open Graph and Twitter both require absolute image URLs, and a route module
cannot know the host it will be served from — so openGraph.images: ["/og.png"] on its own ships exactly as written and is not an Open Graph image
at all. Declare metadataBase once on the root layout and every descendant
inherits it:
export const metadata: Metadata = {
metadataBase: "https://docs.uniflowed.dev",
};
canonical is emitted as both <link rel="canonical"> and og:url, because
og:url is defined as the page's canonical URL and writing it twice is two
copies of one fact. Put it on a page, never on a layout: a layout's
metadata reaches every page under it, and a canonical URL declared there tells
a search engine that the whole section is one page.
site.url in uf.config.js is the same origin read
by a different reader — uf build, in Rust, writing <loc> into
sitemap.xml. A site that has both should keep them the same.
The rest of what a page says about itself
robots is how a page asks not to be indexed. A page that declares nothing
gets no <meta name="robots"> at all, because "index, follow" is what a
document without one already means — the tag exists to say something else:
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
It is usually a layout's field rather than a page's. A preview tree, a
staging section or an account area is index: false for everything under it,
and saying so once is the only version of that which stays true when somebody
adds a page. maxSnippet and maxImagePreview are the other two directives,
and they change what a result looks like rather than whether there is one.
alternates.languages maps a language tag to that translation's URL. The set
has to be reciprocal — every page in it lists every other one and itself,
which is what makes a search engine read them as translations rather than as
duplicates — so it is normally the same map on every page of the set, declared
on the layout they share. "x-default" is a tag like any other, and names
what a reader with no matching language should be given:
export const metadata: Metadata = {
alternates: { languages: { en: "/guide", ja: "/ja/guide", "x-default": "/guide" } },
};
pagination is the two ends of a sequence, as <link rel="prev"> and
<link rel="next">. Keep each page's canonical pointing at itself:
a paginated list whose every page is canonical to page one has told a search
engine that pages two onwards are duplicates, and everything only linked from
them stops being reachable.
jsonLd is structured data, one <script type="application/ld+json"> per
entry. It is the one field that adds to what an outer layout declared
instead of replacing it, because an Organization on the root layout and an
Article on the page are two statements about one page rather than two
answers to one question:
export const metadata: Metadata = {
jsonLd: [
{
"@context": "https://schema.org",
"@type": "TechArticle",
headline: "Install uf",
},
],
};
The scripts render with the route rather than in <head>: React hoists a
<title>, a <meta> and a <link>, and not a script whose body it has to
carry. JSON-LD is read from anywhere in the document, so this costs nothing —
it is worth knowing when reading the markup.
Metadata a component computes
metadata and generateMetadata are declarations by a route module, and some
of what a page has to say is decided further in. A pager knows which page of
the list it is drawing; the route that renders it does not. useSeo is the
same vocabulary from inside a render:
import { useSeo } from "@uniflowed/router";
export component Pager(page: number, of: number) {
const seo = useSeo({
pagination: {
prev: page > 1 ? `/posts?page=${page - 1}` : undefined,
next: page < of ? `/posts?page=${page + 1}` : undefined,
},
});
return <nav className="pager">{seo}…</nav>;
}
It returns elements, and the caller renders them. That is not a stylistic
choice: a hook that wrote to the head would have to do it in an effect, an
effect does not run on a server, and the page would end up with tags that are
right in a browser and missing from every crawler. Rendering is what puts a tag
in a server-rendered head. useHead from @uniflowed/web is the browser-only
escape hatch and says so about itself.
Where the caller renders does not decide where the tag lands. A <title>, a
<meta> and a <link> are hoistable elements: React gathers every one it finds
while rendering the shell and emits them at the front of its output, whatever
depth they were written at, and uf lifts that run into the head — so a useSeo
three components below the page reaches <head> on both document shapes, the
same as a route's own metadata. The exception is the one above: a tag inside a
component that only renders after the shell — behind a <Suspense> a deferred
loader is still filling — is in a later chunk, and the head has already gone. A
page that wants to stream keeps its metadata static.
The argument is a Metadata, the same type a route exports, so there is one
vocabulary rather than two. What the hook adds over writing the tags by hand is
metadataBase — the root layout declared it, a component three levels down
cannot know it, and the relative URLs written here are resolved against it.
| Field | Emits |
|---|---|
title | <title> |
description | <meta name="description"> |
metadataBase | nothing on its own; resolves the URLs below |
canonical | <link rel="canonical"> and og:url |
robots.index, .follow, .maxSnippet, .maxImagePreview | one <meta name="robots">, only for what is declared |
alternates.languages | one <link rel="alternate" hreflang> each |
pagination.prev, .next | <link rel="prev">, <link rel="next"> |
jsonLd | one <script type="application/ld+json"> each; accumulates down the tree |
openGraph.title, .description | og:title, og:description, falling back to title and description |
openGraph.type, .siteName, .images, .imageAlt | og:type (website by default), og:site_name, one og:image each, og:image:alt |
twitter.card, .site, .creator, .title, .description, .images, .imageAlt | twitter:*, under name rather than property |
Navigating
Link navigates on the client and prefetches on hover by default:
import { Link } from "@uniflowed/router";
<Link to="/guide/install">Install</Link>
prefetch="render" fetches as soon as the link is on screen, and
prefetch="off" never does. useRouter() navigates imperatively;
useRoute() gives the current path, params and search — which is what the
sidebar on this page uses to mark the open entry.
A Link into a route that ships no JavaScript is a document navigation. uf build leaves a route's page out of the client bundle when no "use client"
module is reachable from it, so there is no page for the router to render and
the browser fetches the document instead — which is what the anchor a Link
renders would have done on its own. Nothing about writing the link changes,
and the page it lands on is the same page; the difference is a full load
rather than a transition. useRouter().push() and the back button do the same
thing for the same reason.
View transitions
Every client navigation goes through document.startViewTransition where the
browser has one. There is nothing to switch on: without a stylesheet the
browser's default cross-fade is what a reader gets, and a browser that has no
such method navigates exactly the way it did before.
A route can name its transition, so one stylesheet can animate an arrival in the manual differently from an arrival anywhere else. The name reaches CSS as an attribute on the document element, set for as long as the transition runs:
// app/guide/_uf.layout.js
export const viewTransition = "manual";
html[data-uf-view-transition="manual"]::view-transition-old(root) {
animation: 120ms ease-out both fade-out;
}
A page's own name wins over its layout's, which is the nearest-declaration rule
metadata already follows. A layout is usually the right place: a section that
animates one way throughout should say so once.
prefers-reduced-motion is honoured by the router, not by the application.
A reader who asked their system for less motion gets the cut they asked for,
and there is no way for an application to override it — an application able to
would eventually.
transition={false} on a Link, or { transition: false } on
useRouter().push(), makes one navigation a cut. It is for a navigation that
is a change of state rather than a change of place: a tab within a page, a
filter written into the query string. router.refresh() never transitions,
because it resolves the same URL again and a transition there would cross-fade
a page into itself.
None of this reaches the server. A transition is a client-only concern, and a prerendered document says nothing about one.
Middleware
app/dashboard/_uf.middleware.js runs before anything under /dashboard
answers — the pages, the route handlers, and the paths under it that match
nothing at all:
// @flow
import { cookies } from "@uniflowed/server";
export default function middleware(request: Request): Response | void {
if (cookies().get("session") == null) {
return Response.redirect(new URL("/sign-in", request.url), 302);
}
}
Returning a Response is the answer. Nothing after it runs: no page is
resolved, no handler is called, and no middleware deeper in the tree runs
either. Returning nothing continues. Both halves matter — a middleware that
could only observe could not reject, and one that had to answer could not be a
logger.
There is no matcher to write. The directory the file sits in is the path it
guards, and middleware composes down the tree exactly the way layouts do: a
root app/_uf.middleware.js runs for every request, then the one in
app/dashboard/, then the one in app/dashboard/admin/. Root first, and the
first one to return a Response wins.
A middleware runs inside the request, so cookies(), headers() and after()
work without anything being threaded through. Its own path parameters arrive as
context.params — app/[org]/_uf.middleware.js guarding /acme/settings gets
{ org: "acme" }, which is what a tenant check needs.
A middleware module never reaches the browser. It is loaded from the server entry and from nothing the client imports, which is the point: it is where the check you do not want a reader to be able to read belongs.
What it deliberately cannot do yet is rewrite the request. That needs a
spelling — a returned Request, or a next(request) argument — and one picked
badly is harder to undo than one not yet picked. Answering and continuing are
what exist, and they are enough for authentication, redirects and logging.
A prerendered page is not a guarded page
A middleware runs on a server, when a request arrives. uf build prerenders
every route without parameters to an HTML file, and a static host serves that
file without asking anyone: there is no request for a middleware to run for,
so the guard is not there. Keep a guarded path off the prerender — give it a
parameter, or serve the build's server bundle rather than only dist/ — and
treat anything you can open in dist/ as public. uf preview and uf start
serve that bundle and run middleware for every request they answer, exactly as
uf dev does; a static host handed dist/ alone has nothing to run.
Route handlers
A path can answer a request instead of rendering. app/api/users/[id]/_uf.route.js
serves /api/users/42:
// @flow
import type { HandlerContext } from "@uniflowed/router/handler";
export async function GET(request: Request, context: HandlerContext): Promise<Response> {
const user = await find(context.params.id);
return user == null
? new Response("not found", { status: 404 })
: Response.json(user);
}
export async function POST(request: Request, context: HandlerContext): Promise<Response> {
return Response.json(await request.json(), { status: 201 });
}
A handler takes a Request and returns a Response — the platform's own
types, not a framework's wrapper. That is what runs unchanged on Node.js, Bun,
Deno and a Cloudflare Worker, which is the whole point of treating the host as
a capability rather than a target.
A handler needs something serving. uf dev, uf preview, uf start and a
binary from uf build --compile all dispatch to it; a dist/ handed to a
plain static file server does not, because there is nothing there to run. That
is the one thing to know before designing an application around handlers, and
it is why uf preview exists: it is where you find out that what answered in
development also answers from the build.
One export per method: GET, HEAD, QUERY, POST, PUT, PATCH,
DELETE, OPTIONS. Anything else a module exports is a helper, not a method —
a dispatcher that treated every export as one would answer requests with your
utility functions, and one that treated every upper-case export as one would
answer a PURGE with your constant.
Three things the dispatcher does so every handler does not have to:
HEADfalls back toGET, with the body dropped. A client asking for headers expects that, and nobody remembers to write it.405carriesAllow. When the path matches and the method does not, the response names the methods that do — which is what lets a client tell "you may not do that here" from "there is nothing here".- The most specific path wins.
/api/users/newbeats/api/users/[id], which beats/api/[...rest], whatever order the files are in.
What it deliberately does not do is catch your errors. A handler that throws is a bug, and turning it into a 500 here would hide it from the host's own error reporting — and leave you unable to tell a 500 you meant from one you caused.
A handler is not prerendered and its module never reaches the browser.
Draft mode: previewing what is not published
A CMS previews unpublished content by linking to a route handler that turns
draft mode on and redirects. Every request after that carries a signed cookie,
draftMode().isEnabled is true for the whole of it — a guard, a handler and
the page all see the same answer — and neither the route cache nor the
prerendered document in dist/ is allowed to answer it.
// app/api/preview/_uf.route.js
// @flow
import { draftMode } from "@uniflowed/server";
export function GET(request: Request): Response {
const url = new URL(request.url);
if (url.searchParams.get("token") !== process.env.CMS_PREVIEW_TOKEN) {
return new Response("no", { status: 401 });
}
draftMode().enable();
return Response.redirect(new URL(url.searchParams.get("to") ?? "/", request.url), 307);
}
A page then asks:
import { draftMode } from "@uniflowed/server";
export default async function Post({ params }: PageProps): Promise<Node> {
const post = await load(params.id, { draft: draftMode().isEnabled });
return <article>{post.body}</article>;
}
enable() and disable() belong in a route handler or a server action, and
nowhere else. They write a cookie, and a cookie is part of a response — so
they are allowed exactly where a response is being produced, and calling one
from a render or from a middleware is a DraftModeError that says so. A
middleware that wants draft mode on redirects to the handler that turns it on.
The token check is yours. uf checks that the cookie is one it issued and has not expired; it does not and cannot check who asked for it, because only your application knows what an editor is. A preview handler with no check is an open door to everything the site has not published.
The cookie is __Host-prefixed, Secure, HttpOnly, SameSite=Lax and good
for an hour, and its value is an HMAC over its own expiry — so it cannot be
forged and a holder cannot extend it. The key is UF_DRAFT_SECRET:
| Variable | Meaning |
|---|---|
UF_DRAFT_SECRET | The key that signs the draft cookie. At least 32 bytes, and shared by every instance of the deployment. Without it uf generates one per process, which is right for uf dev and wrong behind a load balancer: the cookie stops working when the process restarts and a second instance never accepts it. uf says so in a warn line the first time it issues one. |
QUERY: the GET whose parameters did not fit in a URL
QUERY is a safe, idempotent method that carries a body. It is what a search
with a page of filters has been faking with a POST for twenty years, and the
difference is not cosmetic: a POST is not safe, so nothing may retry it,
prefetch it or cache it, and every layer between the browser and the handler
has to treat a search as a mutation.
// app/api/search/_uf.route.js
// @flow
export async function QUERY(request: Request): Promise<Response> {
const filters = await request.json();
return Response.json(await search(filters));
}
The client helper sends one:
import { createFetch } from "@uniflowed/fetch";
const api = createFetch({ baseURL: "/api" });
const hits = await api.request("/search", { method: "QUERY", body: { filters } });
What refuses it is the part worth knowing. Nothing in uf does: the
dispatcher matches QUERY like any other verb, and @uniflowed/fetch retries
it like the other safe methods. What refuses it is the path in between.
- A proxy, CDN or firewall with a list of verbs answers
405or501itself, so the request never arrives and the failure looks exactly like a route that does not exist.@uniflowed/fetchsays so in the error rather than passing the status through, because that is the difference between an afternoon and a status code. - A cache in front of the application must not store a
QUERY: the method is cacheable in principle, and the key includes the body, which almost nothing implements. uf's own route cache isGET-only and stays that way. <form>,XMLHttpRequestandEventSourcecannot send it.fetchcan, in every browser and every runtime uf targets.
There is no method-override header, and there will not be.
X-HTTP-Method-Override: QUERY on a POST is the usual workaround and it is
the shape of CVE-2025-29927 — an inbound header steering dispatch — which
docs/security.md forbids. A route that has to work through infrastructure you
do not control exports POST as well, in its own file, where a reader can see
it.
Streams, sockets, and work that outlives the request
A handler returns a Response, and a Response body may be a stream — so
server-sent events need nothing new from the router:
// app/api/progress/_uf.route.js
// @flow
import { eventStream } from "@uniflowed/server/events";
export function GET(): Response {
return eventStream((sink) => {
const timer = setInterval(() => sink.send({ event: "tick", data: stateOfPlay() }), 1000);
return () => clearInterval(timer);
});
}
eventStream writes the format, sets the four headers that stop a proxy
buffering the whole stream until it ends, sends a heartbeat so an idle
connection is not closed by something in the middle, and disconnects a reader
that falls far enough behind rather than holding it in memory.
useEventSource from @uniflowed/hooks/events is the other end: it reports
the connection as state, and reopens the one case the browser's own
reconnection gives up on — a response that was not an event stream, which is
what a deployment restarting looks like.
A WebSocket is a GET with Upgrade: websocket on it, so it is a handler too:
// app/api/room/[id]/_uf.route.js
// @flow
import { upgradeWebSocket } from "@uniflowed/server/socket";
export function GET(request: Request, context: HandlerContext): Response {
const { response, socket } = upgradeWebSocket(request);
socket.addEventListener("message", (event) => socket.send(String(event.data)));
return response;
}
uf defines the upgrade and your deployment supplies it. Deno, Bun and
Cloudflare each spell taking a socket differently and Node has no server-side
WebSocket at all, so what uf owns is the shape they reduce to — a function
from a Request to { response, socket } — passed as websocket where the
host is built. A built-in that wrapped one library would make uf the thing that
decides which WebSocket library your project has, which is the failure
docs/red-lines.md exists to prevent.
And work that should outlive the response is a job:
// @flow
import { defineJob, enqueue } from "@uniflowed/server/queue";
export const sendWelcome = defineJob({
name: "send-welcome",
retry: { attempts: 5 },
run: async (payload) => mail(payload.to),
});
export async function POST(request: Request): Promise<Response> {
const user = await create(await request.json());
await enqueue(sendWelcome, { to: user.email });
return Response.json(user, { status: 201 });
}
uf provides the job, the payload boundary, the retry policy and its backoff,
and one backend that runs in this process. Your deployment provides the two
things no configuration flag can: storage that survives a restart, and a
consumer — a process that is still there when no request is being answered.
memoryQueue is an array, says so as durable: false, and is right for one
long-lived process whose work can be lost. Delivery is at least once, so a job
that charges a card needs its own idempotency key.
A queue is not after(). after() runs a callback in this process once the
response has gone, with no retry and no record it ever existed, which is right
for a metric and wrong for anything a user would notice missing.
What each target can do
The two questions that decide all three: does a response body reach the client as it is produced, and is the process still there once it has.
| Target | Streams | Outlives the response | Event streams | WebSockets | In-process queue |
|---|---|---|---|---|---|
uf dev, uf preview, uf start | yes | yes | yes | with an upgrader | yes |
node, container, --compile | yes | yes | yes | with an upgrader | yes |
edge | yes | no | yes | with an upgrader | refused |
serverless | no | no | refused | refused | refused |
Refused means refused where the host is wired, before a single connection is accepted and dropped — a serverless deployment handed a WebSocket upgrader fails to start rather than accepting handshakes all day and delivering nothing. A durable queue is accepted everywhere, because pushing to one is not draining it.
When a route is rendered
uf build writes a document for a route when it can, and leaves the rest to a
server. Which one happens is a property of the page, and there are two ways to
say so.
A page with parameters is prerendered only if it enumerates them:
// app/posts/[slug]/_uf.page.js
export function generateStaticParams(): $ReadOnlyArray<{ readonly slug: string }> {
return [{ slug: "hello-world" }, { slug: "second-post" }];
}
Without it, nothing knows what the URLs are, so /posts/:slug is rendered per
request instead.
A page with no parameters is prerendered by default, and says otherwise for itself:
// app/now/_uf.page.js
export const dynamic = "force-dynamic";
generateStaticParams cannot express that — there are no parameters to
generate — which is why this exists. Use it for a page whose content depends on
the request: one that reads cookies() or headers(), or that has to be fresh
on every visit. "auto" is the default and is the same as not writing the line.
Next.js's other two values, "force-static" and "error", are constraints on
what a page may do, and uf does not check them yet. A page that names one fails
the build rather than being quietly rendered as "auto".
Route handlers and middleware are always per request; there is no version of either that a file can be written for.
uf build lists everything it wrote no document for, under answered by a server. If your project deploys to a static host, say so with
app.rendering.modes or
build.staticBuild and that list becomes
a build error naming the route — which is the difference between finding out
now and finding out from a 404.
Types for routes
uf build and uf dev write a router.js next to your config, declaring the
paths that exist and the parameters each takes. Importing route from it gives
a link builder Flow checks:
import { route } from "../router.js";
route("/posts/[slug]", { slug: post.slug });
A path that does not exist, or a missing parameter, is a type error rather than a 404 in production.