Root
- Category: Points
A root is the point every other point grows from. It's the only point created
straight from Point0 instead of inherited, it's the server's entry point, and
it holds the defaults — server and client URLs, the data transformer, the error
class, prefetch policies, query options, loading and error UI — that every page,
layout, query, and mutation below it inherits.
import { Point0 } from '@point0/core'
import { zodSchemaHelper } from '@point0/core/schema/zod'
import superjson from 'superjson'
import { AppError } from '@/lib/error'
import { sharedEnv } from '@/lib/env/shared'
export const root = Point0.lets
.root() // open
.serverUrl(sharedEnv.SERVER_URL)
.clientUrl(sharedEnv.CLIENT_URL)
.transformer(superjson)
.schemaHelper(zodSchemaHelper())
.errorClass(AppError)
.prefetchPageOnNavigate('pageDehydratedStateAndClientQuery')
.prefetchPageOnLinkHover('pageDehydratedStateAndClientQuery')
.queryOptions({
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
})
.loading(() => <Spinner />)
.error(({ error }) => <ErrorScreen error={error} />)
.root() // closeEvery other point is then opened off this root — root.lets.page(...),
root.lets.query(...), and so on — and inherits everything set here. The rest
of this page shows each piece and where it lands.
Declaring a root
A root opens with .lets.root() and closes with .root() — same as every
point, "what you open it with, you close it with". In between you set the
defaults; while the chain is open it's a StagePoint, and the closing .root()
turns it into the finalized ReadyPoint that children grow from. The closing
.root() is server-and-client — not cut from either bundle, kept in both
(isomorphic). Between open and close you can call every default-setter; after
the close those stage-methods are gone and you're left with the ready surface
(.lets, .id, .point, …). See points for the stage-method /
ready-method split and the .lets notation.
export const root = Point0.lets.root().root()
Point0.lets('root', 'app') // a root named 'app'The name 'plugin' is reserved — Point0.lets('root', 'plugin') throws,
because that scope is used internally for plugin points.
A root holds no data of its own — it has no .loader, .clientLoader,
.mapper, or .params. It sets defaults and (on the server) mounts middleware;
everything else is for the points below it.
Server and client URLs
.serverUrl is the origin the server uses to resolve absolute routes — where
query.fetchQuery(...) sends its request, and what route.abs() returns when
there's no browser location:
.serverUrl('https://app.example.com')
// action.route.abs() // => "https://app.example.com/api/..."On the server, serverUrl is required: without it, route.abs() throws
origin for route /api/x is not set.
.clientUrl is the public origin pages live on, for when it differs from
serverUrl — split dev ports, a native shell, or a CDN in front. Page and
layout routes resolve against clientUrl; action (API) routes always use
serverUrl, because the API lives on the server:
.serverUrl(sharedEnv.SERVER_URL) // API origin
.clientUrl(sharedEnv.CLIENT_URL) // page originThe split is by route kind, not by runtime side — so server-rendered and
client-rendered hrefs come out identical. Without clientUrl, pages fall back
to serverUrl.
.serverUrl and .clientUrl are server-and-client — not cut from either
bundle, kept in both (they configure URL resolution on either side, so nothing
is pruned).
The transformer
.transformer sets how input and loader data are serialized over the wire — the
same idea as tRPC's transformer. It runs both ways: query input on send/receive,
and the data loaders return.
import superjson from 'superjson'
.transformer(superjson) // Date, Map, Set, BigInt survive the round-tripThe transformer needs { serialize, deserialize }. Without one, the default is
a plain pass-through (raw JSON). With superjson set, special types are also
encoded into the query key. Details on Transformer.
.transformer is server-and-client — not cut from either bundle, kept in both
(it runs on both sides: serialize on send, deserialize on receive).
The schema helper
.schemaHelper teaches Point0 about your validation library, mainly for
OpenAPI generation and a few search-param edge cases. It's optional —
validation already works through Standard Schema:
import { zodSchemaHelper } from '@point0/core/schema/zod'
.schemaHelper(zodSchemaHelper())Helpers ship as subpath exports: @point0/core/schema/zod, /valibot, /yup,
/arktype, /typebox, /superstruct. You can call .schemaHelper more than
once to register several — the calls accumulate. A falsy argument is a no-op
that keeps the existing helpers, not a reset. More in Validation.
.schemaHelper is server-and-client — not cut from either bundle, kept in both
(validation runs on both sides).
The error class
.errorClass sets the error type for the whole tree. It's the one method that
re-types errors everywhere below: after it, .error(({ error }) => …),
.on('error', …), and result.error all see your class instead of the default
ErrorPoint0:
import { AppError } from '@/lib/error' // your own error class
.errorClass(AppError)Without .errorClass the default is ErrorPoint0. You can replace it with any
class of the same-or-wider shape — a constructor taking
(message, { cause?, status?, code?, redirect?, response?, headers?, meta? })
plus static from, serializePublic, and serializePrivate. How you build
that class is up to you; error0 is one convenient way, but
it's optional. Full surface on Error handling.
.errorClass is server-and-client — not cut from either bundle, kept in both
(errors are raised, serialized, and rendered on both sides).
Prefetch policies
The root sets how pages are prefetched, so navigation feels instant. There are three setters:
.prefetchPageOnNavigate('serverAndClientQuery') // when a navigation starts
.prefetchPageOnLinkHover('serverQuery', 200) // on link hover, after 200ms
.prefetchPagePolicy('serverAndClientQuery') // sets both at onceThe policy is one of 'serverQuery', 'clientQuery', 'serverAndClientQuery',
'pageDehydratedState', 'pageDehydratedStateAndClientQuery',
'onPrefetchOnly', 'none', or false (which means 'none'). The optional
second argument on the hover setters is a debounce in milliseconds. Set these on
the root and override them per page or per link as needed.
Costs differ: pageDehydratedStateAndClientQuery is the most reliable but the
most expensive (it runs a full SSR render). The policies live on
Navigation (and SSR).
All three setters are client-only — cut from the server bundle, body and its
imports removed (navigation and link-hover prefetch are browser behaviours).
.prefetchPagePolicy just sets the other two at once, so it's cut the same way.
Query option defaults
.queryOptions sets default TanStack Query options for every query beneath the
root. It accumulates across calls and can be overridden at query creation and at
each call site:
.queryOptions({
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
})There are type-specific siblings too — .pageQueryOptions,
.componentQueryOptions, .layoutQueryOptions, .providerQueryOptions,
.pageDehydratedStateQueryOptions, .infiniteQueryOptions, .mutationOptions,
.fetchOptions — each merging into its own slot. On the server, Point0
hard-overrides a few of these (retry: false, no refetch,
staleTime/gcTime: Infinity) since a server render fetches once. See
Query for precedence and stage-methods for the full
list.
.queryOptions and its whole *QueryOptions family, plus .mutationOptions
and .fetchOptions, are server-and-client — not cut from either bundle, kept in
both (query/mutation options are applied on both sides).
Events and logging
.on subscribes to runtime events on both sides; .serverOn and .clientOn
narrow to one side. The 'error' shorthand subscribes to every error event —
this is where app-wide error logging goes:
.on('error', ({ side, name, error, meta }) => {
console.error({ ...meta, side, name, error })
})Each callback gets { side, name, data, error, meta }. error is set on error
events; meta is the log-friendly projection (points become ids, requests
become { method, path }, errors are serialized) — log meta, not the raw
data. Full event list and the server/client split on Events.
Strip categories differ by setter. .on is server-and-client — not cut from
either bundle, kept in both (it subscribes on both sides). .serverOn is
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 only on the server).
.clientOn is client-only — cut from the server bundle: body and its imports
removed (it runs only in the browser).
Loading and error UI
The root holds the fallback loading and error components for every point below
it that renders UI. On a root, .loading and .error each set the fallback for
pages, layouts, and components at once:
.loading(() => <Spinner />)
.error(({ error }) => <ErrorScreen error={error} />)The error component receives the (possibly custom) error instance, so with
.errorClass(AppError) set, error is an AppError. There are granular
siblings — .pageLoading / .pageError, .layoutLoading / .layoutError,
.componentLoading / .componentError — for one slot at a time. start0 uses
.componentError to give in-component errors a different look from page errors:
.error(({ error }) => <ErrorPageComponent error={error} />)
.componentError(({ error }) => <ErrorComponent error={error} />)Any point below can override these. Full rules in Loading & error.
.loading and .error — and their granular siblings
(.pageLoading/.pageError, .layoutLoading/.layoutError,
.componentLoading/.componentError) — are server-ssr-and-client: cut from the
SERVER bundle when ssr: false (or after a .clientOnly() earlier in the
chain) — body and imports removed from the server build; kept in the client
build always, and in the server build only when SSR is on.
The error component renders on the server during the initial SSR pass, so be
careful what it exposes: if it prints error.stack, that stack would otherwise
end up in the server-rendered HTML. The default error component hides the stack
in production; if you write your own, render the stack only on the client by
wrapping it in <ClientOnly>, so it never reaches the SSR output. The basic
root does exactly this.
The global head
.head('global', fn) sets the document head for the whole app shell. Unlike a
point's own .head, the global head runs on every page state and reads
{ status, loading, error } rather than a point's loaded data — so you can
drive the <title> from the app's loading/error state:
.head('global', ({ loading, error }) => ({
...(loading ? { title: 'Loading...' } : {}),
...(error ? { title: error.message } : {}),
titleTemplate: '%s | IdeaNick',
htmlAttrs: { lang: 'en' },
}))The return is an unhead object (or a bare string treated as the title).
Flat SEO keys (description, ogTitle, …) and canonical are supported and
win over an explicit meta entry for the same tag. Details on Head.
.head is server-ssr-and-client — cut from the SERVER bundle when ssr: false
(or after a .clientOnly() earlier in the chain): body and imports removed from
the server build; kept in the client build always, and in the server build only
when SSR is on (so the document head is server-rendered under SSR).
Middleware — the server entry point
The root is also the server's entry point: .middleware mounts server-side
handlers. Because the root is the entry, its middleware chain is the request
pipeline itself — it runs for every incoming request regardless of which other
points you've declared (a root with no pages or queries still serves its
middleware). This is rarely needed for your own code, but it's how third-party
handlers — better-auth, an OpenAPI doc server — plug in:
.middleware(openapi({ route: '/openapi.json', scalar: '/scalar', filter: 'all' }))
.middleware('/api/auth/*', async ({ request }) => authServer.handler(request.original))Three forms: global (runs for all requests), route-scoped (a string or route),
and method+route-scoped. .middleware is server-only — cut from the client
bundle: its body and the imports it uses are removed, so it never ships to the
browser (on the client the call no-ops to next(), and it runs only on the
server). Each callback gets { request, set, scope, next, points } (plus
params for a route with params) and returns a Response or calls next().
Full surface on Middleware.
One server, many clients
You can have more than one root — one per client. A typical setup shares a base root (transformer, error class, query defaults) and derives a root per client (server + website + Expo app + admin), each with its own loading/error UI:
const root = Point0.lets
.root()
.serverUrl(sharedEnv.SERVER_URL)
.transformer(superjson)
.errorClass(AppError)
.root()
// a derived root inherits the parent's defaults and overrides what it needs
export const siteRoot = root.lets
.root()
.clientUrl('https://example.com')
.loading(/* ... */)
.root()
export const mobileRoot = root.lets
.root()
.clientUrl('https://m.example.com')
.root()A derived root inherits the parent's defaults — serverUrl, transformer,
error class, query options, prefetch policies, and the loading/error UI — and
can override any of them. mobileRoot above keeps the parent's serverUrl but
sets its own page origin. Each root's name becomes a scope that tags every
point under it, which is how a query in a multi-client build knows which client
it belongs to. Put the shared loading/error UI on the base root and override it
on a client root only where that client should look different.
On the engine side, the config takes a single client or a clients array,
each entry carrying its own scope — that's how the build wires each root to
its client. See Engine config.
Base vs. root
A base is the non-entry sibling of a root: derive one with
root.lets.base()…base() to share partial-scope defaults (a basePath, a
gating plugin) with a subset of points, without being a second server entry
point:
export const adminBase = root.lets
.base()
.basePath('/admin')
.use(adminOnlyPlugin)
.base()Use a root for a whole client, a base for a slice of one. See base.
.use (attaching a plugin) and the .base() closer are server-and-client — not
cut from either bundle, kept in both (isomorphic). Whether a plugin's own
methods get stripped is decided per method by its category, not by .use
itself.
How children inherit
The closing .root() finalizes the point and marks it as both the base and the
root for everything below. Children opened with root.lets.<type>() then pull
defaults up that chain — server/client URLs, base path, all the *QueryOptions,
prefetch policies, and the loading/error components — and the transformer, error
class, schema helpers, middleware, and event subscriptions carry through the
chain too. Nothing lives in a separate config file: everything that shapes a
point is reachable by walking the parent chain from the point itself.
Reference
The setters shown above are the same stage-methods every point has — a root just
sets them as defaults instead of for a single point. The full catalog, with each
method's own page, lives on stage-methods; the sections above
cover the ones whose default belongs on the root (URLs, transformer, schema
helper, error class, prefetch, the *QueryOptions, events, loading/error, the
global head, middleware).
A root has no .loader, .clientLoader, .mapper, .params, .fetchFn,
or .onPrefetchPage — it holds defaults, not data.
Prefetch policies
| Policy | Effect |
|---|---|
'serverQuery' | prefetch the server query |
'clientQuery' | prefetch the client query |
'serverAndClientQuery' | both — the cheap, recommended default for load |
'pageDehydratedState' | full SSR render (expensive) |
'pageDehydratedStateAndClientQuery' | SSR render + client query — most reliable, most expensive |
'onPrefetchOnly' | only fire the onPrefetchPage hook |
'none' / false | no prefetch |
Gotchas
- A root named
'plugin'throws — the scope is reserved. serverUrlis required on the server, orroute.abs()throws.clientUrlsplits page vs. action origin by route kind — actions always useserverUrl.- Middlewares are server-only; on the client they no-op to
next(). .schemaHelper(falsy)is a no-op, not a reset.- The default error class is
ErrorPoint0; the default transformer is a pass-through.
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️