Readiness: Implemented
Build an app
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.
What you will be able to do: lay out routes as files; nest layouts, templates and slots; handle a missing page, an error and a slow one; load data and metadata before a page renders; and navigate between pages.
What you need first: a project — Your first project — and
Flow, the modern parts for the component syntax every example
uses.
The file names
Nine names are reserved. Everything else in app/ is an ordinary module.
| File | What it is |
|---|---|
$page.js | The page at this path. Its default export is a component. |
$layout.js | Wraps this path and everything under it. Receives the page as children. |
$template.js | The same wrapper, rebuilt on every navigation instead of persisting. |
$default.js | Renders in a slot when the URL matches none of that slot's routes. |
$not-found.js | Rendered when nothing under this path matches. |
$error.js | Rendered when something under this path throws. |
$loading.js | Rendered while something under this path is still resolving. |
$middleware.js | Runs before anything under this path answers — auth, redirects, logging. |
$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/$page.js → /
app/guide/$page.mdx → /guide
app/guide/install/$page.mdx → /guide/install
A segment in brackets is a parameter, and a segment in brackets with a spread captures the rest:
app/docs/[...path]/$page.js → /docs/a/b/c
app/posts/[slug]/$page.js → /posts/anything
[...path] needs at least one segment to capture, so /docs is not one of
its URLs — a page at app/docs/$page.js answers that, if there is one. In
double brackets the spread is optional, and captures none as well as many:
app/guide/[[...path]]/$page.js → /guide, /guide/a, /guide/a/b/c
path is a list either way, empty for /guide. Because an optional catch-all
already answers the path above it, app/guide/$page.js beside it would be a
second page at /guide, and uf build and uf dev refuse the pair by name —
including when the other page is in a (group), which does not change its URL.
Where both could match, a static segment beats a parameter, a parameter beats a
catch-all, and a catch-all beats an optional one.
A directory named @team is a parallel-route slot: it is
not a URL segment at all. There is no escape hatch for a URL segment that
really begins with @; capture it with a [param] instead. Inside a slot, a
directory named (.)photo is an intercepting route,
and it is not a URL segment either.
Until #267 neither spelling
meant anything to the router, so both fell through to "an ordinary URL
segment": app/@team/$page.js served /@team, app/feed/(.)photo/$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. Both are routes now, and what is spelled like one
without being one is refused by name rather than served as a URL.
A (group) is untouched: it ends in ) and always did.
"The rest" is the whole rest, so a spread — optional or not — is the last
routing directory on the way to a page. app/docs/[...path]/edit/$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: {| readonly slug: string |}) {
return <article>{params.slug}</article>;
}
The query string
A page is also given searchParams, the query string. By default it is a map
of strings, one per key. A repeated key keeps its last value, so ?tag=a&tag=b
gives { tag: "b" }. parseSearchAll(search) from @uniflowed/router keeps
every value, for a page that needs the repeats and declares nothing else.
A page can declare its query with a
@uniflowed/validator schema instead. The page is then
given the schema's output, typed:
// @flow
// app/search/$page.js
import type { PageProps, SearchParamsOf } from "@uniflowed/router";
import { array, number, object, optional, pipe, integer, min, string, withDefault } from "@uniflowed/validator";
export const searchParams = object({
q: optional(string()),
page: withDefault(pipe(number(), integer(), min(1)), 1),
tag: array(string()),
});
export default component Search(
...props: PageProps<{}, void, SearchParamsOf<typeof searchParams>>
) {
const { q, page, tag } = props.searchParams; // ?string, number, $ReadOnlyArray<string>
return <Results query={q} page={page} tags={tag} />;
}
Before the schema runs, the router shapes the query and coerces its strings:
- Lists. A field the schema describes as an array, a set or a tuple gets
every value of its key, in order:
?tag=a&tag=bgives["a", "b"]. If the key is missing, a plain array field gets[]. Anoptional(array(…))field stays absent. Any other field gets the last value, as the plain map does. - Coercion. Every value in a query is a string. A field the schema reads as
a number, a boolean, a bigint, a date or a literal of one gets the value the
string spells, when it spells one:
"2"becomes2and"true"becomestrue. A string that spells nothing is passed on unchanged, and the schema reports it with its own message. An empty value is not zero. A field the schema reads as a string is left alone, sopipe(string(), transform(parseCursor))still gets the string. - The schema decides the rest: optionality, defaults, refinements, and
whether an unknown key is dropped (
object), refused (strictObject) or kept (looseObject).
The schema runs before the page's loader. If the query does not fit, the loader
does not run: the route renders the nearest $error.js
with a 400 and a RouteError of { kind: "badRequest", issues }. Each issue
has path, code and message. A page with a schema is always
rendered per request, since a document written
once would answer every query the same way.
Only the page receives the parsed value. useRoute(), a loader and
generateMetadata still get the string map, and so does a page inside a
parallel-route slot.
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/$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
$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/$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/$layout.js the sidebar — mounted once
app/guide/$template.js rebuilt on every navigation under /guide
app/guide/[slug]/$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 $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.
Parallel routes
A directory whose name starts with @ is a slot: a second thing a layout
renders, beside its children.
app/dashboard/$layout.js the frame, which renders both
app/dashboard/$page.js → /dashboard as `children`
app/dashboard/@team/$default.js the team slot when the URL says nothing
app/dashboard/@team/$error.js the team slot when it throws in the page
app/dashboard/@team/members/$page.js → /dashboard/members as `team`
app/dashboard/members/$page.js → /dashboard/members as `children`
The layout receives the slot under the directory's name without its @:
// @flow
// app/dashboard/$layout.js
import * as React from "react";
export default component Dashboard(children: React.Node, team: React.Node) {
return (
<div className="dashboard">
<main>{children}</main>
<aside>{team}</aside>
</div>
);
}
A slot adds no URL. @team contributes nothing to the path, the way a
(group) does not, and the slot's pages are matched against the same URL the
page is — so /dashboard/members renders app/dashboard/members/$page.js
as children and app/dashboard/@team/members/$page.js as team, at once,
each inside its own layouts. A URL no ordinary page matches is a 404 whatever
the slots hold, which is why uf has no default for children the way Next.js
does.
$default.js is what a slot renders when the URL matches none of its
routes. A slot with none renders nothing, and the layout is handed null — so
{team ?? <Empty />} is a thing you can write and rely on.
Four rules, each refused by name rather than by silence:
- the segment that declares a slot must have a layout of its own, because the slot is a prop that layout receives;
- a slot may not be named
childrenorparams, which are the props every layout already receives —@childrenis the tempting one, becausechildrenis what Next.js calls the page, and here the page always ischildren; $default.jsbelongs directly inside a slot directory, and nowhere else;- a slot holds pages, layouts, templates, loading fallbacks, nested slots,
intercepting routes and one default —
$not-found.js,$route.jsor$middleware.jsinside one is refused, because slots are not request boundaries. Put those files in the route tree outside the slot.
$loading.js in a slot is slot-local. It wraps the slot's matched page and the
layouts under that slot, not the segment's children, so a slow side panel can
stream its placeholder without replacing the main page:
app/dashboard/$layout.js
app/dashboard/@team/$loading.js
app/dashboard/@team/members/$page.js
app/dashboard/members/$page.js
/dashboard/members renders the members page immediately and puts the team
slot behind its own fallback until @team/members/$page.js resolves. If the
fallback should cover the whole dashboard instead, put $loading.js beside
app/dashboard/$layout.js; that boundary wraps both children and every slot
the layout receives.
$error.js in a slot is slot-local in the browser. It catches an exception
while that slot subtree renders and replaces only the slot's page, leaving the
segment's children and the layout that received the slot mounted. Put the
file beside the slot page it should guard, or higher inside the slot to cover
everything below that point. A server render failure still resolves through the
route's ordinary $error.js, because React's server renderer does not run class
error boundaries during the shell render.
A slot page's loader is not run — a slot's data has nowhere to be embedded for
hydration — and a slot page that exports one says so rather than being handed
undefined. Fetch inside the component, or put the data on the page the URL
names.
Intercepting routes
A directory inside a slot whose name starts with a marker — (.)photo,
(..)photo, (...)photo — is an intercepting route: what the slot shows
when a client navigation reaches the URL it names, instead of the page that
URL names. The modal that opens over a feed, with the photo's own address in
the bar, is the shape it exists for:
app/feed/$layout.js the frame, which renders `modal`
app/feed/$page.js → /feed
app/feed/@modal/(.)photo/[id]/$page.js → /feed/photo/:id in `modal`, over the feed
app/feed/photo/[id]/$page.js → /feed/photo/:id the photo page
// @flow
// app/feed/$layout.js
import * as React from "react";
export default component Feed(children: React.Node, modal: React.Node) {
return (
<>
<main>{children}</main>
{modal}
</>
);
}
Two ways to arrive at one URL, and two answers.
- A client navigation that starts on a page the slot is on — any page
under
app/feed/$layout.js— renders the intercepting page inmodal. The address bar says/feed/photo/1. Everything else stays exactly where it was:childrengoes on rendering the page the reader came from, with its data, its scroll position and the state its components were holding, and every other slot keeps what it was showing. - Everything else — a reload, a link somebody was sent, a new tab, a
crawler,
uf build's prerender — rendersapp/feed/photo/[id]/$page.js. A request carries where it is going and not what was on screen when it was made, so the server never renders an intercepting page, and the document for/feed/photo/1is always the photo page.
A navigation from a page the slot is not on — /about, which renders no
feed layout — is an ordinary navigation to the photo page: there is no
modal on screen for it to render into.
What the marker counts
The marker says which URL the directory stands in for, counted in URL
segments from where the directory sits — not in directories. A @slot and a
(group) contribute no URL segment, so they are not levels to climb past.
| Directory | Intercepts |
|---|---|
app/feed/@modal/(.)photo/ | /feed/photo — the level the directory is at |
app/feed/@modal/(..)photo/ | /photo — one URL segment up |
app/shop/[category]/@modal/(..)(..)photo/ | /photo — two up |
app/shop/[category]/@modal/(...)photo/ | /photo — from the router root, however deep |
app/feed/@modal/(.)[id]/ | /feed/:id — a parameter is a segment like any other |
Everything below the marked directory is ordinary: (.)photo/[id]/ stands in
for /feed/photo/:id, and a $layout.js, $loading.js, $template.js or
$error.js inside the slot wraps an intercepting page exactly as it wraps any
other page in the slot.
Back, forward, reload
The history entry an interception writes remembers where the navigation came from, so the back and forward buttons put the same screen back: back from the modal is the feed with nothing in the slot, and forward is the modal over the same feed again. A second interception from inside the first — the next photo, opened from the modal — is intercepted from the feed too, so back from the second photo is the first.
A reload of an intercepted entry renders the photo page, because a reload is a request. The entry forgets it was intercepted, so coming back to it later renders the photo page again rather than a modal over a page the reader never saw one on.
A few things follow from "the page underneath stays":
useRoute()describes the pagechildrenrenders — during an interception, the page the navigation came from. The intercepting page gets its ownparams, and the intercepted URL'ssearchParams.- An intercepted navigation does not scroll the window; the modal opens over
the post that was clicked. Pass
scroll: truetorouter.pushto scroll anyway. router.refresh()refreshes both halves: the page underneath and the interception over it.- A
Linkprefetches what the next render will need, which for an interceptable URL is the intercepting page. - The document title stays the page underneath's. A slot page contributes no metadata, and an intercepting page is a slot page.
- An interception arrives under the intercepting page's own
viewTransition, or that of a layout inside the slot, so a stylesheet can animate a modal opening differently from a page arriving. Back arrives under the page underneath's, as any navigation arrives under its destination's.
To close the modal, navigate back — router.back() — or link to where the
reader came from.
What is refused
Each of these is refused by name rather than served. The first four are about
how a directory is spelled and where it is, so uf lint
(router/unsupported-segment) reports them with the sentence uf build and
uf dev give. The last two are about the route table as a whole, so they are
refused wherever the table is read — uf build, uf dev, uf routes,
uf inspect — and not by uf lint, which reads one file at a time.
| Written | Why it is refused |
|---|---|
app/feed/(.)photo/ — outside a slot | there is no named place for it to render into |
(....)photo, (.)(.)photo, (...)(..)photo | a marker uf does not read; (.) and (...) stand alone, and (..) repeats |
(.)(gallery), (.)@photo | no URL segment after the marker for it to stand in for |
app/@modal/(..)photo/ | it climbs above the router root, and there is no URL up there |
app/feed/@modal/(.)photo/[id]/ with no app/feed/photo/[id]/$page.js | nothing serves the URL it intercepts, so a reload of the address the modal put in the bar would be a 404 |
app/docs/[...path]/@panel/(.)edit/ | the URL it stands in for has a catch-all before edit, and no URL can reach a segment after a catch-all |
"Serves" means every URL the interception matches: app/docs/[...path]/$page.js
serves what app/docs/@panel/(.)[slug]/ intercepts, and
app/photo/[id]/$page.js does not serve what app/photo/@panel/(.)[...rest]/
intercepts, because that also matches /photo/a/b.
An intercepting page is a slot page, so the slot rules apply to it too: it
takes no loader — fetch inside the component, or put the data on the page
the URL names — and uf routes add does not write one, because an
intercepting route is not a URL.
Where it needs the client router
Interception is something a client navigation does, so it needs a page that the client router is running on:
- A route under a slot that intercepts always ships its page to the browser,
even when nothing in it is a
"use client"module. A page served as a document with nothing hydrated on it starts no client navigation, and the interception would never render. - Under
app.rendering.navigation: "document"every navigation is a document request, so an intercepting route never renders and the page its URL names always does. Nothing breaks; the modal is simply not there. @uniflowed/router/nativedoes not intercept. A native navigator has no address bar for the screen to disagree with, and already presents a screen modally over the one below it — so/feed/photo/1resolves to the ordinary route, and presenting it as a modal is the navigator's decision.
Coming from Next.js
The markers mean what they mean in Next.js, counted in route segments the same way. Two things are stricter here, on purpose:
- Only inside a slot. Next.js also lets an interception replace
childrenoutright. Here an interception is always something shown in a named place, over a page that is still there. - The page it stands in for has to exist. An interception with no page at its URL works for the reader who clicked and is a 404 for everybody they send the link to, so it is refused where the file is rather than found out from a reload.
Not found nests too
$not-found.js is a segment file like $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/$not-found.js → answers /anything
app/guide/$not-found.js → answers /guide/anything
This site has both. /guide/nope renders app/guide/$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/$not-found.js replaces the page;
it is not what makes the site appear around it.
When a page throws
$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.
"use client";
// @flow
// app/guide/$error.js
import type { RouteError } from "@uniflowed/router";
export default component Error(error: RouteError, reset: () => void) {
return (
<section>
<h1>
{match (error) {
{kind: "badRequest", ...} => "That search does not make sense",
{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>
);
}
"use client" is not optional. The boundary catches a throw while the browser
renders, so its component runs in the browser; uf build and uf dev refuse an
$error.js that does not open with the directive, naming the file, rather than
letting the first page that throws be answered with a bare 500.
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 $unauthorized.js and $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, and 400 for a query that fails the page's
searchParams schema.
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 $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
$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.
The broader Async React shape — local Suspense, transitions and optimistic UI —
is in Async React.
// @flow
// app/guide/$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/$loading.js
and app/guide/$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/$layout.js the masthead — sent immediately
app/guide/$layout.js the sidebar — sent immediately
app/guide/$loading.js the fallback — sent immediately
app/guide/[slug]/$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 $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 $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$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 streams, and its terminal names each boundary that arrived after the
shell — What actually streamed. So do
uf start, uf preview and the deploy adapters, except serverless, which
buffers the response.
What none of this covers is a route handler. $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 says why.
Loading data
With RSC enabled (the default), fetch data inside the Server Component that
uses it. A loader is optional. The router passes params and searchParams
directly to the page, so neither needs forwarding through a loader:
export default async function Post({ params }) {
const post = await readPost(params.slug);
return <h1>{post.title}</h1>;
}
React suspends while the component awaits data; $loading.js or a nearby
<Suspense> boundary supplies its loading UI. With Relay, colocate each
component's fragment and start the server preload during the RSC render. See
the Relay guide and the separate GraphQL SNS example.
Optional route loaders
A loader is useful for route-level work before rendering, or for an application that disables RSC and uses the client router's data flow. It is not a prerequisite for fetching data in a Server Component.
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: {| readonly 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".
Deferring part of the answer
A loader may leave a promise in what it returns. The page is rendered with the promise still pending, and the value arrives in the same response, later:
export async function loader({ params }: LoaderArgs) {
return {
post: await readPost(params.slug), // waited for
comments: readComments(params.slug), // not waited for
};
}
export default component Post(
data: {| readonly post: Post, readonly comments: Promise<Array<Comment>> |},
) {
return (
<article>
<h1>{data.post.title}</h1>
<Suspense fallback={<p>Loading comments…</p>}>
<Comments comments={data.comments} />
</Suspense>
</article>
);
}
component Comments(comments: Promise<Array<Comment>>) {
return <ul>{use(comments).map((one) => <li key={one.id}>{one.body}</li>)}</ul>;
}
The document the server writes carries the post, the heading and the fallback
immediately; when readComments settles, the comments' markup and the data
behind them are appended to the same response, and the browser resolves that
boundary without a second request. Each deferred value is its own boundary, so
three of them arrive in the order they resolved rather than in the order they
were written.
This is uf's payload format, and <Suspense> is the whole of the API: there is
no defer() to call and no <Await> to render, because a promise in the data
already says everything either of them would have.
Three things are worth knowing about it:
- What a promise resolves to is data. The same values a loader could always return — JSON's — and not a promise of its own. A deferred value that is itself deferred is refused, because the payload numbers its rows once.
- A rejected promise reaches the boundary that was reading it. The browser is told the value failed and not why; the reason stays on the server, where it is logged, and reaches the error boundary above whatever used the value.
- The response is already a 200. The same rule as a deferred loader: bytes
that have gone cannot be unsent, so a
notFound()thrown from inside one of these arrives at the error boundary rather than at the 404 page.
uf build waits for every one of them: a file on disk has nothing to stream to,
so a prerendered page contains the finished comments.
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.
A Link to a URL that a slot on screen intercepts is the other exception: it
renders the intercepting page into that slot and leaves the page it was clicked
on where it was — see Intercepting routes.
Keeping what a navigation fetched
By default a navigation asks the server every time. A prefetched link hands its
answer to one click, and a second visit to the same page, or the back button,
waits on the network again. app.rendering.staleTime keeps what a navigation
fetched, for that many seconds:
// uf.config.js
export default defineConfig({
app: {
rendering: {
staleTime: 30,
},
},
});
While an entry is fresh, following a link to its page, going back or forward to it, and hovering a link to it all read the kept answer and make no request. Once it is stale, the next navigation asks again and keeps the new answer.
| What is kept | |
|---|---|
| Routes rendered as Server Components (the default) | The route's payload, with the $loading.js fallbacks in its tree, so a page that was still streaming shows the same loading shell |
app.rsc: false | The resolved route: the loader's data, and the page, layouts and loading boundaries it loaded. A prefetch runs the loader too |
Entries are kept by the path and query the route table is asked about, without
app.router.basePath, and at most 32 routes are kept at a time. A route that
answered with its error or not-found page is not kept. router.refresh() and
every server action clear everything kept, because a write can change any page
and which ones is the server's to know. A Link with prefetch="off" fills
nothing on hover, and a click on it still reads what an earlier navigation kept.
0, the default, keeps nothing, which is uf's cache policy everywhere: nothing
is shown that the server did not just answer until a project says how old an
answer it can show.
Link progress and cache inspection
useLinkStatus() reads the containing Link's { pending } state. Render a
small client component inside the link to show a spinner or change its label.
Only the link that started the navigation becomes pending; the anchor also gets
aria-busy="true" and data-pending until the navigation settles.
During uf dev, call window.__UF_NAVIGATION_CACHE__() in the browser console.
It returns the configured stale time and the Flight and module caches, with each
entry's key, cachedAt, staleAt and fresh status. It never exposes payloads
or loader values, and inspecting an expired entry does not remove it. The console
function is absent from production builds.
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/$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.
Answering requests
Middleware, route handlers, draft mode, QUERY, and streams and sockets have a
page of their own: Answering requests. A file that
answers a request directly is a different job from the route table and the
layouts a page renders inside, and each section moved there whole, under the
same heading.
Redirects, rewrites and response headers
Three lists in uf.config.js answer a request before the route table does.
They are next.config.js's redirects(), rewrites() and headers(),
written as data rather than as functions, because uf.config.js is read
without being run:
// uf.config.js
export default defineConfig({
app: {
router: {
redirects: [
{ source: "/blog/:slug", destination: "/posts/:slug", permanent: true },
{ source: "/docs/:path*", destination: "https://docs.example.com/:path*", permanent: false },
],
rewrites: [{ source: "/me", destination: "/users/me" }],
headers: [
{ source: "/:path*", headers: { "X-Frame-Options": "DENY" } },
{ source: "/assets/:file*", headers: { "Cache-Control": "public, max-age=31536000, immutable" } },
],
},
},
});
| List | What it does | Where it applies |
|---|---|---|
redirects | Answers with a redirect to destination: 308 for permanent: true, 307 for false. The first rule whose source matches wins. | Before anything else, a file the build wrote included |
rewrites | Serves the route at destination, and the address bar keeps the path that was asked for. The first rule whose source matches wins. | After the build's own files, before middleware |
headers | Sets response headers. Every matching rule applies, and a later one setting the same name wins, over the response's own header too. | On every response, files included |
Both redirect statuses keep the method and the body, which a 301 does not: a
redirected form POST stays a POST.
Sources and destinations
A source is written in the route table's own grammar: literal segments,
:name for one segment, and a trailing :name* for the rest of the path. A
destination may use every parameter its source declares, in the same spelling,
and the request's query string is passed through to it, beside any query the
destination names.
What next.config.js accepts and uf does not read is refused when the config is
loaded, with a sentence that names the entry: a regular expression
(/blog/(.*)), the :slug+ and :slug? modifiers, a parameter inside a
segment (/@:user), a query in a source, and a has or missing condition. A
condition on a cookie, a header or the query is a middleware, which can
rewrite as well as redirect. A misspelled
field is an error in the same place, and so is a redirect to itself.
A rewrite never leaves the application. Its destination is a path of this project, and a URL on another origin is refused: sending a visitor there is a redirect, and proxying to another server is a route handler that fetches it.
The order a request meets them in
redirects, before the build's own files — so a redirect away from a page the build still prerendered still happens.- The files the build wrote.
rewrites— after the files, so a catch-all rewrite never answers for a hashed chunk.- Middleware, for the path the rewrite reached — so a rewrite into a guarded directory still meets its guard.
- Server actions, route handlers and pages.
headers go on whatever answered, at any of those steps.
A browser navigating between pages fetches a route's payload rather than its document. Every rule matches that request as the document it is for, so a client navigation is redirected and rewritten exactly as a full page load of the same address would be.
Where they run
uf dev, uf preview, uf start, uf build --compile and every server
adapter — node, bun, deno, container, edge and serverless — apply
all three the same way, and one set of tests asks each of them the same
questions about one project. The rules are written into the server bundle when
it is built, so a served build answers with the rules it was built with.
A static host runs none of it. uf build --adapter static refuses a project
that declares any rule, naming each by its source, and so does every plan that
leaves no server behind: build.staticBuild, rendering.modes: ["ssg"] and
["csr"]. Netlify, Cloudflare Pages and Vercel each have a redirect file of
their own and no two agree, so uf does not write one on your behalf; deploy the
project with a server adapter, or write the host's own file.
Coming from next.config.js
next.config.js | uf.config.js |
|---|---|
async redirects() { return [ … ] } | app: { router: { redirects: [ … ] } } |
{ source, destination, permanent } | the same three fields; permanent is required, as it is in Next.js |
statusCode: 301 | permanent: true, which is a 308 |
async rewrites() { return [ … ] } | app: { router: { rewrites: [ … ] } }, applied where Next.js applies afterFiles |
beforeFiles, fallback | not read: a rewrite runs after the files and before the routes |
a rewrite to https://… | a route handler that fetches it |
async headers() { return [{ source, headers: [{ key, value }] }] } | app: { router: { headers: [{ source, headers: { [key]: value } }] } } |
has, missing | a $middleware.js that checks, then returns rewrite() or Response.redirect |
/:slug, /:path* | the same |
/(.*), /:slug(\\d{1,}), /:slug+, /:slug? | refused by name |
basePath: false | a destination with its origin written out; a source is always under the base path |
locale | not read |
NextResponse.rewrite(url) in middleware.ts | rewrite(url) from @uniflowed/router/middleware |
A base path and a trailing slash
Two more keys decide the address a page is answered at, rather than which route answers it: where on its host the application is served, and which spelling of a path is the page.
// uf.config.js
export default defineConfig({
app: {
router: {
basePath: "/docs",
trailingSlash: "never",
},
},
});
| Key | Default | What it does |
|---|---|---|
basePath | none | Serves the application under this path. A request outside it is a plain 404, and every address uf writes for a browser carries it. |
trailingSlash | "ignore" | "never" answers /about/ with a 308 to /about. "always" answers /about with a 308 to /about/. "ignore" answers both. |
A base path is literal segments with no trailing slash. uf.config.js is
refused when it is not one, and "/", "/docs/", a :name pattern and a query
each get a sentence saying why.
What carries the base path
Everything a project writes is an application path, without the base: route
directories, a Link's to, router.push, redirect(), and a rule's source
and destination. /guide is /guide whether the site is served at / or at
/docs. uf puts the base in front of what reaches a browser:
- the
hrefaLinkrenders, so a link followed before hydration goes where a click does; - the history entry a navigation writes, and the
Locationaredirect()or a redirect rule answers with; - every asset URL in a document, and the payload URL a client navigation fetches;
- every
<loc>insitemap.xml, and theSitemap:line inrobots.txt.
The trailing-slash policy spells those addresses too, so following a link uf wrote is never a redirect.
In the other direction, middleware, route handlers and pages get the request
with the base already taken off: new URL(request.url).pathname is
/api/health under /docs as well. Code that builds an absolute address
itself puts the base back with basePath():
// app/account/$middleware.js
import { basePath } from "@uniflowed/router";
import { cookies } from "@uniflowed/server";
export default function middleware(request: Request): Response | void {
if (cookies().get("session") == null) {
return Response.redirect(new URL(`${basePath()}/sign-in`, request.url), 302);
}
}
Under a base, the application's root is the base itself: /docs, or /docs/
under "always". A path whose last segment has an extension, like
/sitemap.xml, keeps the spelling it was written with under every policy.
Where they apply
uf dev, uf preview, uf start, uf build --compile and every server
adapter take the base off and apply the policy before anything else answers,
a redirect rule and the build's own files included, and the tests ask each of
them the same questions.
uf build writes its files without the base, dist/index.html and
dist/assets/…, so a static host serves dist/ at /docs/. Under "never" a
prerendered page is written as about.html rather than about/index.html,
which static hosts serve at /about without a redirect to the slash. The host
applies no policy of uf's, so the 308 from the other spelling, and what
answers outside the base, are the host's to configure. A crawler reads
robots.txt only at the root of a host, so a site served under a base path
puts its own there.
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]/$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/$page.js
export const dynamic = "force-dynamic";
generateStaticParams cannot express that — there are no parameters to
generate — which is why this exists. A page that exports a
searchParams schema is also rendered per request, without
this line, because its output depends on the query. 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, uf dev and uf prepare write a router.js next to your config,
naming 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 });
The paths are written the way the router matches them — :slug for a
segment, :slug* for a catch-all and :slug*? for an optional one — which is
what RoutePath in the generated file lists.
A route that takes no parameters takes no second argument:
route("/about");
A route with a catch-all takes the segments as an array, because that is what it matches:
route("/docs/:path*", { path: ["guide", "routing"] });
route("/guide/:path*?", { path: [] }); // `/guide`
An empty list is for the optional one: /docs/:path* does not serve /docs,
so route throws rather than build a link to it.
Each of these is a type error rather than a 404 in production:
route("/nope"); // not a path this router serves
route("/posts/:slug"); // the parameter is missing
route("/posts/:slug", { slug: 1 }); // and here it is the wrong type
route("/posts/:slug", { slug: "a", b: "c" }); // `b` is not a parameter of it
route("/about", { id: "1" }); // this route takes none
route("/docs/:path*", { path: "guide" }); // a catch-all is an array
route returns the URL with each parameter encoded, so a value containing a
/ stays one segment rather than becoming two — and the link it builds matches
the route it was built from, because the builder and the matcher are the same
code.
Where to go next
Server Components is next: the "use client"
boundary, and how uf decides which modules reach the browser.
Answering requests covers the files that answer a
request directly — middleware, route handlers, draft mode, QUERY, streams and
sockets.
Edit this pagedocs/app/guide/routing/$page.mdx