С любовью ко всем разработчикам на свете ❤️
A mountable is a point that renders React. There are four: page,
layout, component, and provider. They differ
in what they mount (a route, a wrapper around children, a piece of UI, a context
value) but share one model: the same composition methods, the same loading/error
wiring, and the same render-prop bag (data, queries, props, …).
// a component, a layout, a page — the same chain shape:
export const UserCard = root.lets
.component()
.with(userQuery, ({ props }) => ({ id: props.userId })) // inject a query
.component(({ data: { user } }) => <span>{user.name}</span>) // read its data
export const ideaLayout = generalLayout.lets
.layout('/ideas/:id')
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.layout(({ children, data: { idea } }) => (
<section>
<h1>{idea.title}</h1>
{children}
</section>
))The rest of this page covers the shared surface; each point type's own page covers its specifics.
| Type | Closes with | Mounts | Extra render-prop keys |
|---|---|---|---|
| page | .page(c?) | a component at a route | location, setSearch |
| layout | .layout(c) | a wrapper around child pages (children) | children, location, setSearch |
| component | .component(c?) | a reusable piece of UI | — |
| provider | .provider(m?) | a context value (read via useValue) | children |
Everything else — how data arrives, what the component receives, how loading and error render — is identical. A non-mountable point (query, mutation, action) renders nothing and has none of this.
Every mountable, while you compose it, exposes the same core methods:
point
.with(...) // inject queries / props / state / wrappers
.wrapper(...) // wrap the whole mountable from outside
.loading(...) // the loading component
.error(...) // the error component
.loader(...) // server data (also makes the point an endpoint — see below)
.clientLoader(...)// browser-side data
.ctx(...) // server context (runs only with a loader)
.mapper(...) // reshape `data`
.clientOnly(...) // opt out of SSR
.use(plugin) // mix in a plugin
.middleware(...) // server middleware
.on / .serverOn / .clientOn(...) // events
.tag(...).description(...) // metadataThe closing method (.page / .layout / .component / .provider) ends
composition and returns the ready point. After that the composition methods are
gone — the ready point exposes only render helpers (.X, .route, queries,
.Infer, …).
The closer (.page/.layout/.component/.provider) is
server-ssr-and-client: cut from the SERVER bundle when ssr:false (or after
a .clientOnly() earlier in the chain) — its body and the imports it uses are
then removed from the server build; kept in the client build always, and in the
server build only when SSR is on.
Page and layout add the route-bound methods (.params, .search, .head,
.scrollRestore, the prefetchPage* family); component and provider, which
have no route, add input methods (.input, .clientInput, .sharedInput)
instead. The full per-type list is in the reference below.
Two ways, the same on every mountable.
A .loader — server-only: cut from the client bundle — its body and the
imports it uses are removed, so it never ships to the browser (it runs on the
server):
export const IdeaCard = root.lets
.component()
.input(z.object({ id: z.number() }))
.loader(async ({ input }) => ({ idea: await findIdea(input.id) }))
.component(({ data: { idea } }) => <h3>{idea.title}</h3>)An injected query via .with — when the data lives in a
reusable query:
export const IdeaCard = root.lets
.component()
.with(ideaQuery, ({ props }) => ({ id: props.id }))
.component(({ data: { idea } }) => <h3>{idea.title}</h3>)Both feed data. The mountable renders only once its data is ready, so the
closing component never sees a half-loaded state. Inject several queries and
read them from queries in declaration order, or fold them with
.mapper. See .with for the full range.
.with and .mapper are both server-ssr-and-client — cut from the SERVER
bundle when ssr:false (or after a .clientOnly()), like the closer.
The closing component receives one object. These keys are shared by all four mountables:
.component(({ data, queries, props, LoadingComponent, ErrorComponent }) => ...)data — the .mapper output, or the first injected query's
data, or {} if neither. (.mapper overrides it.)queries — the injected query results, in .with order ([] if none).props — props contributed by .with ({} if none).LoadingComponent / ErrorComponent — the resolved boundary components.Plus keys that exist only when the matching schema or route is set: params /
search / input, and location (page and layout only — component and
provider have no location). Page and layout also get setSearch; layout and
provider get children. The exact per-type bag is in the
reference.
.mapper(({ data }) => ({ ideas: data.pages.flatMap((p) => p.ideas) }))
.component(({ data: { ideas } }) => <List items={ideas} />)A .mapper reshapes data for the component. On a provider, the
mapper's return value is the provided value — what useValue() and
getValue() hand out.
When a mountable's data is pending, Point0 renders the nearest .loading
component up the chain; on a thrown error or an Error returned from .with,
the nearest .error. Set them once on the root and override per point:
export const root = Point0.lets
.root()
.loading(() => <Spinner />)
.error(({ error }) => <ErrorScreen error={error} />)
.root()
export const IdeaCard = root.lets
.component()
.loading(() => <CardSkeleton />) // override for this mountable
.with(ideaQuery, ({ props }) => ({ id: props.id }))
.component(/* ... */)The error component receives { type, error } where type is the mountable's
variant ('page' | 'component' | 'layout'; a provider reports as 'page'); the
loading component receives { type }. A layout can set separate
boundaries for itself and for the pages beneath it — .layoutError /
.pageError, .layoutLoading / .pageLoading. Full rules, including prefetch
interaction, are in Loading & error.
.loading and .error (and their split forms) are server-ssr-and-client —
cut from the SERVER bundle when ssr:false (or after a .clientOnly()).
.wrapper vs a .with wrapperTwo ways to wrap a mountable's render.
.with(({ children }) => …) sits inside the lifecycle — it sees the
resolved data, queries, and props, and can short-circuit to loading/error
before they load:
.with(({ children, data }) => <section data-idea={data.idea.id}>{children}</section>).wrapper(Component) wraps the whole mountable from the outside,
including its loading and error boundary. It gets props and (on page/layout)
location, but not the resolved data/queries — it renders before they
exist:
.wrapper(({ children }) => <ErrorBoundaryProvider>{children}</ErrorBoundaryProvider>)Multiple .wrapper calls nest with the first-registered outermost. Use
.wrapper for things that must exist around the loading state itself (a theme,
an outer error boundary); use a .with wrapper when you need the loaded data.
Both .wrapper and the .with wrapper are server-ssr-and-client — cut from
the SERVER bundle when ssr:false (or after a .clientOnly()).
A .wrapper wraps the render of the point it's set on. A layout's
wrappers don't carry to the pages beneath it — see that page for why.
.clientOnly.clientOnly() marks a mountable as client-only: it's skipped during SSR and
mounts in the browser. From this point on, the rest of the chain (the remaining
.with, the closing .component/.page, …) is treated as if ssr: false for
this point — those bodies and the imports they use are cut from the server
bundle, so that code never ships to the server (it runs only in the browser).
Call it bare, or pass an optional Fallback to render server-side in its place:
export const MetricsChart = root.lets
.component()
.clientOnly(() => <ChartSkeleton />) // shown server-side until the client mounts
.with(metricsQuery)
.component(({ data }) => <Chart data={data} />)The Fallback renders in the server HTML and on the first client paint; once
the point hydrates, Point0 swaps in the real component. The fallback receives
the mount state plus LoadingComponent and ErrorComponent. With no
Fallback, nothing renders in that slot until hydration.
A mountable renders UI. An endpoint is served over HTTP (its own path, an
OpenAPI entry). These are orthogonal: a mountable becomes an endpoint
only if it has a server .loader() or SSR is on — the loader needs a URL to
fetch.
.loader() is also its own query
(point.useQuery(), point.fetchQuery(), point.getQueryKey()) — Point0
attaches a self query, which is what makes SSR and prefetch work. That self
query needs an endpoint.A query, mutation, or action is always an
endpoint; a mountable is one only sometimes. The page page covers the
page case (SSR on vs loader) in detail.
.with, not .ctxThe closing component is browser code. Server-only code (loader bodies, secrets, DB calls) is stripped from the client bundle at compile time — but the rendered markup is not: under SSR it's produced on the server and shipped to the browser, and after the initial render every mountable re-renders client-side. So don't put secret content in the markup expecting it to stay on the server.
Gate access in a .with, not in .ctx. A mountable's .ctx runs
only when the point has a loader (no loader → no server request → .ctx
never runs), so a loader-less mountable's .ctx protects nothing. .ctx is
server-only — cut from the client bundle, like .loader. A .with runs at
render, every time, on the client and (under SSR) the server:
import { authPlugin } from '@/lib/auth' // a plugin that resolves the user into props.me
import { AppError } from '@/lib/error' // your own error class — ErrorPoint0 or a compatible one
export const AdminPanel = root.lets
.component()
.use(authPlugin)
.with(({ props: { me } }) => {
if (!me?.isAdmin) return new AppError('Forbidden', { code: 'FORBIDDEN' })
return { me }
})
.component(/* ... */)me comes from a plugin upstream (it doesn't appear on its own).
Returning an error from .with short-circuits to the error component. The class
can be the default ErrorPoint0 or any error class of the
same-or-wider structure (one option for building one is
Error0). See .with for the full gate
pattern.
.X — the bound componentClosing a mountable produces a ready point that is itself a React component carrying the full point API. The same component is exposed two ways:
<UserCard userId={1} /> // short — the ready point IS the component
<UserCard.X userId={1} /> // explicit — the bound component on `.X`The short form only works because the variable starts with a capital letter —
JSX treats a lowercase tag (<userCard />) as an HTML element, so a lowercase
point forces you into the explicit <userCard.X /> form. Declare every
mountable in PascalCase and prefer <UserCard />.
A provider is mounted the same way and hands its value out below:
export const AppProvider = root.lets.provider().provider(() => ({ theme: 'dark' }))
// mount it:
<AppProvider>{children}</AppProvider>
// and read its value anywhere below:
AppProvider.useValue('theme') // => 'dark'Page and layout mount on their own — the router renders them
from their route — so you rarely write them as JSX. A page's .X is its Page,
a layout's its Layout, a component's its Component.
All four share data, queries, props, LoadingComponent, ErrorComponent,
plus params / search / input when the matching schema is set. Differences:
| Key | page | layout | component | provider |
|---|---|---|---|---|
location | ✓ (exact) | ✓ (ancestor or exact) | — | — |
setSearch | ✓ | ✓ | — | — |
children | — | ✓ | — | ✓ |
A provider's closing argument is a .mapper-style function whose
return value becomes the provided context value.
Each stage-method is tagged with what compile-time stripping cuts and from which
bundle (the body and the imports it pulls in go together) — one of
server-only (cut from the client bundle, never ships to the browser),
client-only (cut from the server bundle), server-and-client (kept in
both, isomorphic, nothing pruned), or server-ssr-and-client (cut from the
server bundle when ssr:false; kept in the client build always, in the server
build only when SSR is on).
Shared by all four (while composing):
.with — server-ssr-and-client.wrapper — server-ssr-and-client.mapper — server-ssr-and-client.loading — server-ssr-and-client.error — server-ssr-and-client.page / .layout / .component / .provider —
server-ssr-and-client.loader — server-only.ctx — server-only.middleware — server-only.headers — server-only.cookies — server-only.serverOn — server-only.description — server-only.clientLoader — client-only.clientOn — client-only.clientOnly — the SSR switch; from here on, server-ssr-and-client methods
behave as ssr:false.use — server-and-client (a plugin's own methods strip by their own
category).fetchOptions — server-and-client.on — server-and-client.tag — server-and-client.query / .infiniteQuery (to finalize a loader-bearing self query) —
server-and-client. The self query is finite by default; close with
.infiniteQuery({...}) after the loader to make it infinite.Page / layout add (route-bound):
.params / .search — server-and-client here (a non-action mountable keeps
them isomorphic; they're server-only only on an action).head — server-ssr-and-client.scrollPosition / .scrollRestore — client-only. Documented in full on
navigation; see that page..relatedQuery — server-and-client. It DOES add its query to queries (like
a .with(query)); the difference is prefetch — a related query is statically
discoverable, so prefetch self-fetches it without rendering under the cheap
policies (serverQuery / clientQuery / serverAndClientQuery), whereas a
.with(query) is only discovered by rendering and is prefetched only under
the expensive pageDehydratedState*..onPrefetchPage — server-and-client. It runs on the client during prefetch
AND on the server before the first render. .serverOnPrefetchPage
(server-only) and .clientOnPrefetchPage (client-only) are the same hook
pinned to one side..prefetchPageOnNavigate / .prefetchPageOnLinkHover / .prefetchPagePolicy
— client-onlyLayout also adds the split boundaries .pageError / .layoutError and
.pageLoading / .layoutLoading (both server-ssr-and-client, like .error
/ .loading), and .pageQueryOptions / .layoutQueryOptions
(server-and-client).
Component / provider add (no route): .input / .sharedInput —
server-and-client on a non-action mountable (isomorphic); .clientInput —
client-only. They have no .params / .search / .head / scroll / prefetch
methods, and no .relatedQuery or .onPrefetchPage — both are
page/layout-only.
At runtime, exactly page | layout | component | provider. Every mountable can
carry and inject queries; not every query-carrying point is a mountable — a
standalone query or infinite query is queryable but
not mountable (it renders nothing).