SSR
- Category: Core
With SSR on, a page is rendered on the server with its data already loaded, so the browser gets real HTML on the first response — not an empty shell that fetches afterward. Point0 does this without you marking anything: it renders the page, finds the queries it tried to run, fetches them on the server, and re-renders until nothing is left pending. Then it ships the HTML plus a dehydrated query cache, and the page hydrates into the normal SPA.
// engine.ts
import { Engine } from '@point0/engine'
export const engine = Engine.create({
ssr: true, // server-render pages; off unless you turn it on
clients: [
/* ... */
],
})That single line is the whole setup. The rest of this page explains the loop, the tuning options, and the prefetch policies that decide how much work each navigation does.
How a render becomes HTML
SSR runs a render-to-discover loop. There's no manifest of "what this page needs" — Point0 learns it by rendering:
- Render the page once on the server.
- Look at the query cache for any query that is pending — a query the render started but couldn't resolve synchronously.
- Fetch those queries on the server (their loaders run here, with your server code).
- Render again. Now those queries resolve from cache, which may reveal new pending queries deeper in the tree.
- Repeat until a render adds no new pending queries.
// this page renders, the loader is found pending, fetched, then re-rendered
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.page(({ data: { idea } }) => <h1>{idea.title}</h1>)
// server ships HTML with <h1> already filled in; no loading flash on the clientIn practice this is 2–4 re-renders per request — one to discover, one or two to fetch and settle. A page with no data settles in a single render; a chain where one query's result feeds the next (via an SsrStore) can take five. You don't manage any of this; it's the price of not having to declare your data dependencies up front.
And if you don't want to pay it, you don't have to. The re-renders are a
convenience, not a tax: tell Point0 up front what a page needs — warm its cache
in .onPrefetchPage (below), which runs once before the
first render — and that render already has the data, so the loop settles in a
single pass. So there are two comfortable modes: convenient but with
re-renders (write nothing, let the loop discover), or a little extra work
and zero re-renders (declare the data once). Pick per page.
Two things the loop deliberately does not do:
- Client loaders don't run on the server. A
.clientLoader()query stays pending through SSR and renders its loading state — the data arrives after hydration. Only server queries are fetched during SSR. - Disabled queries are skipped. A query with
enabled: falseis never prefetched, which is exactly how a dependent query waits for its input.
Shipping the HTML, then the SPA
After the loop settles, the server sends the rendered HTML with a serialized
store (including the resolved query cache) injected as a <script> at the start
of the <head>. On the client, mount() (from @point0/react-dom/mount)
prepares that dehydrated superstore — which carries the query cache — then calls
React's hydrateRoot over the existing markup. From there the page is a normal
SPA — navigations no longer hit the server for HTML.
A page that started life as a bare index.html and fetched on the client ends
up identical to its SSR'd version. SSR changes when the data arrives (with the
HTML vs. after a client fetch), not the final result.
The HTML ships whole. Point0 waits for the entire React tree
(stream.allReady) before it sends a byte — there's no progressive,
Suspense-boundary streaming and no out-of-order chunks. That's the intent: if
you turned SSR on, you asked for the finished page in the first response, and
streaming a half-built page full of spinners would undo that. When you do want
a slow part to load in pieces, reach for the explicit tools instead — turn SSR
off so the whole page fetches on the client (ssr: false), or mark just that
part client-only with .clientOnly() / <ClientOnly>
(below), so SSR ships its fallback and the real content
mounts after hydration. React Server Components aren't supported either — not a
current goal. Point0's render-to-discover SSR already fetches on the server and
strips server-only code from the client bundle; we don't see much additional
value in RSC on top of that. If you do have a concrete picture of what RSC would
unlock here — why you need it and who it would help — we're open to the likely
first step: letting a .loader() return React elements directly. Spell out that
motivation in a GitHub issue — who it helps
and why the current model isn't enough.
When the server render throws (anything that isn't a redirect),
the engine falls back to serving the bare index.html with the error attached —
the page still loads as an SPA instead of 500-ing.
Security: server-only code (loader bodies, secrets, DB calls) is cut from the client bundle — its body and the imports it uses are removed at compile time, so it never ships to the browser (it runs only on the server during SSR and query fetches). The page component itself renders on both sides, and anything you render into the HTML is public. Gate access with a server
.ctxor.loader, not by hiding markup. In production Point0 never renders an error stack into the HTML.
Turning SSR off
ssr: false ships the bare index.html and the page fetches its data on the
client (the classic SPA):
export const engine = Engine.create({ ssr: false })With ssr: false the server-ssr-and-client render methods (.page /
.layout / .component / .provider, the .loading and .error families,
.wrapper, .with, .mapper, .head) are cut from the server bundle — their
bodies and the imports they use are removed from the server build, since there's
no server render to keep them around for; they stay in the client build. (Of the
four strip categories, only this one tracks the SSR flag: server-only and
client-only code is cut the same way with SSR on or off, and server-and-client
code is never stripped.)
You can also opt one point out while SSR is globally on, with
.clientOnly — it forces that point to client-only render and
shows an optional fallback during SSR:
export const ChartPage = root.lets
.page('/chart')
.clientOnly(() => <Skeleton />) // SSR renders the fallback; the real chart mounts on the client
.page(/* a component that only works in the browser */)The fallback is optional — call .clientOnly() with no argument and SSR renders
nothing for the point (an empty placeholder) until it mounts on the client:
export const ChartPage = root.lets
.page('/chart')
.clientOnly() // no fallback; the slot is empty during SSR, filled after hydration
.page(/* a browser-only chart */).clientOnly() makes the rest of the point's chain client-only — exactly as if
ssr: false applied to this one point. It targets one of the four strip
categories: the server-ssr-and-client render methods. After .clientOnly()
(or globally, with ssr: false) those are cut from the server bundle —
their bodies and the imports they use are removed from the server build, kept in
the client build always and in the server build only when SSR is on. So a
browser-only library you reach for in them never lands in the server build, and
never executes during SSR. The full set: .page / .layout / .component /
.provider; .loading (and .pageLoading / .layoutLoading /
.componentLoading); .error (and .pageError / .layoutError /
.componentError); .wrapper; .with; .mapper; .head.
The other three categories are unaffected by .clientOnly() / ssr: false.
server-only methods before it (.ctx, a server .loader, .input, …) stay
cut from the client bundle either way — their bodies and imports never ship to
the browser (they run on the server as usual); client-only methods
(.clientLoader, .onPrefetchPage, …) stay cut from the server bundle — body
and imports removed regardless of SSR; and server-and-client methods
(closers like .query, the *QueryOptions setters, .relatedQuery, …) are cut
from neither bundle — kept in both (isomorphic). .clientOnly() only
client-restricts what renders, not what loads.
Tuning the loop
The discover loop is comfortable, but every re-render is a render you paid for. If a page's data is predictable, you can tell Point0 what it needs before the first render and collapse the loop to a single pass.
The escape hatch is .onPrefetchPage: a hook where you warm
up the cache yourself. Take a page that loads a query the loop would otherwise
discover on a second pass — declare it in .onPrefetchPage instead:
export const ProfilePage = root.lets
.page('/profile')
// runs before the first render — the cache is already warm when rendering starts
.onPrefetchPage(async () => {
await getMeQuery.prefetchQuery()
})
.with(getMeQuery)
.page(({ data: { me } }) => <h1>Hello, {me.name}!</h1>)Now the very first render finds getMeQuery already in cache, resolves it
synchronously, and the loop settles in one pass — no discover-then-fetch
round-trip.
Two things make this hook pull its weight:
- It runs on both sides, automatically. The same
.onPrefetchPageruns on the server (once before the first render — no opt-in) and on the client (when you navigate to the page). You write the prefetch once and it covers the first load and every client-side navigation. Need one side only? Reach for.serverOnPrefetchPage/.clientOnPrefetchPage— the same hook, but the body (and the imports it pulls in) is stripped from the other bundle. - On the client it doesn't cost an HTTP round-trip just to "be on the
client". A
prefetchQuery/fetchQueryon a point's loader goes over the network on the client only because the loader is server code; that's expected. But there's no duplicate hop on the server — during SSR the same call resolves in-process throughengine.fetch(), not by Point0 making an HTTP request back to itself. The point'sfetchis wired straight into the engine.
Warming the queries this way removes the data-discovery passes — and by default
you only pay for what you warm. The hook runs before the first render; on its
own it does not auto-prefetch your page/layout loaders. If a page's loaders
are predictable, flip on
prefetchLoadersBeforePageRender to
prefetch the declared .loader() queries up front as well — no hook needed:
// a page with a layout loader + a page loader:
// nothing warmed → 3 renders (initial, layout, page)
// both queries warmed in .onPrefetchPage → 1 render
// prefetchLoadersBeforePageRender: true → 1 render (loaders warmed automatically)A separate source of re-renders is store/cookie stabilization, which you cap
independently with allowedRerendersCount (below) — set it to 0 to also stop
those.
Pass an object instead of a boolean to tune the re-render loop. The object form
is SSR on unless you set enabled: false:
export const engine = Engine.create({
ssr: {
// best case: prefetch loaders up front so the first render already has the data
prefetchLoadersBeforePageRender: true,
allowedRerendersCount: 0,
},
})prefetchLoadersBeforePageRender
The .onPrefetchPage hooks always run before the first render; this option adds
the loaders to that step. With prefetchLoadersBeforePageRender: true,
Point0 also prefetches the page's and its layouts' .loader() server queries
declaratively — with inputs derived from the route — before the first
render. The render then finds the data already in cache and needs fewer, often
zero, extra passes.
// a page with a layout loader + a page loader:
// default → 3 renders (initial, layout, page)
// prefetchLoadersBeforePageRender: true → 1 renderOnly queries declared as .loader() are prefetched here — their inputs are the
route params, so they are always correct. Queries injected with .with() (or
declared inside a component) take render-time inputs and are still discovered by
the render loop, which runs as the fallback. Default false.
allowedRerendersCount (soft cap)
A budget on the SsrStore/cookie stabilization re-renders. Once this many of
those passes happen, the loop stops quietly — no error, it just uses the
last render. Default Infinity (re-render until stable). Set it to 0 or 1
to opt out of the stabilization re-renders that an SsrStore or
cookie write would otherwise trigger:
ssr: {
allowedRerendersCount: 0
} // never re-render just to settle a store/cookieWhen the soft cap is hit, any staged SsrStore change from the last render is
left uncommitted — the HTML and the transferred store value stay consistent.
forbiddenRerendersCount (hard cap)
A safety net. Reaching it stops the loop and logs a server error. It catches
non-deterministic values — Date.now(), Math.random() in a store or cookie —
that never stabilize and would otherwise re-render forever. Default 25.
// after 25 passes, logged at level 'error', category ['ssr']:
// "SSR stores/cookies did not stabilize after 25 re-renders (forbiddenRerendersCount);
// using the last render. Check for non-deterministic SsrStore or cookie values..."If both caps are set, the hard cap is checked first.
Dev only: a server render sets an
X-Point0-Renders-Countresponse header with the final pass count, so you can eyeball how many re-renders a page took straight from the network tab. It's not set in production.For anything beyond eyeballing, read the count in code:
request.rendersholds the pass count for the current request, in dev and production. From a middleware you can log it, alert on pages that re-render too much, or feed it into metrics — it's a real number on every request, not just a debug header.
Prefetch policies
The loop above is the first load. Once the SPA is running, navigations no longer ask the server for HTML — instead Point0 prefetches the next page's data before it swaps the view, so the page appears already filled in. The policy decides what "prefetch its data" means, and the policies differ in one key way: whether the prefetch renders the page on the server or not.
That distinction is where the old framing goes wrong, so be precise about it:
- With
pageDehydratedStateAndClientQuery(andpageDehydratedState) the server does render the page — in memory, to discover and resolve its queries. It just doesn't send back HTML; it sends back the dehydrated query cache, which the client drops into its own cache. A real server render happens, it's the most thorough policy, and it's the most expensive one. - With
serverAndClientQuerythere is no server render at all. The client looks at the queries declared on the target page and its layouts and calls them directly. Cheaper, but it only sees queries that are visible without rendering.
Policies are a navigation topic; here is the SSR-relevant gist.
Set a policy on the root (the usual place) for both triggers, or per
<Link> / navigate:
export const root = Point0.lets
.root()
.prefetchPagePolicy('pageDehydratedStateAndClientQuery') // sets navigate + hover
.root()The policy values, cheapest to most thorough:
| Policy | What it does |
|---|---|
'none' / false | no prefetch — the page loads its data after navigation |
'onPrefetchOnly' | run only the .onPrefetchPage hooks |
'serverQuery' | prefetch the page/layout server queries it can see |
'clientQuery' | prefetch the page/layout client queries |
'serverAndClientQuery' | prefetch whichever loader each point has — no render |
'pageDehydratedState' | server renders the page in memory, returns the cache |
'pageDehydratedStateAndClientQuery' | the above, plus the client loaders |
The three approaches worth knowing
The table has seven values, but in practice you reach for one of three setups. They trade how much code you write against how many server renders you pay for against what the user sees on navigation.
1. pageDehydratedStateAndClientQuery — least code, server renders. Set it
once on the root and forget it. Every navigation triggers a full in-memory
server render of the target page, so every query is found — even ones declared
inside deep components — and the page lands fully loaded. You write nothing
extra; you pay a server render per navigation.
export const root = Point0.lets
.root()
// most reliable coverage, no per-page work, but a server render per navigation
.prefetchPageOnNavigate('pageDehydratedStateAndClientQuery')
.root()2. serverAndClientQuery + onPrefetchPage — a little code, no server render
on navigation. The client calls the page's declared queries directly, no
server render. Queries buried inside components aren't seen this way, so you
make up the difference with .onPrefetchPage, warming
exactly what the page needs — and that same hook keeps the first SSR load
single-pass. Add allowedRerendersCount: 0 if you also want to forbid
store/cookie stabilization re-renders.
export const ProfilePage = root.lets
.page('/profile')
// declare what the cheap policy can't see on its own
.onPrefetchPage(async () => {
await getMeQuery.prefetchQuery()
})
.with(getMeQuery)
.page(({ data: { me } }) => <h1>Hello, {me.name}!</h1>)
export const root = Point0.lets
.root()
.prefetchPageOnNavigate('serverAndClientQuery') // no server render on navigation
.root()3. none — no prefetch, loading states do the work. Don't prefetch
anything. On navigation the page mounts, its queries start, and your
.loading() components show until the data arrives. The simplest model, and
perfectly fine when a brief loading state is acceptable.
export const root = Point0.lets
.root()
.prefetchPageOnNavigate('none') // mount, then resolve under .loading()
.root()serverAndClientQuery vs pageDehydratedState in detail
The two ends of the trade-off, spelled out:
// CHEAP: no server render. Looks at the page/layout loaders and calls them from the client.
.prefetchPageOnLinkHover('serverAndClientQuery')serverAndClientQuery renders nothing, so it can only see queries declared on
the points themselves (.loader, .with on the page/layout). A query declared
inside a component isn't discovered — it shows its loading state after
navigation. Cheap to run, looser coverage; close the gap with .onPrefetchPage.
// EXPENSIVE: full in-memory SSR render of the target page, just to collect its cache.
.prefetchPageOnNavigate('pageDehydratedStateAndClientQuery')pageDehydratedState* asks the server to render the page in memory and
return only its dehydrated query cache (the page's HTML is thrown away). It runs
the same render-to-discover loop, so it finds every query, including the ones
inside components — best coverage, no per-page work, but you pay a full server
render per prefetch.
These two require SSR. With ssr: false they throw:
.prefetchPagePolicy('pageDehydratedState')
// throws "Query client dehydrated state can be prefetched only when ssr is enabled..."A practical split: serverAndClientQuery on hover (fires constantly, must be
cheap), pageDehydratedStateAndClientQuery on the actual navigate. Pick the
expensive one only where coverage matters more than load.
.onPrefetchPage
The escape hatch for what a cheap policy misses. .onPrefetchPage registers a
callback that runs during prefetch (and, on the server, once before the first
render), where you can warm up data the policy wouldn't otherwise discover. It's
on base, page, layout, and plugin; calls
accumulate.
export const profilePage = root.lets
.page('/profile')
.onPrefetchPage(async () => {
const { me } = await getMeQuery.fetchQuery()
if (me) await listAccountsQuery.prefetchQuery() // only when signed in
})
.page(/* ... */)It receives { location, props } and runs on both sides: on the server once
before the first render (always — no opt-in), and on the client during prefetch
when you navigate to the page — so the same warm-up code covers the first load
and every navigation. On the client it never fires in the normal
render-to-discover loop, never for the 'none' policy, and never for the
server-only 'pageDehydratedState' policy (which returns right after the
in-memory render, before the hooks fire; pageDehydratedStateAndClientQuery
still runs them).
Need one side only — server-only setup, or a client-only ping —
.serverOnPrefetchPage and .clientOnPrefetchPage are the same hook restricted
to one bundle: the server-only body (and its imports) is stripped from the
client build, the client-only body from the server build. Plain
.onPrefetchPage stays in both.
The dehydrated-state endpoint
The pageDehydratedState* policies are powered by a third output type a page
can serve, alongside its HTML and its data. A page endpoint can be asked for
queryClientDehydratedState — the engine runs the full SSR loop in memory and
returns just the serialized query cache (pending queries filtered out), which
the client hydrates and then swaps for live queries.
You don't call this endpoint directly; the prefetch policy does. Two things are worth knowing:
-
Pages always keep an endpoint so this can be requested — even a page with no server loader stays addressable for its dehydrated state.
-
Tune the prefetch query itself with
.pageDehydratedStateQueryOptions(...)on the root, base, or page. This is the dehydrated-state fetch's own query options, separate from your page queries — and a longerstaleTimehere usually pays off:export const root = Point0.lets .root() .pageDehydratedStateQueryOptions({ staleTime: Infinity }) .root()It matters most when you prefetch on link hover: a hover fires every time the cursor crosses a link, and without a stale window each pass would re-fetch the whole dehydrated state. A longer
staleTimelets one hover's result satisfy the navigation that follows (and repeat hovers), instead of asking the server to re-render the page again and again.
SsrStore: state that survives the loop
A value written during render that must reach the client — and may drive
re-renders — belongs in an SsrStore, written through
useEffectSsr so the effect also runs on the server:
import { useEffectSsr } from '@point0/core'
import { SsrStore } from '@point0/core/ssr-store'
export const $breadcrumb = SsrStore.define<BreadcrumbItem[]>(
'breadcrumb',
() => [],
)
export const useBreadcrumb = (...items: BreadcrumbItem[]) => {
useEffectSsr(() => {
$breadcrumb.set(items) // staged on the server, committed between renders
return () => $breadcrumb.set([])
}, [stringify(items)])
}On the server a set() stages the value; the loop commits it between passes
and re-renders so ancestors reading the store see the new value. This is what
the soft/hard caps protect: a store that never settles would re-render forever.
Cookies behave similarly but are always committed, even on the final pass —
a dropped cookie is worse than a re-render. Full mechanics on
SsrStore and CookieStore.
Reference
The ssr engine option
type SsrOptions = {
enabled?: boolean // default true when an object is given
allowedRerendersCount?: number // soft cap; default Infinity
forbiddenRerendersCount?: number // hard cap (+ logs an error); default 25
prefetchLoadersBeforePageRender?: boolean // also prefetch loaders before first render; default false
}
// engine config accepts: boolean | SsrOptions
ssr: true // on, all loop defaults
ssr: false // off — bare index.html, client-side fetch
// (omitting `ssr` entirely) // off — same as `ssr: false`
ssr: {
enabled: false
} // off (object form, explicitly disabled)
ssr: {
allowedRerendersCount: 0
} // on, no stabilization re-rendersSSR is off unless you turn it on — omitting ssr resolves the same as
ssr: false. Turn it on with ssr: true or an object (the enabled: true
default applies only to the object form: ssr: {} is on). A boolean turns SSR
on or off with every loop default; an object overrides only the keys you set,
keeping the default for anything left out. Per-point, .clientOnly
forces that one point off.
Prefetch policy values
'none' · false · 'onPrefetchOnly' · 'serverQuery' · 'clientQuery' ·
'serverAndClientQuery' · 'pageDehydratedState' ·
'pageDehydratedStateAndClientQuery'. Set via .prefetchPagePolicy (both
triggers), .prefetchPageOnNavigate, .prefetchPageOnLinkHover (optional hover
delay, default 30ms), or per <Link> / navigate. When you set none of them,
the policy is 'none' — no prefetch. The pageDehydratedState* ones require
SSR. Resolution and link wiring are on navigation; the setter gist
is in stage-methods.
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️