# 1gr14 — full documentation
> Documentation for every open-source project by 1gr14. The projects are independent — each one is used on its own.
---
# Point0 — Overview
Source: https://1gr14.dev/point0/v0/overview.md
The first fullstack framework on Bun — the scope of Next.js and TanStack Start,
the simplicity of tRPC. One typed builder describes every point: pages, layouts,
components, providers, queries, mutations, actions. Everything that affects a
page lives in the page's builder methods: no hidden config in other files, no
folder structure forced on you. The loader is plain react-query under the hood,
so pages, layouts, and components become cacheable queries themselves. Server
and client code live in the same builder; the compiler strips the loader body
and all its imports out of the client bundle. A loader can even return React
elements — server components render on the server, component points hydrate as
interactive islands. Works with and without SSR. Types aren't generated — it all
rides on the builder's generics.
```sh
bun create point0-app@latest
```
The code lives on [GitHub](https://github.com/1gr14/point0). For your AI agent
there's [1gr14.dev/llms.txt](https://1gr14.dev/llms.txt) (the
[llmstxt.org](https://llmstxt.org) format) — feed it to an agent and it answers
any question about the framework.
Below is the root — the shared setup every point inherits — and five examples
built on it. It's a deliberately thin slice: enough to feel how Point0 works
without drowning you in features. The framework behind it is much bigger — what
it covers is summed up at the end of this page, walked end to end in
[Full Overview](full-overview), and covered in depth on each feature's own page.
## Root point
Every point grows from a root — `root`. You set the shared things once here: the
loading view, the error view, the transformer, the schema helper, and more.
Every page and component inherits those, so in the examples below you won't have
to think about loading and errors.
```tsx
import { Point0 } from '@point0/core'
export const root = Point0.lets
.root()
// shown while a point's data is loading
.loading(() => )
// shown if loading failed
.error(({ error }) => )
// loaders may return React elements (RSC: server components & islands)
.rsc({ depth: 1 })
.root() // a point ends with the word it started with (.root) — same for all points
```
## A page with a loader
The path, the data, and the markup live in one place. `params` is typed straight
from the route string. The framework renders the loading and error states for
you.
```tsx
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma' // server-only — never reaches the client
export const ideaPage = root.lets
.page('/ideas/:id') // params.id is typed because the route has :id
.loader(async ({ params }) => {
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: params.id },
})
return { idea } // whatever the loader returns is typed below
})
.head(({ data: { idea } }) => idea.title) // the page's
.page(({ data: { idea } }) => (
// in .page() the data is already loaded — otherwise we never reach here,
// the root's .loading() or .error() shows instead
{idea.title}
{idea.content}
))
```
## A page with one injected query
When a loader is reused, move it into its own query and inject it into the page
with `.with()`. The query has a single cache — no duplicate requests hit the
server.
```tsx
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
import * as z from 'zod'
export const ideaViewQuery = root.lets
.query()
.input(z.object({ id: z.string() })) // schema via any library: zod, valibot, typebox…
.loader(async ({ input }) => {
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: input.id },
})
return { idea }
})
.query()
export const ideaPage = root.lets
.page('/ideas/:id')
// inject the query and map route params onto its input
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.head(({ data: { idea } }) => idea.title)
.page(({ data: { idea } }) => (
{idea.title}
{idea.content}
))
```
You can call the same query like any react-query in any component:
`ideaViewQuery.useQuery({ id })`. The compiler strips the server code out of it
on the client.
## A page with two injected queries
`.with()` can be called more than once. The queries load in parallel; the page
renders once both are ready. `.mapper()` folds them into one tidy `data` before
the render.
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.with(ideaBestQuery) // a second query; both load in parallel
.mapper(({ queries: [view, best] }) => ({
idea: view.data.idea,
bestIdea: best.data.bestIdea,
}))
.page(({ data: { idea, bestIdea } }) => (
{idea.title}
))
```
## A page with a mutation
A mutation is a react-query mutation. Declare it anywhere, call it directly by
importing the mutation itself. Types don't bloat — the editor stays fast.
```tsx
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
import { navigate } from '@/lib/navigation'
import * as z from 'zod'
export const ideaUpdateMutation = root.lets
.mutation()
.input(
z.object({
id: z.string(),
title: z.string().min(1),
content: z.string().min(1),
image: z.file().optional(), // a file field, like any other input
}),
)
.loader(async ({ input }) => {
// input.image is a real File on the server — or undefined if none sent
const cover = input.image && Buffer.from(await input.image.arrayBuffer())
const idea = await prisma.idea.update({
where: { id: input.id },
data: { title: input.title, content: input.content, cover },
})
return { idea }
})
.mutation()
export const ideaEditPage = root.lets
.page('/ideas/:id/edit')
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.page(({ data: { idea } }) => {
const mutation = ideaUpdateMutation.useMutation()
return (
)
})
```
## A component with its own loader
More often, different parts of a page need different data. Don't pull everything
into the page loader — let a component load its own. A component is a point too:
its own loader, its own props, its own loading and error states.
```tsx
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
export const IdeaBestComponent = root.lets
.component<{ cta: string }>() // the component's input props type
.loader(async () => {
// server-only, like every loader — this body is stripped from the client
const bestIdea = await prisma.idea.findFirstOrThrow({
orderBy: { rating: 'desc' },
})
return { bestIdea }
})
.component(({ data, props }) => (
Best idea: {data.bestIdea.title}
{props.cta}
))
// use it like any component — short notation (name starts with a capital letter)
export const homePage = root.lets.page('/').page(() => (
Home
))
```
## Streaming components with `suspend`
A component with a loader is fetched during SSR, so its data lands in the first
paint — but the page waits for it. Close its loader with
`.query({ suspend: true })` and it stops holding the page back: the shell ships
at once, each component shows its loading state, and every one streams into the
**same** response the moment its own loader resolves. These aren't server
components — they're real, live components; SSR just delivers them in pieces,
with no client refetch and no waterfall.
```tsx
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
// close the loader with .query({ suspend: true }) so it never blocks the page
export const IdeaStats = root.lets
.component()
.loader(async () => ({ count: await prisma.idea.count() }))
.query({ suspend: true })
.component(({ data }) =>
{data.count} ideas so far
)
export const IdeaTrending = root.lets
.component()
.loader(async () => ({ ideas: await prisma.idea.findMany({ take: 5 }) }))
.query({ suspend: true })
.component(({ data }) => )
// the shell ships instantly; each component streams in — with the root's
// loading state in its place — as its own loader resolves, in one response
export const dashboardPage = root.lets.page('/dashboard').page(() => (
Dashboard
))
```
Two independent widgets, two arrival times, one response — the page never blocks
on the slowest.
## Server components and islands
A loader can return React elements, not just plain data — and Point0 handles the
two kinds for you. A plain function becomes a **server component**: it runs on
the server, and only its rendered markup ships to the client, so its code and
data access never reach the browser. A component point becomes an **interactive
island**: it travels as a reference and comes alive on the client. Both ride the
same data pipe as every other loader value — no `'use client'`, no second module
graph.
```tsx
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
// a plain async function — its code and this prisma call stay on the server
const IdeasStats = async () => {
const count = await prisma.idea.count()
return
{count} ideas so far
}
export const feedPage = root.lets
.page('/feed')
.loader(async () => ({
// a server component — renders here, only its markup ships
stats: ,
// a component point — travels as a reference, hydrates as a live island
best: ,
}))
.page(({ data }) => (
{data.stats}
{data.best}
))
```
An element is just another value in `data`, so the same trick works in queries,
mutations, layouts, and providers — anywhere a loader runs.
## Streaming a slow part with `defer`
Point0 awaits every server component before the response ships, so one slow part
would hold back the whole page. Wrap it in `defer` and the loader returns at
once: a fallback ships in its place, and the resolved markup streams into the
**same** response as it settles — on the first SSR paint and on client
navigation alike. No second request, no client waterfall.
```tsx
import { defer } from '@point0/core'
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
// a slow server component — its query and imports stay on the server
const Comments = async ({ ideaId }: { ideaId: string }) => {
const comments = await prisma.comment.findMany({ where: { ideaId } })
return
}
export const ideaPage = root.lets
.page('/ideas/:id')
.loader(async ({ params }) => {
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: params.id },
})
return {
idea, // fast — ships in the first paint
// slow — the page ships without it, then it streams into the same response
comments: defer(, ),
}
})
.page(({ data }) => (
{data.idea.title}
{data.comments}
))
```
The full model — server components, interactive islands, and streaming with
`defer` and `suspend` — is on the [RSC](rsc) page.
## Sockets: channels and rooms
Chat, presence, live dashboards — same builder, same types. A **channel** is the
one WebSocket: its `.connector` runs like a loader and establishes the
connection's server-held identity (a guest is simply `userId: null`). A
**space** grows rooms from that channel — the client joins, the server's
`.joiner` decides which rooms it enters. Handlers are the typed messages that
ride the socket: the client sends, the server pushes to a room, and every
subscribed component wakes.
```tsx
import { root } from '@/lib/root'
import { readSession } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { messageSchema } from '@/lib/schemas'
import * as z from 'zod'
// the channel: one socket, open to everyone. The connector establishes the identity —
// `userId: null` is a guest; what needs a user is gated where it runs, server-side.
export const appChannel = root.lets
.channel()
.connector(async ({ request }) => {
const me = await readSession(request)
return { userId: me?.userId ?? null }
})
.channel()
// a space of chat rooms — one room per chat; the joiner is the gate
export const chatSpace = appChannel.lets
.space<{ chatId: string }>()
.input(z.object({ chatId: z.string() }))
.joiner(async ({ input, identity }) => {
if (!(await isMember(identity.userId, input.chatId))) {
throw new AppError('Not a member of this chat', { code: 'FORBIDDEN' })
}
return { chatId: input.chatId }
})
.space()
// client → server — check the identity, save the message, push it to the whole room
export const messageSend = chatSpace.lets
.serverHandler()
.clientSend(z.object({ text: z.string().min(1) }))
.serverReply(async ({ input, identity, room }) => {
if (identity.userId === null) {
throw new AppError('Sign in to send messages', { code: 'UNAUTHORIZED' })
}
const message = await prisma.message.create({
data: {
text: input.text,
chatId: room.chatId,
authorId: identity.userId,
},
})
void messageAdded.sendToClient(message, { room }) // fan out to the room
return message
})
.serverHandler()
// server → client — every listening component in the room wakes
export const messageAdded = chatSpace.lets
.clientHandler()
.serverSend(messageSchema)
.clientHandler()
```
On the client, a component joins its room and the messages just arrive — no
polling, no refetch:
```tsx
export const ChatRoom = ({ chatId }: { chatId: string }) => {
const membership = chatSpace.useMembership({ chatId }) // join this chat's room
const [messages, setMessages] = useState([])
messageAdded(membership).useOnMessageFromServer(({ message }) => {
setMessages((prev) => [...prev, message])
})
const send = (text: string) => messageSend(membership).sendToServer({ text })
// an at the app root holds the one socket every room rides
return
}
```
`sendToClient` to a room on the server, `useOnMessageFromServer` on the client —
that's the loop. Channels, spaces, and handlers are points like the rest: same
validation, transformer, events, and stripping, and multi-process fan-out rides
a Redis-shaped socket backplane — a `redis://` URL, a ready-made adapter
(Postgres over `LISTEN`/`NOTIFY`, ioredis, node-redis), or any KV + pub/sub you
bring. Sockets are also **optional in the literal sense**: an app that never
turns `socket` on never downloads them — the compiler cuts the whole socket
runtime out of the client bundle. Full model on the [Channel](socket) and
[Subscription](subscription) pages.
## Client bundle size
Every package a Point0 app ships to the browser, measured as npm delivers it:
bundled, minified, with `react` and `react-dom` left out because you pay for
those either way. Each row imports the whole surface of its package, so nothing
tree-shakes away — these are ceilings, not best cases. The socket is the one
opt-in: core is measured the way an app that never sets
`server: { socket: true }` compiles it, and the row under it is the same core
with the feature on. **Total** is one bundle holding every non-optional row at
once: what the browser actually downloads.
| package | role | raw | gzip | brotli |
| ------------------------ | --------------------------------------------------------------------- | -----------: | ----------: | ----------: |
| `@point0/core` | the framework itself, without the socket feature | 223.1 KB | 56.6 KB | 48.5 KB |
| `@point0/core` + sockets | optional — the same core with sockets on (`server: { socket: true }`) | 303.6 KB | 75.1 KB | 63.5 KB |
| `@point0/react-dom` | React/DOM bindings — `mount` and the router | 14.0 KB | 5.1 KB | 4.6 KB |
| `@1gr14/route0` | peer — typed routes and URL building | 43.4 KB | 12.7 KB | 11.5 KB |
| `@tanstack/react-query` | peer — the cache every loader rides on | 47.0 KB | 13.7 KB | 12.5 KB |
| `wouter` | peer — history and route matching | 5.6 KB | 2.7 KB | 2.4 KB |
| `unhead` | peer — the `` | 20.2 KB | 7.8 KB | 7.1 KB |
| `@1gr14/error0` | optional peer — typed errors across the wire | 10.8 KB | 3.1 KB | 2.8 KB |
| **total** | everything above, minus the optional rows | **361.4 KB** | **99.7 KB** | **85.2 KB** |
## The rest of the framework
Five examples and one builder — that's how Point0 feels, not how big it is. The
same builder carries a complete framework. Points cover pages, layouts,
components, providers, queries, infinite queries, mutations, and actions. Their
methods cover validation with any schema library, middleware, context, loading
and error states, redirects, and the ``. Around the points: typed
navigation, SSR or a pure client app, React Server Components with streaming,
file uploads, OpenAPI generation, typed env, assets, MDX, events. And Point0
ships its own engine — a compiler, a dev server, a production build, a CLI,
testing helpers, and two MCP servers: one that knows your project, one that
knows the docs.
All of that, and the daily loop stays fast — every number below comes from an
open benchmark repo ([Benchmarks](benchmarks)). HMR lands an edit in the DOM in
~15 ms — 3× faster than Next.js, 11× faster than TanStack Start. At 500 pages
the production build finishes over 2× faster than both, and the per-edit
type-check stays flat where Next.js slows down 2.5×.
That's Point0: the scope of Next.js and TanStack Start, the simplicity of tRPC,
a DX no other framework has. Scaffold an app and feel it:
```sh
bun create point0-app@latest
```
## Where next
- [Getting Started](getting-started) — scaffold an app with one command and run
it.
- [Full Overview](full-overview) — the whole framework in one long read, every
major feature as a code example.
- [Benchmarks](benchmarks) — how Point0 measures against Next.js and TanStack
Start, from an open benchmark repo.
- [Points](points) — the one building block behind pages, layouts, queries, and
mutations.
---
# Point0 — Getting Started
Source: https://1gr14.dev/point0/v0/getting-started.md
One command is the whole setup. It scaffolds a complete app — SSR, Prisma +
SQLite, Tailwind, type-safe routing — installs dependencies, and runs codegen:
```sh
bun create point0-app@latest
# prompts for a name and a bundler (Bun or Vite), installs deps, runs setup
cd my-app
bun run dev # http://localhost:3001 (client) + http://localhost:3000 (server)
```
That's it — the app is running. You need Bun; the scaffolder offers to install
it if it's missing.
The home page ships a small playground: an `Idea` Prisma model, a query that
lists the rows, and a mutation with a form that adds one — `setup` seeds a few
ideas, so there's something to poke at right away.
The rest of the page is optional depth: [the scaffolder](#the-scaffolder),
[the commands you'll run every day](#everyday-commands), and
[manual setup](#manual-setup) — every file the scaffolder writes, for when you'd
rather wire the app by hand.
## The scaffolder
`bun create point0-app` resolves to the `create-point0-app` package by Bun's
`create-` convention. With no arguments it prompts interactively: project
name, bundler (`Bun` or `Vite`, default `Bun`), install now (default yes), and
override (asked only when the target directory is non-empty). Pass a name and
flags to skip the prompts:
```sh
bun create point0-app@latest my-app # name as a positional argument
bun create point0-app@latest my-app --vite # use Vite for the client bundle
bun create point0-app@latest my-app --no-install # scaffold only, don't install
bun create point0-app@latest my-app -I # non-interactive, take all defaults
```
It scaffolds into `/`. If that directory already exists and is not
empty, the run stops unless you pass `--override` (or confirm the prompt).
| Flag | Default | What it does |
| ---------------------------- | ----------------- | ----------------------------------------------------- |
| `[name]` | `my-app` | Directory name for the new app |
| `--vite` / `--no-vite` | `--no-vite` (Bun) | Bundle the client with Vite instead of Bun |
| `--install` / `--no-install` | `--install` | Run `bun install` + `bun run setup` after scaffolding |
| `-O, --override` | off | Allow writing into a non-empty target directory |
| `-I, --no-interactive` | off | Skip every prompt and use the defaults above |
### Bun is required
The scaffolder checks for Bun first. With `-I` and no Bun on `PATH`, it errors
out (`Bun is required but was not found in PATH`). Interactively, it offers to
install Bun globally with `npm install -g bun` before continuing.
`bun create point0-app` is the only supported entry point — there's no `npx` /
`pnpm dlx` / `yarn create` equivalent. Those would resolve the
`create-point0-app` bin, but both the scaffolder and the app it writes run on
Bun, so they'd dead-end the moment you tried to use the result.
### TypeScript 6 or 7 — both work
Point0 never loads TypeScript. `dev`, `build` and `generate` don't touch the
compiler API: the Point0 compiler parses your code with Babel and reads the
`paths` aliases out of `tsconfig.json` itself. So the TypeScript in your project
is yours alone — 6, 7, or none at all, the framework behaves identically.
The scaffold pins TypeScript 7, and `bun run types` runs its `tsc`.
Staying on 6 is a normal choice, and one tool makes it the right one:
**typescript-eslint's type-aware linting doesn't run on TypeScript 7** — 7.0
ships no compiler API, and typescript-eslint needs one. Support is waiting on
the stable API in 7.1. If you lint with `projectService` or `project`, keep
`"typescript": "^6.0.3"` and add the new compiler under a second name:
```json
{
"devDependencies": {
"typescript": "^6.0.3",
"@typescript/native": "npm:typescript@^7.0.2"
}
}
```
`tsc` then resolves to TypeScript 7 while `import 'typescript'` still hands
typescript-eslint the API it needs. This is exactly how the Point0 repo itself
is set up. Don't alias the `typescript` name to `@typescript/typescript6` —
under Bun the alias resolves back into itself and you end up with an empty API.
### Bun or Vite — pick at scaffold time
The `--vite` choice changes which files you get:
- **Bun** (default) — Bun bundles the client; Tailwind comes from
`bun-plugin-tailwind`, declared as `bunPlugins` in `engine.ts`. No
`vite.config.ts`.
- **Vite** — Vite bundles the client via a `viteConfig` block in `engine.ts` and
a `vite.config.ts`; CSS is linked from `index.html`; `preload.ts` gets
`preventLoadBunPlugins: true`.
You can't have both — `engine.ts` carries `viteConfig` _or_ `bunPlugins`, never
both. The trade-offs are in [bun-vs-vite](bun-vs-vite); the Vite walkthrough is
[vite](example-vite).
## Everyday commands
Four scripts you'll live in:
```sh
bun run dev # point0 dev --hot — dev server + client, server hot reload
bun run setup # migrate the SQLite DB, generate Prisma + point0 code, seed
bun run build # point0 build → dist/
bun run start # NODE_ENV=production bun run ./dist/server/index.server.js
```
With `--install` (the default) the scaffolder already ran `bun install` and
`bun run setup`, so `bun run dev` works right away. Run the chain yourself after
`--no-install`, a fresh clone, or a database reset:
```sh
bun install
bun run setup
bun run dev
```
`bun run setup` is the important one. `src/generated/` (the Prisma client and
point0's points / routes / assets) is **gitignored** — `setup` produces it.
Without it, typecheck fails and the app won't run.
> **Why `setup`, not `prepare`?** A `prepare` script runs on every `bun install`
> — which breaks installs where no database exists yet (CI, a monorepo). So the
> codegen + DB step is named `setup` and run explicitly, not on install.
There is no `point0 start` command — production runs the built server entry
directly; see [deploy](deploy). `point0 dev` and `point0 build` are thin
wrappers over `engine.dev()` / `engine.build()` — full flag lists in [cli](cli),
[dev](dev), and [build](build). `--hot` hot-swaps edited points without
restarting the server; it's marked **experimental** in the CLI help.
### No leaked dev servers
Every process in the dev tree dies with whatever launched it: the `point0` CLI
spawns each dev child with Bun's `--no-orphans` flag (inherited by nested bun
processes), and the scaffolded `bunfig.toml` sets `[run] noOrphans = true` so
the `bun run dev` wrapper itself dies with the terminal. Close the terminal and
the dev server (and all its children) exits with it — no orphaned servers
holding ports.
`--no-orphans` landed in **Bun 1.3.14**, and Point0 itself is built and tested
on **Bun 1.4** — the generated `package.json` declares
`"engines": { "bun": ">=1.4.0" }`. On an older Bun the dev server still runs,
but it can leak orphaned processes that keep holding ports; upgrade with
`bun upgrade`.
## Manual setup
Everything below is what the scaffolder writes for you — **skip this section if
you used `bun create point0-app`.** Point0 is just a set of packages, so you can
assemble an app from a handful of files. Each one is cited from the template so
you can copy it.
### The package.json scripts
The four core scripts map directly to the `point0` CLI:
```jsonc
{
"scripts": {
"dev": "point0 dev --hot", // dev server + clients, server hot reload
"generate": "point0 generate", // write src/generated/point0/* from your points
"build": "point0 build", // build server + clients into dist/
"start": "cross-env NODE_ENV=production bun run ./dist/server/index.server.js",
},
}
```
The supporting scripts complete the setup chain — Prisma helpers, a seed, and
typecheck:
```jsonc
{
"scripts": {
"setup": "bun run prisma:migrate:deploy && bun run prisma:generate && point0 generate && bun run seed",
"seed": "cross-env NODE_ENV=development bun --preload ./src/preload.ts ./src/lib/seed.ts",
"prisma:generate": "cross-env NODE_ENV=development prisma generate",
"prisma:migrate:deploy": "cross-env NODE_ENV=development prisma migrate deploy",
"types": "tsc --noEmit",
},
}
```
`seed` runs with `--preload ./src/preload.ts` — any direct `bun src/` that
imports app code needs the preload first, so the point0 compiler plugins are
registered before that code loads (more on this below).
### `src/engine.ts` — the engine config
Everything starts from one `Engine.create(...)` call. It declares SSR, where to
find points, where to write generated code, and one config block per side
(`server`, `client`):
```ts
// src/engine.ts (Bun-bundled form)
import { Engine } from '@point0/engine'
import { clientEnvKeys } from '@/lib/env/shared'
export const engine = Engine.create({
file: import.meta.url, // engine anchors all relative paths to this file
ssr: true,
pointsGlob: '**/*.{ts,tsx,mdx}', // where points live
generate: {
meta: './generated/point0/meta.ts',
assetsTypes: './generated/point0/assets.d.ts',
},
server: {
scope: 'root',
port: process.env.SERVER_PORT || process.env.PORT,
entry: { main: './index.server.ts' },
points: async () => await import('./generated/point0/points.server'),
generate: { points: './generated/point0/points.server.ts' },
outdir: '../dist/server',
},
client: {
scope: 'root',
port: process.env.CLIENT_PORT,
indexHtml: './index.html',
app: async () => await import('./app.client'),
points: async () => await import('./generated/point0/points.client'),
generate: {
points: './generated/point0/points.client.ts',
routes: {
outfile: './generated/point0/routes.ts',
origin: 'process.env.CLIENT_URL',
},
},
bunPlugins: ['bun-plugin-tailwind'], // Vite mode replaces this with `viteConfig`
env: { vars: clientEnvKeys }, // which env keys are bundled into the client
publicdir: {
source: '../public',
outdir: '../dist/client',
},
outdir: '../dist/client',
},
})
```
Every option here has a page: the full schema is [engine-config](engine-config),
the codegen keys are [generator](generator), `publicdir` is
[publicdir](publicdir), and `env.vars` is [env](env). The `examples/basic`
engine adds one extra block —
`client.compiler.babel: ['babel-plugin-react-compiler']` — which the template
omits.
### The four entry files
Around the engine sit four small files. Order is load-bearing in two of them.
**`src/preload.ts`** — registers the point0 compiler plugins and env constants.
Anything that imports app code must run this first:
```ts
// src/preload.ts
import { engine } from '@/engine'
await engine.preload({ nodeEnvFallback: 'development' })
```
**`src/index.server.ts`** — the server entry (this is what `start` runs in
production, as `dist/server/index.server.js`). Preload before app code, via
dynamic imports so the order holds:
```ts
// src/index.server.ts
await import('./preload.js')
await import('./app.server.js') // imports @/engine and calls engine.serve()
export {}
```
`app.server.ts` validates the server env, then calls `await engine.serve()`. See
[engine-runtime](engine-runtime) for `serve()` and the rest of the engine
instance.
**`src/app.client.tsx`** — the React app shell, default-exported. It nests the
providers your points expect (react-query, unhead) and mounts the router:
```tsx
// src/app.client.tsx
import { Router, RouterRoutes } from '@/lib/navigation'
import { UnheadProvider } from '@point0/core/unhead'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/query-client'
import { Head } from '@unhead/react'
export default function App() {
return (
{/* global head tags; per-page ones come from `.head(...)` on each point */}
)
}
```
**`src/index.client.tsx`** — the browser entry. It hands your `App` and the
generated points to `mount` from `@point0/react-dom/mount`:
```tsx
// src/index.client.tsx
import { clientEnv } from '@/lib/env/client' // first import — see below
import App from '@/app.client'
import points from '@/generated/point0/points.client'
import '@/styles/index.css'
import { ErrorBoundary } from '@/ui/error-boundary'
import { mount } from '@point0/react-dom/mount'
clientEnv.validate() // whole client shape at once, so a misconfigured build fails fast
mount(
,
points,
)
if (import.meta.hot) {
import.meta.hot.accept()
}
```
The env import stays **first**: in dev `client.ts` rewrites `SERVER_URL` to
`CLIENT_URL` at module scope, the env getters cache on first read, and
`src/lib/root.tsx` reads `sharedEnv.SERVER_URL` while it loads. Move the import
down and the root point captures the old value.
`mount` mounts into `#root` by default (it throws if `#root` is missing) and
hydrates from the SSR-dehydrated store when one is present. `index.html` is the
HTML template the client builds from — a `#root` div plus a
`
```
```ts
// index.client.tsx
// this file is never seen by the server, so here we can safely validate
// the client envs. Otherwise it is similar to a standard React application.
import App from '@/app.client'
import points from '@/generated/point0/points.client'
import '@/styles/index.css'
import { ErrorBoundary } from '@/ui/error-boundary'
import { mount } from '@point0/react-dom/mount'
// the mount function is responsible for finding the data received from SSR, in particular
// the Query Client Dehydrated State, and hydrating the application on top of it.
mount(
,
// By passing the points here, they become globally available to the application
points,
)
```
```tsx
// app.client.tsx
import { Router, RouterRoutes } from '@/lib/navigation'
import { UnheadProvider } from '@point0/core/unhead'
import { QueryClientProvider } from '@tanstack/react-query'
import { NProgress } from '@/components/other/nprogress'
import { Toaster } from '@/components/ui/sonner'
import { ThemeProvider } from '@/components/ui/theme'
import { ErrorPageComponent } from '@/components/other/error'
import { queryClient } from '@/lib/query-client'
import { Head } from '@unhead/react'
export default function App() {
return (
{/* UnheadProvider is a mandatory dependency, our .head() in points works through it.
You can also use the standard unhead helpers useHead(), useSeoMeta(), in places where you need them */}
{/* declare everything in the head related to assets here, not in index.client.html
otherwise bun will break their urls and try to make them its own assets, and we don't need that for the favicon
and the like */}
{/* The whole connection between page addresses and their components is known to the router because
in index.client.tsx we passed them into the mount function */}
(
)}
/>
)
}
```
Read more in the docs [about wiring](engine-runtime).
## SSR
I wanted to make it so that we don't feel at all whether ssr is enabled or not.
The code we write should look the same. Initially, when I was making `.loader()`
in a page, I thought I would return its result to the client by serializing it
and inserting the result into index.html. And at first that's what I did, but
then I came to the conclusion that it's better to just treat all of this as a
query, and simply return the dehydration result of the query client in
index.html. This way, essentially, whether the page was requested from the
server in SSR format, where there will be a dehydrated query client, or whether
the page was initially a bare index.html and the requests went to the server
already from the client, the result is the same.
Also, initially, before rendering the page, I would figure out which loaders it
and its layouts have, and call their code, to stuff it into the server query
client, so that the page would render without loading states. But then I made
`.with()`, into which you can stuff several queries that can wait for each
other's results. And then components also came along, which essentially are
completely unknown whether they are on the page or not.
In the end, the decision on SSR is as follows. I render the page the first time.
I look at the state of the server query client; if there are queries that belong
to point0, they are in pending status, I haven't seen them before in this
render, and they are enabled, then, knowing their `queryKey`, which contains
everything needed to understand which point the query belongs to, I simply fetch
this query directly on the server, turning the status of this query either into
success or into error, doesn't matter. Then I render the page again. And so on
in a loop, until there are no unresolved queries left. In practice this comes
out to 2–4 re-renders per request.
This discover loop ships the page whole — the server waits until nothing is
pending and sends the finished HTML. And I consider that the right default: you
turned SSR on to get the finished page in the first response. But sometimes one
query is just slow, and it shouldn't hold everything else back. So on top of the
same model there is streaming. Mark a query with `suspend: 'server'` and the
server stops waiting for it: the loader starts immediately, the shell of the
page goes out with that block's `.loading()` fallback in place, and when the
loader resolves, React streams the real content into the SAME response —
together with a tiny inline script that puts the result into the client's query
cache, so after hydration there is no refetch and no flicker:
```tsx
export const StatsComponent = root.lets
.component()
// fallbacks go ABOVE the query — the closest .loading() declared above it is
// the boundary that streams first
.loading(() => )
.loader(async () => ({ stats: await computeSlowStats() })) // ~2 seconds
.query({ suspend: 'server' })
.component(({ data }) => )
```
The important part: this is not a separate mode you switch the app into — it
composes with the discovery loop. Queries you didn't mark keep blocking as
before, the marked ones stream, and since `suspend` is an ordinary query option,
it merges like everything else — `.queryOptions({ suspend: 'server' })` on the
root or a layout turns the whole subtree streaming-first with one line. The
default `suspend: 'auto'` also knows to suspend by itself when waiting is no
longer possible — a query that only appears under an already-streamed block, or
one the loop didn't get to — so nothing ever ships as a dead pending state.
And for those who like honest suspense there are `useSuspenseQuery` and
`useSuspenseInfiniteQuery` with the TanStack semantics: `data` is non-optional
in types, a pending query suspends into the nearest `.loading()` declared above
it, an error throws to the nearest `.error()`. On the server the fallback ships
and the content streams in; on the client they suspend on navigations and fresh
inputs. If you want the earliest possible shell, there is also
`ssr: { allowedDiscoveryRenders: 0 }` — skip the discovery loop entirely, ship
the shell with the first bytes, and let every query stream in as it resolves.
You have to understand that SSR actually only happens on the first request of a
page; afterwards, when navigating, we no longer request html, we only download
the js chunk of the page itself, plus request the necessary data and insert it
into the query client. And to control this we have all these options in
`.prefetchPageOnNavigate('pageDehydratedStateAndClientQuery')`.
With the `pageDehydratedStateAndClientQuery` policy, before navigating to a page
we will ask the server to render the page in its head, to assemble a resolved
query client, and return to us only the result of its dehydration. Upon
receiving the dehydration result on the client, we simply slip it into the
client query client. Also, with this policy, all found `.clientLoader()` will be
called already on the page itself. For us as developers, this is the most
convenient option in terms of DX. Because then we know for sure that all the
needed queries will be assembled. But we pay for this with server re-renders. I
don't see anything terrible about this at all, but I understand those who would
like to avoid re-renders, and for that we also have a solution.
There is the `serverAndClientQuery` policy; in that case we won't render at all
when moving from one page to another, but just look at the loaders of the
layouts and the page, and request only those. But this way, if queries were
declared in `.with()`, or in some components inside the page, then they won't be
discovered, and after navigating to a page loaded in this way, we will see
loading states in such places.
When using the `serverAndClientQuery` policy, to avoid loading in those places,
we can specify `.onPrefetchPage()` in the page or layout point:
```tsx
export const IdeaBestComponent = root.lets
.component()
.loader(async () => {
const bestIdea = await prisma.idea.findFirst({
orderBy: {
rating: 'desc',
},
})
})
.component(({ data: { bestIdea } }) => (
))
```
So if it's important for you to avoid server re-renders, just use
`.prefetchPageOnNavigate('serverAndClientQuery')` and write everything you need
in `.onPrefetchPage()`.
And the same `.onPrefetchPage()` solves the first visit to a page via a direct
link too — there's nothing extra to turn on. It runs on the server once before
the first render, so whatever you warmed there is already in the cache when
rendering starts, collapsing the discover loop. And if your loaders are
predictable, you don't even have to write the warm-up by hand — flip on
`prefetchLoadersBeforePageRender` and Point0 prefetches the page's and layouts'
declared loaders up front for you. Add `allowedDiscoveryRenders: 1` to also stop
the store/cookie stabilization re-renders:
```ts
export const engine = Engine.create({
// ...
ssr: {
prefetchLoadersBeforePageRender: true,
allowedDiscoveryRenders: 1,
},
})
```
`prefetchLoadersBeforePageRender` only prefetches queries declared as
`.loader()` — their inputs come from the route, so they're always right. Queries
you injected with `.with()` take render-time inputs and are still discovered by
the render loop, so warm those in `.onPrefetchPage()` if you need them up front.
This way you have to understand that the code declared in `.onPrefetchPage()`
can be called both on the server and on the client. But there's no problem in
still writing `ideaViewQuery.prefetchQuery({ id: location.params.id })` there;
on the client the request will go to the server's address on the internet. And
if it is called on the client, then the same request, bypassing the network,
will go directly to `engine.fetch(request)` with all the headers, cookies, and
so on from the original client request preserved. (Need a warm-up that lives on
just one side, `.serverOnPrefetchPage()` / `.clientOnPrefetchPage()` are the
same hook with the other bundle's body stripped out.)
But honestly, I like just re-rendering many times, it's very convenient. I think
that if a project doesn't have a giant load, you won't even feel it. And if a
load appears, you can always add `.onPrefetchPage()` in especially sensitive
places and change the policy to
`.prefetchPageOnNavigate('serverAndClientQuery')`, without changing the rest of
the code.
We also have
`.on('engineFetchSettled', (event) => console.log(event.data.request.renders))`
which will tell you how many re-renders happened. You can set up metrics, and
fix things precisely in those places where there are many re-renders.
Read more in the docs [about SSR](ssr).
## RSC
RSC here comes out of one decision, the same way everything else does: **a React
element is just a value in the loader's data.** A `.loader()` can return JSX
right next to numbers and strings; it travels to the client through the same
[data transformer](transformer) as every other value and lands in `data` as a
live element. No Flight protocol, no second `react-server` module graph, no
`'use client'` / `'use server'` directives, nothing to decode on the client. And
because an element is just data, RSC works everywhere data already works — SSR,
hydration, the query cache, refetches — with no RSC-specific code.
An element in loader data is one of two things. A **plain function component**
is a _server component_: Point0 calls it on the server right after the loader
(async is fine), and only its rendered host markup travels — its code (Prisma, a
markdown renderer, a heavy layout) never reaches the browser. A **component
point** is an _interactive island_: in the data it serializes as a reference —
its name — and its props as data; on the client the real component mounts from
the points collection, with hooks, state and handlers; its code ships to the
browser, and — when the point lives in its own file — as a separate chunk
fetched only when a payload mentions it (code-splitting is by file, not by
point). The security story falls out of this: the server sends names, never
code, and client input is parsed with the raw transformer, so an element marker
arriving in an input stays inert JSON — the server never turns client bytes into
components.
Streaming composes with the SSR loop above. `suspend: 'server'` streams a slow
_query's data_; **`defer`** streams a slow _server component's markup_ — the
same in-tree property the Flight stacks are proud of, without Flight. Wrap a
slow subtree in `defer` and the loader returns at once: a hole ships under a
`Suspense` boundary with a fallback, and the resolved markup is pushed into the
same response as it settles — on the first SSR load and on client fetches
(navigation, mutations, refetches) alike.
```tsx
export const postPage = root.lets
.page('/posts/:slug')
.loader(async ({ params }) => ({
title: await getTitle(params.slug), // fast — in the shell
article: defer(, ), // slow — streams in
}))
.page(({ data }) => (
{data.title}
{data.article}
))
```
The one honest caveat: an interactive island _inside_ a `defer` hole is live on
every client fetch, but on the very first SSR paint it displays without its
handlers — for first-paint interactivity keep the island top-level or give it
its own `suspend: 'server'` query. The full picture — the wire format, error
handling, and the live-where matrix — is in the docs [about RSC](rsc).
## Sockets and subscriptions
Queries and mutations are request/response. But sometimes the server has to
speak first — a long task's progress, a live feed, a chat message someone else
just sent. Point0 has two machines for that, from the cheap end up.
A **subscription** is a loader that's an async generator: every `yield` streams
to the client the moment it happens, over plain HTTP — no WebSocket, no rooms,
nothing to keep alive. The data type is just the union of the yields; nothing is
declared.
```tsx
export const taskProgressSubscription = root.lets
.subscription()
.input(z.object({ taskId: z.string() }))
.loader(async function* ({ input, signal }) {
// the generator runs on the server — stripped from the client bundle
for await (const percent of watchProgress(input.taskId, { signal })) {
yield { percent } // one streamed value per yield
}
})
.subscription()
export const TaskProgress = ({ taskId }: { taskId: string }) => {
const { data } = taskProgressSubscription.useSubscription(
{ taskId },
{ lastMessageFromServerAsData: true }, // keep the latest message in `data`
)
return
}
```
And when the flow starts from outside — a push to many clients, two-way —
there's the **channel**: one WebSocket per client. Its `.connector` runs like a
loader and establishes the connection's server-held identity — keep the channel
open (`userId: null` marks a guest) and gate where the action runs. A **space**
grows rooms from the channel — the client joins, the server's `.joiner` decides
which rooms it enters. Handlers are the typed messages that ride the socket: a
serverHandler is client → server (the client sends, the server answers), a
clientHandler is server → client (the server pushes to a room, subscribed
components wake). One socket carries everything; joins and messages are cheap
frames on it.
```tsx
export const appChannel = root.lets
.channel()
.connector(async ({ request }) => {
const me = await readSession(request)
return { userId: me?.userId ?? null } // this connection's identity; null = a guest
})
.channel()
export const chatSpace = appChannel.lets
.space<{ chatId: string }>() // the room shape, declared at the opener
.input(z.object({ chatId: z.string() }))
.joiner(({ input }) => ({ chatId: input.chatId })) // which room you enter
.space()
// client → server: check the identity, save the message, push it to the whole room
export const messageSend = chatSpace.lets
.serverHandler()
.clientSend(z.object({ text: z.string().min(1) }))
.serverReply(async ({ input, identity, room }) => {
if (identity.userId === null) {
throw new AppError('Sign in to send messages', { code: 'UNAUTHORIZED' })
}
const message = await createMessage(
room.chatId,
identity.userId,
input.text,
)
void messageAdded.sendToClient(message, { room }) // fan out to the room
return message
})
.serverHandler()
// server → client: subscribed components wake on every push
export const messageAdded = chatSpace.lets
.clientHandler()
.serverSend(messageSchema)
.clientHandler()
export const ChatRoom = ({ chatId }: { chatId: string }) => {
const membership = chatSpace.useMembership({ chatId }) // join the room while mounted
const [messages, setMessages] = useState([])
messageAdded(membership).useOnMessageFromServer(({ message }) => {
setMessages((prev) => [...prev, message])
})
const send = (text: string) => messageSend(membership).sendToServer({ text })
// an at the app root holds the one socket every room rides
return
}
```
Across several processes behind a load balancer, the rooms and pushes ride a
Redis-shaped socket backplane you plug into the engine config — a `redis://`
URL, a ready-made adapter (Postgres over `LISTEN`/`NOTIFY`, ioredis,
node-redis), or any KV + pub/sub you bring. Channels, spaces, and handlers are
points like the rest — same validation, transformer, events, and stripping.
Sockets are an **optional feature**, and optional here means what it says: turn
`socket` on and you get them, leave it alone and the client build never carries
them. The compiler folds every socket body in `@point0/core` to a throw and
`@point0/core/socket` — the client socket runtime and the wire protocol — simply
is not in the bundle, some 52.5 KB raw / 13 KB gzip an app without sockets never
downloads — the gap between the two `@point0/core` rows in the size table above.
Read more in the docs [about subscriptions](subscription),
[about channels, spaces & handlers](socket).
## SsrStore
And since we can do server-side re-rendering, let's go ahead and introduce a
server-side store right here too. I doubted whether it could even be useful at
all. But then I ran into this situation.
```tsx
import { create } from 'zustand/react'
const useBreadcrumb = create<{
items: Array<[string, string]>
setItems: (items: Array<[string, string]>) => void
}>((set) => ({
items: [],
setItems: (items) => set({ items }),
}))
export const adminLayout = root.lets
.layout('/admin')
.layout(({ children }) => {
const items = useBreadcrumb((state) => state.items)
return (
)
})
```
It all works in general, but after the first render of the page, my breadcrumbs
were empty! And they only loaded once the js had loaded and they were computed.
I realized that we need an SsrStore. You might think it isn't worth it, and that
the issue could have been solved some other way. But I think we'll still find
good uses for SsrStore, so let these breadcrumbs just be an excuse to create
SsrStore.
```tsx
import { SsrStore } from '@point0/core/ssr-store'
import { useEffectSsr } from '@point0/core'
export const $breadcrumb = SsrStore.define>(
// name of the item in the ssr store
'breadcrumb',
// function that returns the default value
() => [],
)
export const adminLayout = root.lets
.layout('/admin')
.layout(({ children }) => {
const items = $breadcrumb.use()
return (
)
})
.layout()
export const adminUsersPage = adminLayout.lets
.page('/users')
.page(({ data: { idea } }) => {
// this is a regular effect, it's just that during ssr it will be called instantly,
// and on the client according to the rules of a normal useEffect
useEffectSsr(() => {
$breadcrumb.set([
['Dashboard', '/admin'],
['Users', '/users'],
])
}, [])
return (
{idea.title}
)
})
```
Here's how it all works. We render the page. If the serialized value of the
store has changed, we re-render, and so on until it stabilizes. That is, you
shouldn't stuff a new Date() in there, or anything that fundamentally doesn't
serialize. Then the value is baked into index.html. And on the client, when the
breadcrumbs are requested, we already know the final computed value, and the
breadcrumbs are visible right away.
Note that the connection here is one-way. Data goes from the server to the
client, but doesn't go from the client to the server. When you use the store on
the client, it works just like a regular store. The connection in SsrStore is
one-way on purpose, because for a two-way connection between client and server
we use CookieStore.
I'll also note that you don't have to use SsrStore at all; if you don't need it,
it won't even make it into the client bundle.
Read more in the docs [about SsrStore](ssr-store).
## CookieStore
Generally, we can work with cookies even without CookieStore. Let me first show
how we work with them at a basic level:
```tsx
// auth — your authorization helpers
// that is, not part of the framework, but your code
import auth from '@/lib/auth'
export const signInMutation = root.lets
.mutation()
.input(z.object({ email: z.string(), password: z.string() }))
.loader(async ({ input, set }) => {
const { token, user } = await auth.signIn(input)
set.cookies('token', token, {
httpOnly: true,
})
return { user }
})
export const updateProfileMutation = root.lets
.mutation()
.input(z.object({ name: z.string() }))
.loader(async ({ input, request, set }) => {
const token = request.cookies['token']
const { user } = await auth.verifyToken(token)
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: { name: input.name },
})
return { user: updatedUser }
})
.mutation()
```
And for authorization things, it seems like it even works fine this way, but it
turns out we have to remember which key we wrote which cookie under. And
besides, I usually do authorization through better-auth, where it manages all of
this itself. Nevertheless, all of this could optionally be replaced with
CookieStore.
```tsx
const $token = CookieStore.define({ name: 'token', httpOnly: true })
export const root = Point0.lets
.root()
// ...
.plugin(CookieStore.plugin())
// ...
.root()
export const signInMutation = root.lets
.mutation()
.input(z.object({ email: z.string(), password: z.string() }))
.loader(async ({ input, set }) => {
const { token, user } = await auth.signIn(input)
$token.set(token)
return { user }
})
export const updateProfileMutation = root.lets
.mutation()
.input(z.object({ name: z.string() }))
.loader(async ({ input, request, set }) => {
const token = $token.get()
const { user } = await auth.verifyToken(token)
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: { name: input.name },
})
return { user: updatedUser }
})
.mutation()
```
Here you may notice that we don't pass the request itself into $token, which you
would seemingly need in order to get the cookies. This happens because
CookieStore can pull the request from the environment itself, since the request
is stored in node async storage, which we use under the hood to wrap all the
code before the request starts executing.
Let's look at how to use CookieStore for cookies that are also used on the
client. For example, a dark or light theme.
```tsx
import { useHead } from '@unhead/react'
import { CookieStore } from '@point0/core/cookie-store'
type ColorMode = 'dark' | 'light'
export const $colorMode = CookieStore.define('color-mode')
// Use it as a theme toggle button
export const ThemeSwitcher = () => {
const colorMode = $colorMode.use()
return (
)
}
// Stick this into app.client.tsx
export const ThemeProvider = () => {
const colorMode = $colorMode.use()
useHead({
htmlAttrs: {
class: {
dark: colorMode === 'dark',
light: colorMode === 'light',
},
},
})
return null
}
```
Now the server sends us html that already has the dark or light class, depending
on which color mode we set in the cookie. And on the client it works like a
regular reactive store.
CookieStore has more settings, and the ability to store non-primitives, as well
as to apply a transformer like superjson to them. Read more in the docs.
Like SsrStore, CookieStore is also an optional component, and if you don't use
it, it won't be included in the client bundle.
Read more in the docs [about CookieStore](cookie-store).
## Testing
Like any ordinary fullstack application, we can test it with playwright. To do
that we bring the app up in a dev environment, or build it and run it. Then it's
just regular playwright tests.
If you want to write integration tests without bringing up a server to test
endpoints, you can do it like this:
```ts
// src/test/setup/preload.int.test.ts
import { engine } from '@/engine'
await import('@/preload')
// This function makes the engine read the points loaded into it.
// In a normal server, when we call engine.serve(), this function is called automatically.
// But in integration tests we don't want to bring up a server, so we call this function manually.
// not to be confused with engine.preload(), these are different functions
await engine.prepare()
export {}
```
```
// bunfig.toml
// in reality it's better to have a separate ./src/test/setup/preload.ts
// which, depending on the test file's extension, will use the appropriate preload
// but for the example this will do for now
[test]
preload = ["./src/test/setup/preload.int.test.ts"]
```
```ts
// src/idea/api.ts
describe('ideaViewQuery', () => {
test('returns one idea by id', async () => {
const user = await createTestUser()
const created = await seedIdea({ authorId: user.id, title: 'Viewable' })
// engine.withFetch is a wrapper over node async storage, which lets you swap
// the fetch for the points under the hood with engine.fetch
const result = await engine.withFetch(async () => {
return await ideaViewQuery.fetchServer({ id: created.id })
})
// here result is correctly typed, and is what the query's loader returned
expect(result.idea.title).toBe('Viewable')
expect(result.idea.author.id).toBe(user.id)
})
})
```
Read more in the docs [about testing](testing).
## For fullstack developers, backenders, and frontenders alike.
Even though Point0 is a fullstack framework, nothing stops you from using it as
a frontend-only framework, or as a backend-only framework.
Frontenders can use navigation and the `.with()` helpers to manage the state of
their pages and components. They can write a BFF using ordinary
queries/mutations, or use client loaders to request data from a third-party
server.
If you are a backender and you only care about the API, then you can use actions
alone, with convenient OpenAPI generation, typed `.ctx()`, and tests without
spinning up a server.
Read more in the docs [about points](points).
## Bun or Vite
At first I wanted to make it specifically a Bun framework. And it was going
well. Then problems of all kinds started, and I thought I should probably allow
using Vite as an optional dependency. I hooked up Vite. Again there were
problems, but in the end Vite started working better than pure Bun inside
Point0. Then I gathered my strength and finished configuring Bun. And in the end
Bun started working better than Vite inside Point0. Bun starts faster, HMR works
better. How I fought with Bun and Vite is a topic for a separate post.
In the end the design turned out such that if one of the bundlers doesn't suit
you, you can switch to the other by changing a couple of files, and the runtime
behavior stays the same.
If you need to configure some build options for Bun, just add them in engine.ts:
```ts
import { Engine } from '@point0/engine'
import react from '@vitejs/plugin-react'
import tailwindcss from 'tailwindcss/vite'
export const engine = Engine.create({
// ...
bunBuildConfig: ({ side, mode, scope }) => ({
// the standard Bun.buildConfig can be overridden here
// mode - production | development | test depending on process.env.NODE_ENV
// side - server | client depending on what we are building
// scope - if you have several clients, here will be the name of the client/server root
}),
client: {
// bunBuildConfig: {}
// you can override here as well
},
server: {
// bunBuildConfig: {}
// you can override here as well
},
})
```
If you want to switch from Bun to Vite, you need to pass `viteConfig` into
`engine.ts`. This replaces Bun with Vite in both build and dev mode.
```ts
import { Engine } from '@point0/engine'
import react from '@vitejs/plugin-react'
import tailwindcss from 'tailwindcss/vite'
export const engine = Engine.create({
// ...
viteConfig: ({ plugins, side, mode }) => ({
// no additional settings are needed, they are inserted automatically
// based on the other engine settings, but they can be overridden here.
// This is a regular Vite config object.
plugins: [
...plugins, // the Point0 compiler plugin is already here
react(),
tailwindcss(),
],
// using side (client|server) you can override some settings
// for the client or the server
}),
client: {
// viteConfig: {}
// you can override here as well
},
server: {
// viteConfig: {}
// you can override here as well
},
})
```
Read more in the docs [about Bun or Vite](bun-vs-vite).
## Deploy
`point0 build` bundles everything into `dist/`: `dist/server` (the server) and
`dist/client` (the client bundle, static assets). Then we simply start the
server: `bun run ./dist/server/index.server.js`, which also serves the client.
```dockerfile
FROM oven/bun:1
WORKDIR /app
COPY . .
RUN bun install && bun run build
CMD ["bun", "run", "./dist/server/index.server.js"]
```
That is, you can deploy absolutely anywhere, there is nothing specific here.
Read more in the docs [about deploy](deploy).
## Size
What Point0 adds to your client bundle.
Every package a Point0 app ships to the browser, measured as npm delivers it:
bundled, minified, with `react` and `react-dom` left out because you pay for
those either way. Each row imports the whole surface of its package, so nothing
tree-shakes away — these are ceilings, not best cases. The socket is the one
opt-in: core is measured the way an app that never sets
`server: { socket: true }` compiles it, and the row under it is the same core
with the feature on. **Total** is one bundle holding every non-optional row at
once: what the browser actually downloads.
| package | role | raw | gzip | brotli |
| ------------------------ | --------------------------------------------------------------------- | -----------: | ----------: | ----------: |
| `@point0/core` | the framework itself, without the socket feature | 223.1 KB | 56.6 KB | 48.5 KB |
| `@point0/core` + sockets | optional — the same core with sockets on (`server: { socket: true }`) | 303.6 KB | 75.1 KB | 63.5 KB |
| `@point0/react-dom` | React/DOM bindings — `mount` and the router | 14.0 KB | 5.1 KB | 4.6 KB |
| `@1gr14/route0` | peer — typed routes and URL building | 43.4 KB | 12.7 KB | 11.5 KB |
| `@tanstack/react-query` | peer — the cache every loader rides on | 47.0 KB | 13.7 KB | 12.5 KB |
| `wouter` | peer — history and route matching | 5.6 KB | 2.7 KB | 2.4 KB |
| `unhead` | peer — the `` | 20.2 KB | 7.8 KB | 7.1 KB |
| `@1gr14/error0` | optional peer — typed errors across the wire | 10.8 KB | 3.1 KB | 2.8 KB |
| **total** | everything above, minus the optional rows | **361.4 KB** | **99.7 KB** | **85.2 KB** |
## Examples
The repository has several examples:
- **basic** — a collective blog of ideas: SSR, Prisma + SQLite, Tailwind,
navigation, pages/layouts/queries/mutations/components, an MDX page, file
upload, OpenAPI
- **vite** — the same app, but the client is built with Vite instead of Bun.
- **better-auth** — the same collective blog, but with authorization hooked up
through better-auth
- **socket** — a chat, a collaborative board, presence, and notifications built
on channels, spaces, and handlers: a resumable channel, an enroller-only
personal space, the presence recipe, and real-browser e2e tests
- **capacitor** — packaging the web app into a mobile app (iOS/Android) via
Capacitor. (experimental)
- **expo** — React Native via Expo: a single server on Bun, shared
query/mutation code, and a native client with the Expo router. The server code
is stripped from the bundle by the compiler's Babel plugin. (experimental)
But so you don't have to go far, I'll show one more piece of code right here,
from my production-ready boilerplate Start0, so that you can get a feel for the
code of a real project:
```ts
// src/features/idea/api.ts
import { paginateCursor } from '@/components/blocks/pagination'
import { AppError } from '@/lib/error'
import { root } from '@/lib/root'
import { zz } from '@/lib/schema'
import { authorizedOnlyPlugin } from '@/modules/auth/plugins'
import { prisma } from '@/modules/prisma'
import { ideaSelect, normalizeIdeaPayload } from '@/features/idea/server'
import { z } from 'zod'
export const ideaListQuery = root.lets
.infiniteQuery()
.input(
z.object({
...zz.shape.paginationCursor,
authorSn: zz.sn.optional(),
}),
)
.loader(async ({ input: { limit = 20, cursor, authorSn } }) => {
const items = await prisma.idea.findMany({
select: ideaSelect,
orderBy: { sn: 'desc' },
take: limit + 1,
where: {
...(authorSn ? { author: { sn: authorSn } } : {}),
...(cursor ? { sn: { lte: cursor } } : {}),
},
})
return paginateCursor({
items: items.map(normalizeIdeaPayload),
limit,
cursorKey: 'sn',
})
})
.infiniteQuery({
getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
initialPageParam: undefined,
pageParamFromInput: 'cursor',
})
export const ideaViewQuery = root.lets
.query()
.input(zz.object.sn)
.loader(async ({ input: { sn } }) => {
const idea = await prisma.idea.findUniqueOrThrow({
select: ideaSelect,
where: { sn },
})
return { idea: normalizeIdeaPayload(idea) }
})
.query()
export const ideaCreateMutationSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
})
export const ideaCreateMutation = root.lets
.mutation()
.use(authorizedOnlyPlugin)
.input(ideaCreateMutationSchema)
.loader(async ({ ctx, input: { title, content } }) => {
const idea = await prisma.idea.create({
select: ideaSelect,
data: { title, content, authorId: ctx.me.user.id },
})
return { idea: normalizeIdeaPayload(idea) }
})
.mutation()
export const ideaUpdateMutationSchema = z.object({
sn: zz.sn,
title: z.string().min(1),
content: z.string().min(1),
})
export const ideaUpdateMutation = root.lets
.mutation()
.use(authorizedOnlyPlugin)
.input(ideaUpdateMutationSchema)
.loader(async ({ ctx, input: { sn, title, content } }) => {
const existing = await prisma.idea.findUniqueOrThrow({
select: { authorId: true },
where: { sn },
})
if (existing.authorId !== ctx.me.user.id) {
throw new AppError('Only the author can edit this idea', {
code: 'FORBIDDEN',
})
}
const idea = await prisma.idea.update({
select: ideaSelect,
where: { sn },
data: { title, content },
})
return { idea: normalizeIdeaPayload(idea) }
})
.mutation()
```
```tsx
// src/features/idea/pages/list.tsx
import { InfiniteScroll } from '@/components/blocks/infinite-scroll'
import { Section } from '@/components/ui/section'
import { generalLayout } from '@/layouts/general'
import { IdeaCard } from '@/features/idea/components/idea-card'
import { ideaListQuery } from '@/features/idea/api'
import { mePlugin } from '@/modules/auth/plugins'
export const ideaListPage = generalLayout.lets
.page('/ideas')
.head('Ideas')
.use(mePlugin)
.page(({ props: { me } }) => {
const query = ideaListQuery.useInfiniteQuery()
return (
idea.sn}
empty="No ideas yet. Be the first to share one."
itemClassName="border-b border-border last:border-b-0"
renderItem={(idea) => }
/>
)
})
```
```tsx
// src/features/idea/pages/view.tsx
import { Button } from '@/components/ui/button'
import { Prose } from '@/components/ui/prose'
import { Section } from '@/components/ui/section'
import { routes } from '@/generated/point0/routes'
import { Link } from '@/lib/navigation'
import { zz } from '@/lib/schema'
import { formatDate } from '@/utils/date'
import { generalLayout } from '@/layouts/general'
import { ideaViewQuery } from '@/features/idea/api'
import { isMyIdea } from '@/features/idea/shared'
import { mePlugin } from '@/modules/auth/plugins'
export const ideaViewPage = generalLayout.lets
.page('/ideas/:sn')
.params(zz.object.sn)
.use(mePlugin)
.with(ideaViewQuery, ({ params }) => ({ sn: +params.sn }))
.head(({ params }) => `Idea #${params.sn}`)
.page(({ data: { idea }, props: { me } }) => {
return (
Edit idea
) : undefined
}
description={
{idea.author.name}
·{formatDate(idea.createdAt, 'date-time-nice')}
{idea.updatedAt > idea.createdAt ? (
· edited {formatDate(idea.updatedAt, 'date-nice')}
) : null}
}
>
{idea.content}
)
})
```
```tsx
// src/features/idea/pages/new.tsx
import { Section } from '@/components/ui/section'
import { routes } from '@/generated/point0/routes'
import { navigate } from '@/lib/navigation'
import { queryClient } from '@/lib/query-client'
import { generalLayout } from '@/layouts/general'
import { authorizedOnlyPlugin } from '@/modules/auth/plugins'
import { FButton } from '@/modules/form/core/button'
import { FFields, FFooter } from '@/modules/form/core/layout'
import { FForm } from '@/modules/form/core/provider'
import { FInput } from '@/modules/form/fields/input'
import { FTextarea } from '@/modules/form/fields/textarea'
import {
ideaCreateMutation,
ideaCreateMutationSchema,
ideaViewQuery,
ideaListQuery,
} from '@/features/idea/api'
import { getQueryPredicate } from '@point0/core'
export const ideaNewPage = generalLayout.lets
.page('/ideas/new')
.head('New Idea')
.use(authorizedOnlyPlugin)
.page(() => {
return (
{
const { idea } = await ideaCreateMutation.fetch({ title, content })
void queryClient.invalidateQueries({
predicate: getQueryPredicate({ id: ideaListQuery.id }),
})
ideaViewQuery.setQueryData({ sn: idea.sn }, { idea })
return { idea }
}}
onSuccess={({ idea }) => {
void navigate('ideaView', { sn: idea.sn }, { replace: true })
}}
toastOnSuccess="Idea published"
size="sm"
>
Publish
)
})
```
```tsx
// src/features/idea/pages/edit.tsx
import { Button } from '@/components/ui/button'
import { Section } from '@/components/ui/section'
import {
ideaListQuery,
ideaUpdateMutation,
ideaUpdateMutationSchema,
ideaViewQuery,
} from '@/features/idea/api'
import { routes } from '@/generated/point0/routes'
import { generalLayout } from '@/layouts/general'
import { navigate } from '@/lib/navigation'
import { queryClient } from '@/lib/query-client'
import { zz } from '@/lib/schema'
import { FButton } from '@/modules/form/core/button'
import { FFields, FFooter } from '@/modules/form/core/layout'
import { FForm } from '@/modules/form/core/provider'
import { FInput } from '@/modules/form/fields/input'
import { FTextarea } from '@/modules/form/fields/textarea'
import { getQueryPredicate } from '@point0/core'
export const ideaEditPage = generalLayout.lets
.page('/ideas/:sn/edit')
.params(zz.object.sn)
.with(ideaViewQuery, ({ params }) => ({ sn: +params.sn }))
.head(({ params }) => `Edit Idea #${params.sn}`)
.page(({ data: { idea } }) => {
return (
View idea
}
>
{
const { idea: updated } = await ideaUpdateMutation.fetch({
sn: idea.sn,
title,
content,
})
ideaViewQuery.setQueryData({ sn: updated.sn }, { idea: updated })
void queryClient.invalidateQueries({
predicate: getQueryPredicate({ id: ideaListQuery.id }),
})
return { idea: updated }
}}
onSuccess={({ idea: updated }) => {
void navigate('ideaView', { sn: updated.sn }, { replace: true })
}}
toastOnSuccess="Idea updated"
size="sm"
>
Save
)
})
```
```tsx
// src/features/idea/pages/my.tsx
import { InfiniteScroll } from '@/components/blocks/infinite-scroll'
import { Section } from '@/components/ui/section'
import { ideaListQuery } from '@/features/idea/api'
import { IdeaCard } from '@/features/idea/components/idea-card'
import { generalLayout } from '@/layouts/general'
import { authorizedOnlyPlugin } from '@/modules/auth/plugins'
export const myIdeaListPage = generalLayout.lets
.page('/my/ideas')
.head('My Ideas')
.use(authorizedOnlyPlugin)
.with(ideaListQuery, ({ props: { me } }) => ({ authorSn: me.user.sn }))
.page(({ queries: [query], props: { me } }) => {
return (
idea.sn}
empty="You haven't shared any ideas yet."
itemClassName="border-b border-border last:border-b-0"
renderItem={(idea) => }
/>
)
})
```
Read more in the docs [about the basic example](example-basic).
## Production
At the moment this article is published, there is only one project in the world
that uses the Point0 framework in production. It is my site https://1gr14.dev
I am not going to change the design of how you interact with the framework. I am
absolutely happy with everything related to the builder of the points
themselves. I refactored it dozens of times, over many months, until I arrived
at what we have now.
It is possible that the structure of the `engine` config will change a little,
but if that happens, the changes in your projects will essentially be needed
only in the `engine.ts` file.
I have no idea when one can confidently recommend that you use Point0 in
production. I just don't know what the criteria are here. I am now going to
actively use it to build my own projects, as well as projects for my clients.
I created a production-ready boilerplate for SaaS called
[Start0](https://1gr14.dev/start0), which is built around Point0. I connected
and configured everything needed for it to build real projects. The stack is as
follows:
- Bun
- TypeScript
- Point0
- React
- Tailwind CSS
- shadcn/ui
- PostgreSQL
- Prisma
- better-auth
- TanStack Query
- TanStack Table
- Zod
- React Hook Form
- Resend
- Sentry
- Mixpanel
- LogTape
- Playwright
- Testing Library
- ESLint
- Prettier
More details here: https://1gr14.dev/start0
As I build my own projects and get feedback from you, I will keep improving this
boilerplate.
## Plans
Right now the focus is on stability. I will respond as quickly as possible to
any [issue on GitHub](https://github.com/1gr14/point0).
I am going to write guide articles on Habr on a regular basis, and record videos
on [YouTube](https://www.youtube.com/@s_1gr14) and
[VK Video](https://vkvideo.ru/@s_1gr14). All announcements will be in the
[community](https://1gr14.dev/community) on [Telegram](https://t.me/s_1gr14)
(Russian) and [Discord](https://discord.gg/hWgtn58FVv) (English).
After that, I want to finish static site generation.
---
# Point0 — Benchmarks
Source: https://1gr14.dev/point0/v0/benchmarks.md
Every number on this page comes from the open benchmark repo:
**[github.com/1gr14/point0-benchmarks](https://github.com/1gr14/point0-benchmarks)**.
The setup: the same app — a small blog plus a SaaS-style dashboard — built four
times (Point0, Next.js, TanStack Start, React Router), each in its framework's
native idiom, rendering the same content from the same deterministic in-memory
store. A Playwright parity gate (22 assertions per app) must be green before any
number counts; results are medians over repeated runs; raw per-run JSON is
committed. This page is the short read — the repo README carries the full method
and all tables.
Measured on an Apple M1 Pro: Point0 0.3.13, Next.js 16.3.2, TanStack Start
1.168.27, React Router 8.3.0, React 19.2.8 everywhere. Each framework runs on
its own supported runtime — Point0 on Bun 1.4.0, the others on Node 22.21.1 —
and every load number sits next to a bare `Bun.serve` / `node:http` floor, so
runtime and framework tax stay separable.
In the tables, every competitor cell carries a small delta vs Point0:
green means that framework is better on the
metric, red means worse. Point0's own cells stay
bare.
## Summary
| | Point0 | Next.js | TanStack Start | React Router |
| ------------------------------------ | ------ | ------------------------------------------ | ------------------------------------------- | ------------------------------------------ |
| Runtime (as shipped) | Bun | Node | Node | Node |
| HMR, edit → DOM ↓ | 20 ms | 54 ms 2.7× | 175 ms 8.8× | 157 ms 7.9× |
| Dev start, warm ↓ | 972 ms | 1,924 ms +98% | 2,015 ms +107% | 1,263 ms +30% |
| Prod build, L (+500 pages) ↓ | 7.49 s | 10.87 s +45% | 18.20 s +143% | 4.47 s −40% |
| Editor re-check, L ↓ | 1.06 s | 0.82 s −23% | 1.07 s +1% | 0.96 s −9% |
| Cold type-check (CI), L · TS7 ↓ | 6.85 s | 0.23 s −97% | 0.58 s −92% | 1.12 s −84% |
| First-load JS, /post (gzip) ↓ | 150 kB | 136 kB −9% | 104 kB −31% | 103 kB −31% |
| Nav data payload, dashboard (gzip) ↓ | 470 B | 1,335 B +184% | 274 B −42% | 254 B −46% |
| Time-to-shell, /slow (streamed) ↓ | 7 ms | 10 ms +43% | 5 ms −29% | 5 ms −29% |
| SSR /post, req/s c64 ↑ \* | 1,548 | 882 −43% | 3,284 +112% | 1,156 −25% |
| Prod cold start ↓ | 448 ms | 721 ms +61% | 100 ms −78% | 612 ms +37% |
| RSS idle ↓ | 348 MB | 363 MB +4% | 173 MB −50% | 251 MB −28% |
↓ lower is better · ↑ higher is better. \* Point0's SSR row is its **warmed**
build (`.onPrefetchPage`, 0 re-renders) — the peer of the others' eager loaders;
the default lazy build does 1,200 req/s.
## The edit loop
This is where Point0's advantage is concentrated, and it is the part of the
table you touch most often.
- **HMR lands in 20 ms** against Next's 54 ms and TanStack's and React Router's
~160 ms. This is the loop that runs on every save.
- **Warm dev start is 972 ms** — the only sub-second start of the four (React
Router 1.26 s, Next 1.92 s, TanStack 2.02 s). Cold dev start is Point0's
slowest row at 2.47 s: that is the first run of a project ever, when the
compiler's first pass fills a persistent disk cache.
- **Production build at scale is second**: 7.49 s for 500 pages, ahead of Next's
10.87 s and TanStack's 18.20 s. React Router's lean Vite pipeline is the
build-speed leader at both sizes (1.64 s base, 4.47 s at 500 pages).
## Types at scale: two different questions
Cold whole-project `tsc` and editor responsiveness are different metrics, and
conflating them is the usual mistake.
**The cold check is Point0's clear cost.** Inferring everything end-to-end with
zero annotations runs to 2.23M type instantiations at 500 pages — 6.85 s on
native TS7, 17.45 s on the JS `tsc` — against Next's 0.23 s and TanStack's 0.58
s. You pay that in CI, and it is a real bill.
**The per-edit re-check — the lag you feel while typing — does not separate the
field.** Point0 pages are isolated exports: there is no monolithic `AppRouter`
type to re-instantiate on each keystroke, and the generated route map is just
path strings.
| Per-edit re-check | Point0 | Next.js | TanStack Start | React Router |
| ----------------- | ------ | ----------------------------------------- | ---------------------------------------- | ----------------------------------------- |
| 4 pages | 0.94 s | 0.61 s −35% | 0.91 s −3% | 0.61 s −35% |
| 504 pages | 1.06 s | 0.82 s −23% | 1.07 s +1% | 0.96 s −9% |
All four stay flat from 4 pages to 504, and at any given size they sit within a
third of a second of each other. Point0 sits at the slow end of that band —
which is another way of saying this metric will not decide anything for you. The
structural outlier is React Router's _cold_ check: its typed routes grow
combinatorially, from 10k instantiations at 4 pages to 4.35M at 504 — the
largest count of the four — though native TS7 still clears it in 1.12 s.
## Navigation and payloads
After the first document, a Point0 navigation ships query data and never HTML.
So do TanStack Start and React Router, and on the dashboard case — rich markup,
tiny data, the shape of a real SaaS page — the three land close together: React
Router 254 B, TanStack 274 B, Point0 470 B gzipped. At that scale the gaps
between them do not matter. Next is the outlier at 1,335 B, because its RSC
flight payload re-sends the rendered markup; the fair caveat is that Next
prefetches every visible link, so those bytes move before the click rather than
during it.
Felt latency does not differentiate either: a dashboard→dashboard click resolves
in 30–33 ms on all four.
The other side of the ledger is first-load JS, and it is Point0's heaviest row:
150 kB gzipped against Next's 136 kB and TanStack's and React Router's ~103 kB.
Point0 pays that once on the first load and collects on every click after.
Memory is a cost too, and it does not wash out. Against a bare `Bun.serve` +
`renderToString` floor of 118 MB, Point0 idles at 348 MB — about 230 MB of
framework tax — while TanStack sits ~38 MB above its Node floor. Point0 is the
second-heaviest of the four at idle.
## Streaming: opt-in, and what it buys
Every framework here can stream a slow block into the same response after the
shell. On a page with one 1.5 s query, all four ship the shell in single-digit
milliseconds — Point0 in 7 ms via a `.loading()` fallback +
`.query({ suspend: 'server' })`, and the 5–10 ms spread across the four is noise
on a local server. The contrast row is Point0 with streaming off: the whole
document waits for the slow block and nothing appears until ~1,515 ms. That is
what streaming buys back, and Point0 makes it a per-query opt-in instead of an
architecture decision.
## Raw SSR: the price of not declaring data deps
On raw SSR throughput with in-memory data, TanStack leads at 3,284 req/s — a
real and large lead — followed by warmed Point0 at 1,548, default Point0 at
1,200, React Router at 1,156 and Next at 882.
Point0's default is a render-to-discover loop: it renders, sees which queries
the page needs, fetches them, re-renders — so you never declare a page's data
dependencies, which none of the other three offer. That convenience costs ~22%
(1,200 vs 1,548 req/s); one opt-in hook
(`.onPrefetchPage(() => q.fetchQuery(...))`) removes it.
Point0's SSR document carries data twice — markup plus the dehydrated React
Query cache that makes every piece of data a live, cacheable query on the
client. On a text-heavy post that is 6.5 kB raw vs TanStack's 3.8 kB, and gzip
closes most of the gap: 2.19 kB vs 1.68 kB. Next's document is the largest of
the four (8.9 kB raw, 2.44 kB gzipped), since RSC inlines its flight payload in
a less compressible format.
Prod cold start is 448 ms — behind TanStack's 100 ms, ahead of React Router's
612 ms and Next's 721 ms. Part of that is deliberate: Point0 imports the whole
app at boot, so a broken page fails the process at deploy time, not on a user's
first request.
## The DB reality check
The SSR numbers above measure frameworks in a vacuum — the loader is trivial, so
framework CPU is the whole latency. Add one realistic DB query and the
differences stop existing:
| DB delay | Point0 | Next.js | TanStack Start | React Router |
| -------- | -------- | -------- | -------------- | ------------ |
| 0 ms | 0.90 ms | 2.00 ms | 0.39 ms | 1.23 ms |
| 5 ms | 8.22 ms | 8.26 ms | 8.19 ms | 8.70 ms |
| 20 ms | 25.14 ms | 25.82 ms | 25.16 ms | 26.01 ms |
At 5 ms the four land within half a millisecond of each other; at 20 ms, within
a millisecond. Those are noise, not rankings — no delta you could act on. Any
app that touches a database lives in the second regime, so the raw-SSR ordering
describes frameworks in a vacuum and stops describing your app the moment a real
query enters the loop. That is the reasoning behind where Point0 spends its
budget: HMR, dev start and navigation payloads are costs a faster database
cannot refund.
## Reproduce
```sh
git clone https://github.com/1gr14/point0-benchmarks
cd point0-benchmarks
bun run setup # installs all four apps + Playwright
bun run parity # the gate — 22/22 must pass per app
bun run bench:all
bun run render # regenerates the README from results/
```
Numbers are machine-dependent (except type instantiations and byte sizes, which
are deterministic); the repo's manifest records the exact machine and versions
behind every published table. If you find a setup that treats any framework
unfairly — [open an issue](https://github.com/1gr14/point0-benchmarks/issues).
---
# Point0 — Points
Source: https://1gr14.dev/point0/v0/points.md
A point is the one building block in Point0. A page is a point; so is a query, a
mutation, a layout, the root itself. They're all instances of a single `Point0`
class, composed with the same builder — you open a point, configure it, and
close it. The word **points** is the umbrella term: pages, layouts, queries,
mutations, and the rest _are_ points.
```tsx
export const ideaQuery = root.lets
.query() // open a query point
.input(z.object({ id: z.number() }))
.loader(async ({ input }) => ({ idea: await getIdea(input.id) }))
.query() // close it into a ready query
export const ideaPage = root.lets
.page('/ideas/:id') // open a page point
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.page(({ data: { idea } }) =>
{idea.title}
) // close it
```
The rest of this page lists every point type and explains the two ways to write
`.lets`.
## The point types
There are sixteen. They split into four groups by what they do.
**Mountables — they render UI.**
- [page](page) — a route and a component, with data, loading, error, and SSR
handled up the chain.
- [layout](layout) — a shared shell around pages, with its own route, data,
loading, and error that pages inherit.
- [component](component) — composes queries and UI but has no route: a reusable
mountable, not a page.
- [provider](provider) — loads or computes a value once and hands it to every
child; read it with `.useValue()` or `.getValue()`.
Page, layout, component, and provider share one method-injection model — see
[mountable](mountable) for what they have in common.
**Data and endpoints — they move data, often over HTTP.**
- [query](query) — an input schema plus a server loader: a real HTTP endpoint
and a TanStack Query in one.
- [infinite-query](infinite-query) — a query that loads in pages (cursor or
offset), with the full TanStack infinite cache.
- [mutation](mutation) — an input schema plus a loader: a real HTTP `POST`
endpoint and a TanStack Mutation in one.
- [action](action) — a server endpoint where you control the HTTP method and
path, and may return a raw `Response`.
- [subscription](subscription) — a server stream of values over HTTP: the loader
is an async generator, and each `yield` streams to the client as it happens.
**Sockets — typed messages over one WebSocket.**
- [channel](socket) — a live connection: the client connects over a regular
request, the server checks it and returns the connection's identity. A
**space** grows from it — a family of rooms the client joins — and the message
types grow from either: serverHandler (client sends, the server replies) and
clientHandler (server pushes, clients listen). One page holds them all.
**Structure — they hold defaults and shape the tree.**
- [root](root) — the point you build everything from: the server entry point and
the holder of defaults for every point beneath it.
- [base](base) — shared settings for a subset of points (a route prefix,
defaults, gating) that its children inherit.
- [plugin](plugin) — a bundle of methods you define once and inject into any
point's chain with `.use()`.
A point is not always an HTTP endpoint. A query, mutation, subscription, or
action always is (its own path, in the OpenAPI spec) — so is a channel's connect
(a dual-method endpoint), while its spaces and handlers live on the socket only.
A page or component is an endpoint only when it has a server loader (or SSR is
on) — otherwise it's a pure mountable. Each point's own page says exactly when.
## The short `.lets` notation
Every example in these docs opens a point with the short form —
`root.lets.page('/ideas')`, `root.lets.query()`, `root.lets.mutation()`. The
point's **type is the method name**; its **name is read from the variable**:
```tsx
export const ideaPage = root.lets.page('/ideas/:id').page(/* ... */)
// ▲ ▲
// name → 'idea' type → 'page'
```
The variable is `ideaPage`; the compiler strips the type suffix and the point's
name becomes `idea`. Same rule everywhere — `ideaQuery` → `idea`, `saveAction` →
`save`, `mainRoot` → `main`. A variable with no matching suffix is kept as-is
(`ideaX` stays `ideaX`). If the variable name strips to nothing, or you use a
default export, the name falls back to the file path — the filename, or the
directory name for an `index` file.
The short notation **needs the compiler.** It's a compile-time rewrite, not a
runtime feature: without the compiler, `root.lets.page(...)` throws
```
lets[type] notation can not work without compiler, please use compiler
```
## The full `.lets(type, name, …)` form
The short form is sugar. The compiler rewrites every `.lets.(...)` into
one explicit `.lets(...)` call — type first, name second, then whatever
arguments the short form took:
```tsx
root.lets.page('/ideas') // → root.lets('page', 'idea', '/ideas')
root.lets.query() // → root.lets('query', 'idea')
root.lets.action('POST', '/save') // → root.lets('action', 'save', 'POST', '/save')
```
The full form runs at runtime unchanged — no compiler required. Use it when you
need to pin a name yourself (the variable name isn't a good source) or when the
compiler isn't in play:
```tsx
export const generalLayout = root.lets('layout', 'general').layout(/* ... */)
```
Both forms are valid and identically typed. The example apps use the full form
in a few places where a name is pinned explicitly — most of all the `expo`
example, which doesn't run `generate`.
### Static vs instance `.lets`
Most points grow off another point's instance — `root.lets.page(...)`,
`generalLayout.lets.page(...)`, `root.lets.query(...)`. Two types are different:
**root** and **plugin** are created from the `Point0` class itself, with the
static `Point0.lets`:
```tsx
import { Point0 } from '@point0/core'
export const root = Point0.lets.root().serverUrl(/* ... */).root()
export const mePlugin = Point0.lets.plugin().with(/* ... */).plugin()
```
`Point0.lets` accepts only `'root'` and `'plugin'`; every other type comes from
an instance. From there, the chain flows down: a root spawns layouts, queries,
and mutations; a layout spawns pages; a base or plugin contributes shared
settings.
## The same word opens and closes
A point is opened with `.lets.(...)` and closed with `.(...)` — the
same word at both ends:
```tsx
root.lets.query(/* ... */).input(/* ... */).loader(/* ... */).query()
// ▲ open ▲ close
```
The opener holds what comes _before_ the point (a query's nothing, a page's
route, an action's method and path); the closer holds what comes _at the end_ (a
mutation's react-query options, a page's component). Between them you add the
point's methods. The closing call must match the type you opened — a page closes
with `.page(...)`, a mutation with `.mutation(...)`. (An [action](action) is the
exception: it can close as `.action()`, `.query()`, or `.mutation()`, depending
on how you want to call it.)
The two ends have names the rest of the docs rely on. Everything from the opener
up to (and including) the closing call is a **stage-method** — the chain methods
you call while the point is still being built (`.input`, `.loader`, `.with`, the
closing `.page(...)`, …). What you get _after_ the close is a **ready-method** —
the surface on the finished point (`useQuery`, `fetchMutation`, `.route`, `.id`,
`.useValue`, …). The two states have their own types in the code: a point under
construction is a `StagePoint`, a closed one is a `ReadyPoint`.
```tsx
const ideaQuery = root.lets
.query() // ┐
.input(z.object({ id: z.number() })) // stage-methods (building the point)
.loader(/* ... */) // ┘
.query() // close → from here on, ready-methods
ideaQuery.useQuery({ id: 1 }) // ready-method on the finished query
```
## Everything lands in one collection
Because all sixteen types are the same class, the compiler collects them into
one array — the points collection the engine loads:
```ts
// generated points file (shape)
export default [root, ideaScreenComponent, createIdeaMutation, ideaQuery] // PointsDefinition
```
Each point carries a stable id of the form `scope:type:name` (e.g.
`root:query:idea`). The engine wires this collection in; you never assemble it
by hand. See [generator](generator) for how it's produced and
[engine-config](engine-config) for how it's loaded.
## Reference
### Point types at a glance
| Type | Group | Opens off | Page |
| --------------- | ------------- | ------------------------ | -------------------------------- |
| `page` | mountable | root / base / layout | [page](page) |
| `layout` | mountable | root / base / layout | [layout](layout) |
| `component` | mountable | root / base | [component](component) |
| `provider` | mountable | root / base | [provider](provider) |
| `query` | data/endpoint | root / base | [query](query) |
| `infiniteQuery` | data/endpoint | root / base | [infinite-query](infinite-query) |
| `mutation` | data/endpoint | root / base | [mutation](mutation) |
| `action` | data/endpoint | root / base | [action](action) |
| `subscription` | data/endpoint | root / base | [subscription](subscription) |
| `channel` | socket | root / base | [channel](socket) |
| `space` | socket | channel | [channel](socket) |
| `serverHandler` | socket | channel / space | [channel](socket) |
| `clientHandler` | socket | channel / space | [channel](socket) |
| `root` | structure | `Point0` (static) / root | [root](root) |
| `base` | structure | root / base | [base](base) |
| `plugin` | structure | `Point0` (static) | [plugin](plugin) |
`query`, `mutation`, `action`, and `subscription` are always HTTP endpoints — so
is a channel's connect (a dual-method endpoint), while its spaces and handlers
live on the socket only; `page`, `layout`, `component`, and `provider` are
endpoints only with a server loader or SSR; `root`, `base`, and `plugin` are
never endpoints.
### Notation at a glance
| | Short (`.lets.page`) | Full (`.lets('page', …)`) |
| --------------------- | ---------------------- | --------------------------- |
| Needs the compiler | yes | no |
| Point name | read from the variable | passed explicitly |
| Runs at runtime as-is | no — rewritten | yes |
| Used in these docs | yes (default) | only where a name is pinned |
---
# Point0 — Page
Source: https://1gr14.dev/point0/v0/page.md
A page is a point. You give it a route and a component; everything else — data,
loading and error states, SSR, the client-side (SPA-style) navigation — is wired
through the point chain it grows from.
```tsx
import { generalLayout } from '@/layouts/general'
import { ideaQuery } from '@/queries/idea'
export const ideaPage = generalLayout.lets
.page('/ideas/:id')
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.head(({ data: { idea } }) => idea.title)
.page(({ data: { idea } }) => (
{idea.title}
{idea.content}
))
```
`/ideas/123` now renders that component, with `idea` already loaded — no loading
branch in sight.
## Declaring a page
A page is opened off a parent — the [root](root), a [base](base), or a
[layout](layout) — with a route, and closed with `.page(component)`:
```tsx
export const homePage = root.lets.page('/').page(() =>
Home
)
```
`root.lets.page('/')` is the short notation for the explicit
`root.lets('page', 'home', '/')` — both are valid and identically typed; see
[points](points) for how the short form works. You can't open a page off a query
or a mutation.
The `.page(component)` closer is **server-ssr-and-client** — it's cut from the
SERVER bundle when `ssr:false` (or after a `.clientOnly()` earlier in the
chain): its body and imports are removed from the server build. Kept in the
client build always, and in the server build only when SSR is on.
The component argument is **optional** — omit it and the page renders nothing
(`() => null`). A page can also do its work in an effect and show only the
inherited loading view:
```tsx
export const logoutPage = root.lets
.page('/logout')
.page(({ LoadingComponent }) => {
useLogoutOnMount()
return
})
```
### Typed route params
Write params into the route string and they arrive typed — Point0 parses the
route at the type level:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id') // params is { id: string }
.page(({ params }) =>
Idea {params.id}
)
export const ideaPageTyped = root.lets
.page('/ideas/:id[int]') // params is { id: number } — and /ideas/abc is a 404
.page(({ params }) =>
Idea {params.id}
)
```
A plain param is a string; give it a type in square brackets (`[int]`, `[uuid]`,
`[date]`, …) and it arrives converted — and the route stops matching URLs of the
wrong shape. For transforms beyond a type there's
[`.params(schema)`](validation). Optional and wildcard segments work too —
`'/files/:dir?/*?'` gives you `params.dir` and `params['*']` — and the route
string can declare typed [search params](validation) as well:
`'/ideas&page[int]=0'`.
### The route prefix is inherited
A page's public route extends its parent's. Open a page off a layout that owns
`/ideas`, give the page `/:id`, and its route is `/ideas/:id`:
```tsx
export const ideasLayout = root.lets.layout('/ideas').layout(/* ... */)
export const ideaPage = ideasLayout.lets
.page('/:id') // final route: /ideas/:id
.page(/* ... */)
```
Everything that shapes the page — its route prefix, loading and error UI,
defaults — is reachable by following the parent chain from the point itself. No
config in another file changes it.
## Getting data into a page
A page renders only once its data is ready, so the component never sees a
half-loaded state. There are two ways to attach data.
**Own loader.** Put a [`.loader`](loader) on the page. It runs on the server, so
your database code never ships to the browser:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.loader(async ({ params }) => {
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: Number(params.id) },
})
return { idea } // becomes `data`
})
.page(({ data: { idea } }) =>
{idea.title}
)
```
`.loader` is **server-only** — cut from the client bundle: its body and the
imports it uses are removed.
**Injected query.** When the data lives in a reusable [query](query), hand it to
the page with [`.with`](with) and map the route params to its input:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.page(({ data: { idea } }) =>
{idea.title}
)
```
Both forms feed `data`. Inject more than one query and read them from `queries`
(in declaration order), or fold them into one shape with [`.mapper`](mapper) —
see [`.with`](with) for the full range. You can also skip `.with` and call
`ideaQuery.useQuery({ id })` inside the component, handling `isLoading` yourself
— the page doesn't force either style.
`.with` and `.mapper` are **server-ssr-and-client** — cut from the SERVER bundle
when `ssr:false` (or after a `.clientOnly()`): their bodies and imports are
removed from the server build.
### `.relatedQuery` vs `.with`
[`.relatedQuery`](query) declares a query the page depends on. Like a `.with`
query, it **adds its query to the `queries` array** and feeds `data`. The
difference is **prefetch**: a related query is statically discoverable, so
prefetch can self-fetch it **without rendering** the page, under the cheap
policies (`serverQuery` / `clientQuery` / `serverAndClientQuery`). A `.with`
query is found only by rendering, so it's prefetched only under the expensive,
SSR-only `pageDehydratedState*` policies. Reach for `.relatedQuery` when you
want a page's data warm before navigation without paying for a full render.
`.relatedQuery` is **server-and-client** — not cut from either bundle
(isomorphic).
## Loading and error states
When a page has pending data, Point0 shows the nearest `.loading` component up
the chain; on a thrown error, the nearest `.error`. Set them once on the root
and override per point as needed:
```tsx
export const root = Point0.lets
.root()
.loading(() => )
.error(({ error }) => )
.root()
```
A page can override either:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.loading(() => )
.loader(/* ... */)
.page(/* ... */)
```
The `.loading` must appear before the data method that can suspend. You won't
see the loading state when SSR is on (the data arrives with the HTML) or when
the page is prefetched before navigation. Full rules, including prefetch
policies, are in [Loading & error](loading-error).
`.loading` and `.error` are **server-ssr-and-client** — cut from the SERVER
bundle when `ssr:false` (or after a `.clientOnly()`).
## Head and SEO
Set the document head from the page, statically or from loaded data:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.head(({ data: { idea } }) => ({
title: idea.title,
description: idea.content.slice(0, 140),
}))
.page(/* ... */)
```
`.head` accepts a string (shorthand for the title) or an [unhead](head) object,
and can be set per state. Details on [Head](head).
`.head` is **server-ssr-and-client** — cut from the SERVER bundle when
`ssr:false` (or after a `.clientOnly()`).
## A page is also a query
A page with a loader is itself a query under the hood — Point0 attaches a _self
query_ so the page can be fetched and prefetched like any other:
```tsx
ideaPage.useQuery({ id: 123 })
ideaPage.useSuspenseQuery({ id: 123 }) // data non-optional, suspends into `.loading`
ideaPage.fetchQuery({ id: 123 })
ideaPage.getQueryKey({ id: 123 })
ideaPage.prefetchQuery({ id: 123 })
```
This is what makes SSR and navigation prefetch work without you wiring anything.
The page's self query is **finite by default**. To make it infinite, close the
chain with [`.infiniteQuery({...})`](infinite-query) after the loader — wire the
cursor to search with `pageParamFromInput`:
```tsx
export const ideaListPage = root.lets
.page('/ideas')
.search(z.object({ page: z.coerce.number().default(0) }))
.loader(async ({ search }) => {
/* ... */ return { ideas, nextCursor }
})
.infiniteQuery({
getNextPageParam: (last) => last.nextCursor,
initialPageParam: 0,
pageParamFromInput: '?.page',
})
.mapper(({ data }) => ({ ideas: data.pages.flatMap((p) => p.ideas) }))
.page(({ data: { ideas }, queries: [query] }) => {
/* ...query.fetchNextPage() */
})
```
`.infiniteQuery` and `.query` (the self-query closers) are **server-and-client**
— not cut from either bundle (isomorphic).
A page with no loader issues no request and exposes no useful query — calling
`.useQuery()` on it returns an empty result, not an error.
## Page or endpoint?
Not every page is an HTTP endpoint. A query, a mutation, or an action is
_always_ a real endpoint (its own path, in the OpenAPI spec); a page is only
sometimes one:
- **SSR on** → the page is server-rendered, so it's an endpoint.
- **SSR off** → the page is an endpoint **only if it has a server `.loader()`**
(the loader needs a URL to fetch). A page that only composes other queries —
no server loader — is a pure **mountable**: client-only, no endpoint.
Either way, a page with a route is always reachable in the client (after the
initial SSR render, navigation is client-side, SPA-style). "Endpoint" is about
whether the server serves it directly.
## Gating a page
The page component is browser code. The server-only parts of the chain — your
loader body, secrets, DB calls — are stripped from the client bundle at compile
time, so they never leak, but a render that depends on access has to be guarded
in code that always runs:
- Don't put secret content in the rendered markup expecting it to be server-only
— the markup ships to the browser.
- Gate access in a [`.with`](with) wrapper (or a [plugin](plugin) that combines
`.ctx` and `.with`), not with `.ctx` alone. `.ctx` is **server-only** (its
body is stripped from the client bundle). A page's `.ctx` runs **only when the
page has a loader** — a loader-less page makes no server request, so its
`.ctx` never executes and can't protect anything.
```tsx
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 any compatible one
export const adminPage = root.lets
.page('/admin')
.use(authPlugin)
.with(({ props: { me } }) => {
if (!me?.isAdmin) return new AppError('Forbidden', { code: 'FORBIDDEN' })
return { me }
})
.page(/* ... */)
```
`me` has to come from somewhere — here a [plugin](plugin) puts it in `props`
first. Returning an [`AppError`](error-handling) from `.with` short-circuits to
the error component.
## Authoring notes
- **Pages are lazy by default.** Each page is its own dynamically imported
chunk, loaded on navigation. Turn this off in the client's codegen config —
`client: { generate: { points: { lazy: false } } }` — there is no per-page
method for it.
- **Put pages anywhere.** Any file, any folder, several per file. Point0 finds
them by static analysis; hot-reload survives mixing pages, queries, and
mutations in one file.
- **Wrap the render with [`.wrapper`](mountable).**
`.wrapper(({ children }) => …)` wraps the page's tree; a wrapper can also
short-circuit before the data loads.
## Reference
### Component props
The page component receives one object:
| Prop | Type | When |
| ------------------ | ----------------------------------------------- | ------------------------------------- |
| `data` | mapper output, or the first query's data | always (`{}` if none) |
| `queries` | tuple of loaded query results, in `.with` order | always (`[]` if none) |
| `params` | parsed route params | when the route has params / `.params` |
| `search` | parsed query string | when `.search` is set |
| `setSearch` | update the URL query (replace / patch / drop) | always (client-only; SSR no-op) |
| `props` | props contributed by `.with` wrappers | always (`{}` if none) |
| `location` | the current location (`location.params`, …) | always (pages have a route) |
| `LoadingComponent` | the resolved loading component | always |
| `ErrorComponent` | the resolved error component (`{ error }`) | always |
A page has no `input` prop — pages use `params`/`search`, not `.input`. Writing
`.input` or `.body` on a page is a type error.
### Methods that apply to a page
Data & context: [`.loader`](loader), `.clientLoader`, [`.ctx`](ctx),
[`.with`](with), [`.mapper`](mapper), [`.relatedQuery`](query),
[`.params` / `.search`](validation), `.headers`, `.cookies`. `.headers` /
`.cookies` validate the incoming request and type it into the loader, so they
only do something on a page with a `.loader` — a loader-less page has no request
to parse.
UI: [`.head`](head), [`.loading`](loading-error), [`.error`](loading-error),
`.wrapper`, [`.layout`](layout).
Navigation & prefetch: `.prefetchPageOnNavigate`, `.prefetchPageOnLinkHover`,
`.prefetchPagePolicy`, `.onPrefetchPage` (plus `.serverOnPrefetchPage` /
`.clientOnPrefetchPage`), `.scrollRestore`, `.scrollPosition`, `.clientOnly`.
See [Navigation](navigation) (and [SSR](ssr)) for the prefetch policies.
Shared: [`.use`](plugin) (plugins), [`.middleware`](middleware), `.on` /
`.serverOn` / `.clientOn` (events), `.tag`, `.description`. `.middleware` runs
server-side around the page's request, so it fires only when the page is served
as an endpoint (a loader, or SSR on).
`.infiniteQuery` and `.query` are valid only to finalize a loader-bearing page's
self query; they're otherwise unavailable on a page.
The per-status `.head` forms work on a page the same as on any mountable:
`.head('global', …)` is the app-wide base head, `.head('universal', …)` applies
in every render state, and `.head('loading' | 'error' | 'success', …)` targets
one state. A bare `.head(value)` defaults to the `'success'` state.
### The page's route
`page.route` is a callable [Route0](navigation) route:
```tsx
page.route({ id: 123 }) // => "/ideas/123" (relative)
page.route.abs({ id: 123 }) // => "https://app.example.com/ideas/123"
page.route.getRelation(href) // match the current URL against this route
```
---
# Point0 — Layout
Source: https://1gr14.dev/point0/v0/layout.md
A layout is a point that wraps a set of pages in a shared shell — a header, a
sidebar, a frame. It can own part of the route those pages sit under, load its
own data with its own loading and error states, and hand that data down to the
pages inside it. When you navigate between pages in the same layout, the layout
stays mounted: it does not re-render and its data is not re-fetched.
```tsx
import { root } from '@/lib/root'
import { NavLink } from '@/lib/navigation'
export const generalLayout = root.lets.layout(({ children }) => (
HomeBrowse Ideas{children}
))
```
Every page built off `generalLayout` now renders inside that shell, in place of
`{children}`.
## Declaring a layout
A layout is opened off a parent — the [root](root), a [base](base), or another
layout — and closed with `.layout(component)`:
```tsx
export const generalLayout = root.lets.layout(({ children }) => (
{children}
))
```
`.layout` 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.
The name comes from the variable — see [points](points) for the `.lets`
notation.
The component argument is **optional**. Omit it and the layout renders just its
children (`({ children }) => children`) — still useful for prefixing a route,
loading shared data, or acting as a [provider](provider) without any visible
shell:
```tsx
export const dataLayout = root.lets.layout('/app').layout()
// no shell, but it owns /app and can carry loaders/queries for its pages
```
## Owning part of the route
Pass a route to the layout and the pages inside it inherit it as a prefix. A
layout that owns `/ideas/:id` with a page at `/` resolves the page to
`/ideas/:id`:
```tsx
export const ideaLayout = generalLayout.lets
.layout('/ideas/:id')
.layout(/* ... */)
export const ideaViewPage = ideaLayout.lets
.page('/') // final route: /ideas/:id
.page(/* ... */)
```
The route argument is optional. Omit it and the layout keeps its parent's route
(or `/` if there's none) — that's the case for `generalLayout` above, a shell
with no route of its own.
A layout's route **can't contain a wildcard**:
```tsx
root.lets.layout('/files/*') // throws: Wildcard is not allowed in layout point
```
You never need one. A layout doesn't match URLs — it only contributes its route
prefix. Pages decide which layout they belong to by building off it (or
attaching it with `.layout(...)`), and a page is free to carry its own wildcard.
## Getting data into a layout
A layout loads data the same way a [page](page) does — with its own
[`.loader`](loader) or by injecting a [query](query) with [`.with`](with). The
layout component receives that data in `data`:
```tsx
export const ideaLayout = generalLayout.lets
.layout('/ideas/:id')
.with(ideaQuery, ({ params: { id } }) => ({ id: Number(id) }))
.layout(({ children, data: { idea } }) => (
{idea.title}
{children}
))
```
Strip categories here: [`.loader`](loader) is **server-only** — cut from the
client bundle: its body and the imports it uses are removed, so it never ships
to the browser. [`.with`](with) is **server-ssr-and-client** — cut from the
server bundle when `ssr: false` (or after a `.clientOnly()`). It ships to the
browser either way, so put secrets in the loader/`.ctx`, not in a `.with`
mapper. A layout that closes with a loader is also a query, and its self-query
is **finite by default**; close with [`.infiniteQuery`](infinite-query) after
the loader to make it infinite.
A layout owns its loading and error states, gating its whole subtree:
- While the layout's data loads, the nearest [loading](loading-error) component
up the chain renders in place of the layout — the pages inside don't render
yet.
- If the layout's loader throws, the nearest [error](loading-error) component
replaces the whole subtree, and the page inside is never rendered.
By the time a page inside the layout renders, the layout's data is guaranteed to
be loaded — that's what makes reading it from the page safe (next section).
You can target the layout's own states specifically with `.layoutLoading(...)`
and `.layoutError(...)`, leaving the generic `.loading` / `.error` for
everything else. See [Loading & error](loading-error).
## A layout is also a provider
A layout publishes its loaded `data` to the pages beneath it, like a
[provider](provider). The page reads it — no prop drilling, no re-fetch.
```tsx
export const ideaViewPage = ideaLayout.lets
.page('/')
// read the idea straight from the layout, like from a provider
.with(() => ({ idea: ideaLayout.useValue('idea') }))
.head(({ props: { idea } }) => idea.title)
.page(({ props: { idea } }) =>
{idea.title}
)
```
`.useValue()` is a React hook for use inside the layout's subtree:
```tsx
ideaLayout.useValue() // => the whole data object
ideaLayout.useValue('idea') // => one key
ideaLayout.useValue(['idea', 'author']) // => a picked object
```
The page does **not** inherit the layout's queries or loaders — it reads the
already-loaded value through the provider. Calling `.useValue()` outside a
mounted, loaded layout throws (`useValue must be used within a Provider`); but
because the layout gates its subtree, a page inside it is always past that
point.
Outside React, read the value imperatively:
```tsx
ideaLayout.getValue() // => data; throws if not yet loaded
ideaLayout.getValueOrUndefined() // => data | undefined; never throws
```
Call `getValue` only from code that runs after the layout has mounted and
loaded; `getValueOrUndefined` is the safe variant when you can't guarantee that.
## Nesting layouts
A layout can be built off another layout. The child inherits the parent's route
prefix and shell — the parent wraps the child, which wraps the page:
```tsx
export const generalLayout = root.lets.layout(/* ... */)
export const ideaLayout = generalLayout.lets
.layout('/ideas/:id') // route extends the parent's
.layout(/* ... */)
export const ideaViewPage = ideaLayout.lets.page('/').page(/* ... */)
// rendered as: generalLayout > ideaLayout > ideaViewPage, route /ideas/:id
```
You can also attach a layout to a page from the page's own code, instead of
building the page off the layout:
```tsx
export const homePage = root.lets
.page('/home')
.layout(generalLayout)
.page(/* ... */)
```
When a page declares a layout this way, the layout's route params must be
compatible with the page's, or you get a compile error
(`Layout params not compatible to current page params`).
## No re-render between sibling pages
Navigating between two pages in the same layout keeps the layout mounted — only
the inner page swaps. The layout doesn't re-render, and its loader or query is
not re-run.
```tsx
// /ideas/1 (info) -> /ideas/1 (news)
// generalLayout — stays mounted
// ideaLayout — stays mounted, `idea` not re-fetched
// — this is the only part that swaps
```
This is why a layout is the right place for a shell, a nav bar, or shared data
that several pages read.
## Gating a layout's subtree
A layout is a natural place to enforce access for every page beneath it. The
layout's loader body, `.ctx`, secrets, and DB calls are cut from the client
bundle, so they never reach the browser — but the shell you render ships to the
browser, so put the gate in the data flow, not in the markup:
- Gate with a [`.with`](with) wrapper (or a [plugin](plugin)), not by relying on
`.ctx` alone. A layout's `.ctx` runs **only when the layout has a loader** — a
loader-less layout makes no server request, so its `.ctx` never runs.
[`.use`](plugin) is **server-and-client** — not cut from either bundle
(isomorphic) — and [`.with`](with) is **server-ssr-and-client** — always kept in
the client build: a plugin or wrapper ships to the browser, so the gate must do
its real check in code that the loader runs, not in markup that reaches the
browser.
```tsx
import { authPlugin } from '@/lib/auth' // a plugin that resolves the user into props.me
import { ErrorPoint0 } from '@point0/core'
export const adminLayout = root.lets
.layout('/admin')
.use(authPlugin)
.with(({ props: { me } }) => {
if (!me?.isAdmin) return new ErrorPoint0('Forbidden', { code: 'FORBIDDEN' })
return { me }
})
.layout(({ children, props: { me } }) => (
{me.name}
{children}
))
```
`me` has to come from somewhere — here a [plugin](plugin) puts it in `props`
first. Returning an error from `.with` short-circuits to the error component:
`ErrorPoint0` is the built-in error class, but you can use your own — see
[error handling](error-handling). The gate now covers every page under the
layout.
## Reference
### Component props
The layout component receives one object:
| Prop | Type | When |
| ------------------ | --------------------------------------------- | -------------------------------------------------------- |
| `children` | the page (or nested layout) to render inside | always — render it to mount the subtree |
| `data` | mapper output, or the layout's loaded data | always (`{}` if none) |
| `queries` | tuple of injected query results | always (`[]` if none) |
| `props` | props contributed by `.with` wrappers | always (`{}` if none) |
| `params` | parsed route params | when the route has params / `.params` |
| `search` | parsed query string | when `.search` is set |
| `setSearch` | update the URL query (replace / patch / drop) | always (client-only; SSR no-op) |
| `location` | the current location (`location.params`, …) | always (a layout's location may be an ancestor location) |
| `LoadingComponent` | the resolved loading component | always |
| `ErrorComponent` | the resolved error component (`{ error }`) | always |
A layout's `location` can be an ancestor location — unlike a page, whose
location always matches the page's route.
### Provider accessors (after `.layout()` closes it)
Available once the layout carries a server loader or a suitable query:
| Accessor | Returns |
| ----------------------------- | --------------------------------------------------------------------------------- |
| `useValue(keys?)` | data (React hook); `keys` picks a subset; throws outside a mounted, loaded layout |
| `getValue(input?)` | data; throws if not yet set |
| `getValueOrUndefined(input?)` | data \| undefined; never throws |
The closed layout point also exposes `.route` (a callable [Route0](navigation)
route), `.Layout` / `.X` (the bound component), `.Infer`, and `.lets` to keep
building child points.
### Methods that apply to a layout
Data & context: [`.loader`](loader), `.clientLoader`, [`.ctx`](ctx),
[`.with`](with), [`.mapper`](mapper), [`.query`](query) /
[`.infiniteQuery`](infinite-query), [`.relatedQuery`](query),
[`.params` / `.search`](validation), `.headers`, `.cookies`.
UI: [`.head`](head), [`.loading`](loading-error), [`.error`](loading-error),
`.layoutLoading`, `.layoutError`, [`.wrapper`](mountable). `.wrapper` wraps even
the loading and error states (it renders outside the data gate).
Shared: [`.use`](plugin) (plugins), [`.middleware`](middleware), `.on` /
`.serverOn` / `.clientOn` (events), `.tag`, `.description`,
[`.queryOptions`](stage-methods) / `.layoutQueryOptions`.
A layout also exposes the page-level defaults `.pageError`, `.pageLoading`,
`.pageQueryOptions`, `.pageDehydratedStateQueryOptions` and the prefetch family
(`.prefetchPageOnNavigate`, `.prefetchPageOnLinkHover`, `.prefetchPagePolicy`,
`.onPrefetchPage`). Like a [base](base), a layout broadcasts these to its
subtree: each becomes the default for every page built under it (a page's own
setting still wins). The scroll defaults (`.scrollPosition` / `.scrollRestore`)
broadcast the same way. See [Navigation](navigation) and [SSR](ssr) for the
prefetch policies.
Where each of these runs (strip categories):
- **server-only** — cut from the client bundle: body and the imports it uses are
removed, so it never ships to the browser (it runs on the server):
[`.loader`](loader), [`.ctx`](ctx), `.headers`, `.cookies`,
[`.middleware`](middleware), `.serverOn`, `.serverOnPrefetchPage`,
`.description`. `.params` / `.search` are isomorphic on a layout
(server-and-client) — they're server-only only on an action.
- **client-only** — cut from the server bundle: body and its imports removed (it
runs only in the browser): `.clientLoader`, `.clientOn`,
`.clientOnPrefetchPage`, `.prefetchPageOnNavigate`,
`.prefetchPageOnLinkHover`, `.prefetchPagePolicy`.
- **server-and-client** — not cut from either bundle, kept in both (isomorphic):
the self-query closers [`.query`](query) / [`.infiniteQuery`](infinite-query)
and [`.relatedQuery`](query), `.onPrefetchPage` (it runs on the client during
prefetch and on the server before the first render), [`.use`](plugin),
[`.params` / `.search`](validation), `.tag`, `.on`, and the option setters
[`.queryOptions`](stage-methods) / `.layoutQueryOptions` / `.pageQueryOptions`
/ `.pageDehydratedStateQueryOptions`.
- **server-ssr-and-client** — cut from the SERVER bundle when `ssr: false` (or
after a `.clientOnly()`): 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
[`.layout`](mountable) component, [`.head`](head), [`.loading`](loading-error)
/ `.layoutLoading` / `.pageLoading`, [`.error`](loading-error) /
`.layoutError` / `.pageError`, [`.wrapper`](mountable), [`.with`](with),
[`.mapper`](mapper).
[`.relatedQuery`](query) adds its query to the layout's `queries` array just
like a `.with(query)` result — the difference is prefetch: a related query is
statically discoverable, so it's self-fetched without rendering under the cheap
policies (`serverQuery` / `clientQuery` / `serverAndClientQuery`), whereas a
`.with(query)` is only found by rendering and is prefetched only under the
expensive `pageDehydratedState*` policy.
`.scrollRestore` / `.scrollPosition` are **client-only** — cut from the server
bundle: body and their imports removed. They're documented in full on the
[navigation](navigation) page.
`.head` on a layout sets the document `` while the layout is mounted, and
because the layout wraps its pages, that head applies across the subtree. A
page's own `.head` stacks on top. `.head('global', …)` additionally broadcasts
to every child's render.
A layout's `.wrapper`s wrap the layout's own render only — they don't carry to
the pages built under it, and don't need to: a page renders inside the layout,
so it already sits within those wrappers.
### Wrapping the 404 page in a layout
When no route matches, the router renders a built-in "Page Not Found". By
default it has no shell — but you usually want the not-found screen to keep your
header and frame. Pass `layout404` to wrap it in one or more layouts, and
`Page404` to replace the default screen itself. Both are options on
[`createNavigation`](navigation) (and props you can override on
``):
```tsx
// src/lib/navigation.ts
import { generalLayout } from '@/lib/layouts'
import { NotFoundPage } from '@/components/NotFoundPage'
export const { Router, RouterRoutes /* … */ } = createNavigation({
routes,
hook,
Page404: ,
layout404: generalLayout, // or an array to nest several layouts around it
})
```
`layout404` takes a layout point or its name, or an array of them (rendered
outermost-first). See [Navigation](navigation) for the full option list.
---
# Point0 — Component
Source: https://1gr14.dev/point0/v0/component.md
A component is a point you mount inside other points. It composes its own data
and UI like a [page](page), but it has no route — you place it yourself with a
JSX tag wherever you need it. Because it's a point, it gets the whole chain:
`.with`, `.loader`, `.clientLoader`, `.mapper`, and loading/error states
resolved up the chain.
```tsx
import { root } from '@/lib/root'
export const BestIdea = root.lets
.component<{ cta: string }>() // declares an outer prop: cta
.loader(async () => {
const bestIdea = await prisma.idea.findFirstOrThrow({
orderBy: { id: 'desc' },
})
return { bestIdea } // becomes `data`
})
.wrapper(({ children }) =>
{children}
)
.component(({ data, props }) => (
Best idea: {data.bestIdea.title}
{props.cta}
))
```
```tsx
// mount it anywhere — pass its declared props:
```
The component loads `bestIdea` on the server, shows the nearest loading
component while it loads, and renders once the data is ready.
The same component has a second placement: a server loader can **return it as
data** (`return { cta: }`) — it travels as a reference and
hydrates as an interactive island, promise props included. Nothing about the
component changes between the two uses. See [RSC](rsc).
Stripping in brief: `.loader` and `.input` are **cut from the client bundle** —
their bodies and the imports they use are removed, so they never ship to the
browser (server-only). `.wrapper` and the closing `.component(render)` are **cut
from the server bundle when `ssr:false`** (or after a `.clientOnly()`) — body
and imports removed from the server build; kept in the client build always, and
in the server build only when SSR is on (server-ssr-and-client).
## Component, page, or plain React component?
A component sits between a [page](page) and an ordinary React component:
- **vs. a page** — a component has **no route**. You don't navigate to it; you
mount it with a tag inside another point. Its `.lets` takes no route argument,
and the inner render gets no `location`.
- **vs. a plain React component** — a component **is a point**. It carries the
full chain (`.loader`, `.with`, `.mapper`, loading/error, a self query), so
`` renders all of that, not just markup you wrote by hand.
A component is one of the four **mountables** — [page](page), [layout](layout),
component, [provider](provider) — and they share the same method surface (see
[mountable](mountable)). The difference is registration and routing: pages and
layouts are collected and mounted by the router automatically; **components and
providers you declare and mount yourself**.
## Declaring a component
Open with `.component()` (no route), build the chain, close with
`.component(component)`:
```tsx
export const Stats = root.lets
.component()
.loader(async () => ({ count: await prisma.idea.count() }))
.component(({ data }) => {data.count} ideas)
```
The component's name (`Stats`) is read from the variable — see [points](points)
for the notation.
The closing `.component(render)` is **server-ssr-and-client** — cut from the
server bundle when `ssr:false` (or after a `.clientOnly()` earlier in the
chain).
A component can be opened off a [root](root) or a [base](base), and also off a
[page](page) or [layout](layout) — anywhere in the chain.
### Declaring outer props
The optional generic on `.component<...>()` declares the props the component
accepts at its mount site:
```tsx
export const Greet = root.lets
.component<{ name: string }>()
.component(({ props }) =>
Hi, {props.name}
)
// at the mount site, the prop is required and typed:
```
Props you declare here show up as `props` inside every later method — `.with`,
`.loader`'s context, the mapper, and the render.
### The closing argument is optional
`.component(component)` takes a single, **optional** component. Omit it and the
component renders nothing (`() => null`) — but you must still call
`.component()` to close the chain and produce the mountable:
```tsx
export const Effect = root.lets
.component()
.with(/* runs an effect via a wrapper */)
.component() // renders nothing
```
## Getting data into a component
Like a page, a component renders only once its data is ready. Two ways to attach
data.
**Own loader.** Put a [`.loader`](loader) on the component. `.loader` (and the
`.input` server schema beside it) is **server-only** — cut from the client
bundle, so your database code and its dependencies never ship to the browser.
```tsx
export const IdeaScreen = root.lets
.component()
.input(z.object({ id: z.number() }))
.loader(async ({ input }) => {
const idea = await prisma.idea.findUniqueOrThrow({ where: { id: input.id } })
return { idea }
})
.component(({ data }) =>
{data.idea.title}
)
// mount with input:
```
**Injected query.** When the data lives in a reusable [query](query), hand it to
the component with [`.with`](with), mapping its outer props to the query input:
```tsx
export const SimilarIdeas = root.lets
.component<{ ids: number[] }>()
.with(ideaListQuery, ({ props }) => ({ ids: props.ids }))
.component(({ data }) => )
```
This is the idiomatic reason to reach for a component: instead of one fat page
loader fetching everything, let each component load the data it needs.
`.with` is **server-ssr-and-client** — cut from the server bundle when
`ssr:false` (or after a `.clientOnly()`); it always ships to the browser.
You can also skip `.with` and call `ideaListQuery.useQuery({ ids })` inside the
render, handling `isLoading` yourself — the component doesn't force either
style.
Inject more than one query and read them from `queries` (in `.with` order), or
fold them into one shape with [`.mapper`](mapper). The full range of `.with`
forms — react-query options, `resolve`, props — is on the [`.with`](with) page.
### A production shape: query + plugin
```tsx
export const SocialAccountsLinking = root.lets
.component()
.with(listAccountsQuery)
.use(authorizedOnlyPlugin) // a plugin that gates + supplies props.me
.component(({ data, props }) => {
const google = data.accounts.find((a) => a.providerId === 'google')
return
})
```
`.use` (plugins) is **server-and-client** — not cut from either bundle
(isomorphic); the plugin's own `.ctx`/`.loader` parts are still cut from the
client bundle per their kind (server-only).
## Loading and error states
A component with a loader suspends while it loads, so Point0 shows the nearest
[`.loading`](loading-error) component up the chain, then the render; if its
loader throws, it bubbles to the nearest [`.error`](loading-error). A component
**without a loader** does not suspend — it renders synchronously, no loading
flash.
Set `.loading` / `.error` once on the root and they cover every component
beneath it, or override either on the component itself:
```tsx
export const Stats = root.lets
.component()
.loading(() => )
.loader(/* ... */)
.component(/* ... */)
```
The `.loading` must appear before the data method that can suspend. Full rules
are in [Loading & error](loading-error).
Both `.loading` and `.error` are **server-ssr-and-client** — cut from the server
bundle when `ssr:false` (or after a `.clientOnly()`).
### The wrapper covers loading too
[`.wrapper`](mountable) wraps the component's whole tree — including its loading
and error states, not just the success render:
```tsx
export const Stats = root.lets
.component()
.wrapper(({ children }) =>
{children}
)
.loader(/* ... */)
.component(/* ... */)
// the spinner renders inside .card while loading; so does the final content
```
`.wrapper` is **server-ssr-and-client** — cut from the server bundle when
`ssr:false` (or after a `.clientOnly()`).
## Mounting a component
A component's `.X` is its mountable React element, and the short notation drops
the `.X`:
```tsx
// short notation (preferred)
// explicit — identical
```
The short `` only works because the variable is PascalCase — JSX treats
a lowercase tag as a DOM element, so a component declared as `const stats = …`
could only be mounted as ``. Always name a component with a capital
first letter.
Pass `input` (when the component has any input schema — `.input`,
`.clientInput`, or `.sharedInput`) and your declared props at the mount site:
```tsx
```
**Props at the mount site win.** If a prop of the same name was contributed
earlier (e.g. by a base `.with`), the value you pass at the tag overrides it.
**Use a component as a wrapper.** Declare `children` as an outer prop and render
it:
```tsx
export const Frame = root.lets
.component<{ children: React.ReactNode }>()
.component(({ props }) => {props.children})
```
## Component or endpoint?
A component is a [mountable](mountable), not automatically an HTTP endpoint —
but it becomes one when it has a server loader:
- **With a `.loader()`** → the loader needs a URL, so the component gets a real
endpoint at `GET`/`POST` `/_point0//component/`. During SSR
its data is fetched on the server and dehydrated into the cache; during SPA
navigation it's fetched on the client.
- **With only a `.clientLoader()`** → the component has **no endpoint**.
`.clientLoader` is **client-only** — **cut from the server bundle**: its body
and imports are removed, regardless of SSR (it runs in the browser). During
SSR it is not server-prefetched; it shows the loading state until the client
loads it.
- **With no loader** → no endpoint, no SSR fetch, no loading flash. A pure
client-only mountable.
Like a [query](query), the component endpoint is a read: a `GET` with its input
JSON-encoded in the `?input=` search param, so a CDN can cache it (a binary or
over-long input falls back to a `POST` body). Pages and layouts are `GET` too,
but carry their input as route params instead.
## A component with a loader is also a query
A component with a loader carries a _self query_, the same way a page does — so
you can fetch and prefetch it like any [query](query):
```tsx
Stats.useQuery()
Stats.useSuspenseQuery() // data non-optional, suspends into `.loading`
Stats.fetchQuery()
Stats.prefetchQuery()
Stats.getQueryKey()
```
This is what makes the component's SSR and client fetch work without extra
wiring. A component with no server loader exposes no useful query — only the
mountable surface (`.X`, `.Component`).
The component's self query is **finite by default**, but you can make it
**infinite** by closing the loader with `.infiniteQuery({...})` after it — any
mountable can. The live react-query key is the array form
`['point0', { scope, type, name, mode, finiteness, tags, output, input }]`; its
serialized (dehydration) form is the pipe-joined prefix
`point0||component||server|finite||data|{...input}` (the empty `||`
is an empty tags segment; `finite` becomes `infinite` when you close with
`.infiniteQuery`).
## Gating a component
A component's `.ctx` runs **only when the component has a loader** — a
loader-less component makes no server request, so its `.ctx` never executes and
can't protect anything, and whatever it renders ships to the client
(server-rendered into the HTML under SSR, then again in the browser). Don't rely
on a component's own `.ctx` to keep something private: gate access with a
[`.with`](with) wrapper, or a [plugin](plugin) that combines `.ctx` and `.with`:
```tsx
import { authPlugin } from '@/lib/auth' // a plugin that resolves the user into props.me
import { ErrorPoint0 } from '@point0/core'
export const AdminStats = root.lets
.component()
.use(authPlugin)
.with(({ props: { me } }) => {
if (!me?.isAdmin) return new ErrorPoint0('Forbidden', { code: 'FORBIDDEN' })
return { me }
})
.loader(/* ... */)
.component(/* ... */)
```
`.ctx` is **server-only** — cut from the client bundle: its body and the imports
it uses are removed, so it never ships to the browser. `.with` is
**server-ssr-and-client** — always kept in the client build — so the gate above
relies on `.with` returning an error to short-circuit, not on `.ctx` to hide
markup.
`me` has to come from somewhere — here a [plugin](plugin) puts it in `props`
first. Returning an error from `.with` short-circuits to the error component.
`ErrorPoint0` is the default; you can swap in
[your own error class](error-handling) via `.errorClass(...)`.
## Reference
### Inner component props
The `.component(fn)` callback receives one object:
| Prop | Type | When |
| ------------------ | ----------------------------------------------- | ----------------------------------------- |
| `data` | mapper output, or the first query's data | always (`{}` if none) |
| `queries` | tuple of loaded query results, in `.with` order | always (`[]` if none) |
| `props` | outer props + props from `.with` | always (`{}` if none) |
| `input` | parsed input | only with `.sharedInput` / `.clientInput` |
| `LoadingComponent` | the resolved loading component | always |
| `ErrorComponent` | the resolved error component (`{ error }`) | always |
A component has **no `location`** prop (it has no route — that's the page/layout
difference).
`input` is visible in the render only with [`.sharedInput`](validation) or
[`.clientInput`](validation). A plain [`.input`](validation) is server-only and
not re-validated on the client, so it does not appear in the render props. With
`.sharedInput`, any transform runs again on the client at mount.
### Outer props (the mount site)
```tsx
```
A component accepts `input` when it has any input schema (`.input`,
`.clientInput`, or `.sharedInput`) plus the props you declared with
`.component<...>()`. (In the render props, `input` appears only with
`.clientInput` / `.sharedInput`; the mount site requires it for a plain server
`.input` too.) It has no framework `children` prop — declare `children` yourself
if you want a wrapper component.
### Methods that apply to a component
Each method below is tagged with its strip category. The category names what's
**cut and from which bundle** (body + the imports it uses pruned along with it):
**server-only** = cut from the client bundle · **client-only** = cut from the
server bundle · **server-and-client** = cut from neither (isomorphic) ·
**server-ssr-and-client** = cut from the server bundle when `ssr:false`.
Data & context:
- [`.loader`](loader) — server-only.
- `.clientLoader` — client-only.
- [`.ctx`](ctx) — server-only.
- [`.with`](with) — server-ssr-and-client.
- [`.mapper`](mapper) — server-ssr-and-client.
- [`.input`](validation) — server-only.
- [`.clientInput`](validation) — client-only.
- [`.sharedInput`](validation) — server-and-client.
- `.headers`, `.cookies` — server-only.
UI:
- [`.loading`](loading-error), [`.error`](loading-error),
[`.wrapper`](mountable) — server-ssr-and-client.
(`.componentLoading` / `.componentError` also exist as type-specific aliases —
same server-ssr-and-client category — but on a component plain `.loading` /
`.error` is the way.)
Shared:
- [`.use`](plugin) (plugins) — server-and-client.
- [`.middleware`](middleware) — server-only.
- `.on` — server-and-client; `.serverOn` — server-only; `.clientOn` —
client-only.
- `.tag` — server-and-client.
- `.description` — server-only.
- `.fetchOptions` — server-and-client.
- `.clientOnly` — the switch that makes everything after it behave as
`ssr:false` (so the server-ssr-and-client methods after it are cut from the
server build).
A component **cannot** use `.params`, `.search`, or `.body` — those are for
[pages](page) and [actions](action) and are a type error on a component:
`For "component" not allowed "params" schema. Only "input" are allowed.` It also
has no `.route`, `.layout`, `.provider`, or prefetch methods. `.head` is
meaningful only for routed pages/layouts.
After `.component(...)`, a loader-bearing component exposes the full query
surface (`useQuery`, `useSuspenseQuery`, `fetchQuery`, `prefetchQuery`,
`getQueryKey`, `invalidateQuery`, …) alongside `.X` and `.Component`. See
[query](query) for the full method list.
### Components are not code-split the way pages are
A page is mounted by the router through the generated points manifest, which the
client side loads lazily by default — so a page's module is its own chunk,
fetched when you navigate to it. A component is different: you mount it yourself
with a `` tag, so it ships inside whatever module imports it. There is
no automatic per-component code splitting at the mount site. To defer a heavy
component's code, wrap it in your own `React.lazy` / `Suspense` like any other
React component.
The exception is [RSC](rsc): every component point also lands in the generated
points aggregator as a lazy record, so a component referenced **from loader
data** (`.loader(async () => ({ cta: }))`) resolves by name from the
points collection and its chunk is fetched on demand — declare it in its own
file and it stays out of every page's bundle until a payload references it.
### Referencing a component from loader data (RSC)
A component point inside a loader's output serializes as a reference — its name
plus its props as data — and renders on the client as an interactive island,
while plain function components in the same payload render on the server. The
full model, including `.rsc({ depth: n })` and the wire format, is on the
[RSC](rsc) page.
---
# Point0 — Provider
Source: https://1gr14.dev/point0/v0/provider.md
A provider is a point that produces one value and exposes it down the tree. A
child reads it with `.useValue()` (a hook, fine-grained) or `.getValue()` (a
plain read). The provider can compute the value, fetch it through a
[loader](loader) or injected [queries](query), or build it from React hooks —
then every descendant gets it without prop drilling.
```tsx
import { root } from '@/lib/root'
export const AppProvider = root.lets.provider().provider(() => ({ x: 1, y: 2 }))
```
```tsx
// mount it once, high in the tree:
;{children}
// read it anywhere below:
const { x, y } = AppProvider.useValue() // => { x: 1, y: 2 }
```
See [points](points) for the short vs. explicit `.lets` notation.
## Declaring a provider
Open with `.provider()` and close with `.provider(mapper?)`. The closing call's
argument is the **mapper** — it maps the provider's accumulated state into the
value children will read:
```tsx
export const AppProvider = root.lets
.provider() // open
.provider(({ data }) => ({ user: data.user })) // close, with a mapper
```
The mapper is **optional**. Omit it and the provided value is the provider's
`data` as-is:
```tsx
export const AppProvider = root.lets
.provider()
.loader(() => ({ flags: readFlags() }))
.provider() // value = data = { flags }
```
This `mapper` is the same as [`.mapper`](mapper), expressed in the closing call.
With no loader, no query, and no mapper, the value is the empty data object.
The mountable closer `.provider()` (and `.mapper`) 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 removed from the
server build. Kept in the client build always, and in the server build only when
SSR is on (so it can render during SSR).
## Getting a value into a provider
A provider fills its value the same way a [page](page) does. Three sources, and
you can combine them.
**A loader.** Put a [`.loader`](loader) on the provider:
```tsx
export const MeProvider = root.lets
.provider()
.loader(async () => ({ me: await getCurrentUser() }))
.provider()
```
`.loader` 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). A provider's self-query (a loader-backed provider is also a query)
is **finite by default**; close with `.infiniteQuery({...})` after the loader to
make it infinite instead.
**Injected queries.** Hand a reusable [query](query) to the provider with
[`.with`](with):
```tsx
export const MeProvider = root.lets
.provider()
.with(getMeQuery)
.provider(({ data: { me } }) => ({ me }))
```
**React hooks via `.with(fn)`.** A function form of [`.with`](with) runs at
render, so it can call hooks; merge their result into props and read it in the
mapper:
```tsx
export const MeProvider = root.lets
.provider()
.with(() => {
const theme = useTheme()
return { theme }
})
.with(getMeQuery)
.provider(({ data: { me }, props: { theme } }) => ({ me, theme }))
```
`.with` is **server-ssr-and-client** — like the closer, cut from the SERVER
bundle when `ssr:false` (or after a `.clientOnly()`). A query handed to it is
injected the same way on both sides.
The mapper callback receives `data`, `props`, `queries`, and — when the matching
schema exists — `input`. Its return value is exactly what children read.
## Input and props
A provider may take an **input** schema (for its loader / queries) and **outer
props** (passed to its React element). It uses `input` — never `params`,
`search`, or `body`; those belong to [pages](page) and [actions](action) and are
a type error on a provider.
```tsx
export const DataProvider = root.lets
.provider()
.input(z.object({ z: z.number() }))
.loader(({ input }) => ({ z: input.z * 2 }))
.provider(({ data }) => ({ doubled: data.z }))
// mount with input:
{children}
// the loader fetches once; children read { doubled: 8 }
```
`.input` on a provider is a non-action mountable schema, so it is
**server-and-client** — not cut from either bundle, kept in both (unlike
`.input` on an [action](action), which is cut from the client bundle —
server-only).
Declare outer props with a generic on `.lets`, then pass them on the element:
```tsx
export const DataProvider = root.lets<{ z: number }>('provider', 'app')
.provider(({ props }) => ({ scaled: props.z * 10 }))
{children} // children read { scaled: 40 }
```
Changing an outer prop re-renders the provider and the children that read the
affected value.
## Mounting: the provider is its own component
The closing `.provider()` returns a mountable component, so the point itself is
the wrapper — no `.X` needed:
```tsx
{children} // short notation
```
`AppProvider.X` and `AppProvider.Provider` are the same component under explicit
names and still work. Wherever you mount it, that subtree (and only that
subtree) can read the value.
## Reading the value
Two readers, with different rules. Both are ready-methods on the closed
mountable, not chain stage-methods, so they are never stripped — they read the
value wherever the provider's subtree renders (the browser, and the server
during SSR).
### `.useValue()` — the hook
`.useValue()` subscribes to the value inside a component's render. It is a React
hook, so it follows the rules of hooks (top level of render only):
```tsx
AppProvider.useValue() // => the whole value
AppProvider.useValue('x') // => the value of one key
AppProvider.useValue(['x', 'y']) // => { x, y } — a Pick
```
It is **fine-grained**: reading one key re-renders the component only when that
key changes, not when the rest of the value does. Read inside a page or another
point's render — including via [`.with`](with), the way the basic example reads
a layout (see below):
```tsx
export const ideaViewPage = ideaLayout.lets
.page('/')
// read the layout's value straight in .with, like from a provider
.with(() => ({ idea: ideaLayout.useValue('idea') }))
.page(({ props: { idea } }) =>
{idea.title}
)
```
Calling `.useValue()` outside a mounted provider throws:
```
useValue must be used within a Provider on point
```
### `.getValue()` — the plain read
`.getValue()` reads the same value without subscribing — not a hook, so you can
call it anywhere (event handlers, loaders, plain functions):
```tsx
const { me } = AppProvider.getValue()
```
It throws if the provider has not mounted and loaded yet:
```
Provider value not found. You should call getValue only after Provider component
is mounted and loaded. On point
```
When the provider might not be mounted, use `.getValueOrUndefined()` — it
returns `undefined` instead of throwing:
```tsx
const value = AppProvider.getValueOrUndefined() // => value | undefined
```
If the provider was mounted with an `input`, address that instance by passing
the same input: `AppProvider.getValue({ z: 4 })`. The value is stored under a
key derived from the input, so two instances mounted with distinct inputs each
keep their own slot and `getValue(input)` reads the matching one. `.getValue()`
with no input reads the value the provider last wrote without an input — under
two instances that returns whichever mounted last.
`.useValue()` takes only a key / keys / nothing — it has **no** `input`
argument, because it resolves through React context, not by input. It reads the
nearest mounting provider above it in the tree; that tree position disambiguates
instances. To read a specific input-keyed value outside the subtree, use
`.getValue(input)`.
## A layout is a provider too
A [layout](layout) carries the same value machinery: it exposes `.useValue()`,
`.getValue()`, and `.getValueOrUndefined()`, so a page below it reads the
layout's data like a provider's. This is the common production shape — load once
in the layout, read it in each child page:
```tsx
// the layout loads the idea once
export const ideaLayout = generalLayout.lets
.layout('/ideas/:id')
.with(ideaViewQuery, ({ params: { id } }) => ({ id: +id }))
.layout(({ children, data: { idea } }) => (
{idea.title}
{children}
))
// a child page reads it without re-fetching
export const ideaNewsPage = ideaLayout.lets.page('/news').page(() => {
const idea = ideaLayout.useValue('idea')
return
})
```
A [component](component) is **not** a provider — it has no `.useValue()` /
`.getValue()`. Only providers and layouts expose values.
## Endpoint behavior
A provider becomes an HTTP [endpoint](query) **only if it has a server
`.loader()`** — then it gets a path (`GET`/`POST`
`/_point0//provider/`, a read like a [query](query)) so
children can fetch its data. A provider with no loader (a pure computed or
props-driven value) issues no request and has no endpoint. An auth gate belongs
in a [`.with`](with) wrapper, not in `.ctx` alone (`.ctx` runs only when the
point has a loader).
## Reference
### Closing method
`.provider(mapper?)` applies **only to a provider-stage point** (a point opened
with `.lets.provider()` / `.lets('provider', name)`). It is terminal: it returns
a `Mountable`, after which only the value/component helpers below remain. As a
mountable closer it is **server-ssr-and-client** — cut from the server bundle
when `ssr:false` or after a `.clientOnly()`.
| Argument | Type | Notes |
| --------- | ----------------- | ------------------------------------------------------------- |
| `mapper?` | `(opts) => value` | optional; same as [`.mapper`](mapper). Omit → value is `data` |
The mapper receives `{ data, props, queries }` plus `input` when an input schema
exists.
### Value helpers
These are exposed on a **closed provider** and on a [layout](layout) — not on a
[component](component).
| Method | Signature | Returns |
| --------------------- | ---------- | ------------------------------------------- |
| `useValue` | `()` | the whole value (hook) |
| `useValue` | `(key)` | one key's value (hook) |
| `useValue` | `(keys[])` | `Pick` of those keys (hook) |
| `getValue` | `(input?)` | the value; **throws** if not mounted/loaded |
| `getValueOrUndefined` | `(input?)` | the value, or `undefined` |
`.useValue()` subscribes (fine-grained re-render); `.getValue()` /
`.getValueOrUndefined()` read once without subscribing.
### Element props
The provider's React element accepts:
| Prop | Type | Notes |
| ------------- | ------------------- | -------------------------------------------- |
| `children` | `ReactNode` | the subtree that may read the value |
| `input` | the input schema | when `.input` is set; addresses the instance |
| _outer props_ | from `.lets<...>()` | spread directly on the element |
### Default query options
`.providerQueryOptions(...)` sets the default react-query options for a
provider's auto-generated self-query. It is configured on the [root](root), a
[base](base), or a [plugin](plugin) — not on the provider itself — alongside the
other `*QueryOptions` methods (see [stage-methods](stage-methods)). Like the
rest of the `*QueryOptions` family it is **server-and-client** — not cut from
either bundle.
---
# Point0 — Mountable
Source: https://1gr14.dev/point0/v0/mountable.md
A **mountable** is a point that renders React. There are four: [page](page),
[layout](layout), [component](component), and [provider](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`, …).
```tsx
// 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 } }) => {user.name}) // read its data
export const ideaLayout = generalLayout.lets
.layout('/ideas/:id')
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.layout(({ children, data: { idea } }) => (
{idea.title}
{children}
))
```
The rest of this page covers the shared surface; each point type's own page
covers its specifics.
## The four mountables
| Type | Closes with | Mounts | Extra render-prop keys |
| ---------------------- | ---------------- | ----------------------------------------- | ----------------------------------- |
| [page](page) | `.page(c?)` | a component at a route | `location`, `setSearch` |
| [layout](layout) | `.layout(c)` | a wrapper around child pages (`children`) | `children`, `location`, `setSearch` |
| [component](component) | `.component(c?)` | a reusable piece of UI | — |
| [provider](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](query),
[mutation](mutation), [action](action)) renders nothing and has none of this.
## The shared method surface
Every mountable, while you compose it, exposes the same core methods:
```tsx
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(...) // metadata
```
The 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](#reference) below.
## Getting data in
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):
```tsx
export const IdeaCard = root.lets
.component()
.input(z.object({ id: z.number() }))
.loader(async ({ input }) => ({ idea: await findIdea(input.id) }))
.component(({ data: { idea } }) =>
{idea.title}
)
```
**An injected [query](query) via [`.with`](with)** — when the data lives in a
reusable query:
```tsx
export const IdeaCard = root.lets
.component()
.with(ideaQuery, ({ props }) => ({ id: props.id }))
.component(({ data: { idea } }) =>
{idea.title}
)
```
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`](mapper). See [`.with`](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 render-prop bag
The closing component receives one object. These keys are shared by all four
mountables:
```tsx
export const IdeaCard = root.lets
.component()
.with(ideaQuery, ({ props }) => ({ id: props.id }))
.component(({ data, queries, props, LoadingComponent, ErrorComponent }) => (
{data.idea.title}
))
```
- **`data`** — the [`.mapper`](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](#reference).
```tsx
export const IdeaList = root.lets
.component()
.with(ideasInfiniteQuery)
.mapper(({ data }) => ({ ideas: data.pages.flatMap((p) => p.ideas) }))
.component(({ data: { ideas } }) => )
```
A `.mapper` reshapes `data` for the component. On a [provider](provider), the
mapper's return value _is_ the provided value — what `useValue()` and
`getValue()` hand out.
## Loading and error
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](root) and override per point:
```tsx
export const root = Point0.lets
.root()
.loading(() => )
.error(({ error }) => )
.root()
export const IdeaCard = root.lets
.component()
.loading(() => ) // 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](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-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()`).
## Wrapping: `.wrapper` vs a `.with` wrapper
Two 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:
```tsx
export const IdeaCard = root.lets
.component()
.with(ideaQuery, ({ props }) => ({ id: props.id }))
.with(({ children, data }) => (
{children}
))
.component(({ data: { idea } }) =>
{idea.title}
)
```
**`.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:
```tsx
export const IdeaCard = root.lets
.component()
.wrapper(({ children }) => (
{children}
))
.with(ideaQuery, ({ props }) => ({ id: props.id }))
.component(({ data: { idea } }) =>
{idea.title}
)
```
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](layout)'s
wrappers don't carry to the pages beneath it — see that page for why.
## Opting out of SSR: `.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:
```tsx
export const MetricsChart = root.lets
.component()
.clientOnly(() => ) // shown server-side until the client mounts
.with(metricsQuery)
.component(({ 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.
## Mountable vs endpoint
A mountable renders UI. An **endpoint** is served over HTTP (its own path, an
[OpenAPI](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.
- A mountable with a server `.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 mountable that only composes other queries (no server loader) is
**client-only**: it renders, but it isn't an endpoint and has no OpenAPI
entry.
A [query](query), [mutation](mutation), or [action](action) is _always_ an
endpoint; a mountable is one only sometimes. The [page](page) page covers the
page case (`SSR on` vs loader) in detail.
## Gating a mountable: `.with`, not `.ctx`
The 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`](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:
```tsx
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](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`](error-handling) or any error class of the
same-or-wider structure (one option for building one is
[Error0](https://1gr14.dev/error0)). See [`.with`](with) for the full gate
pattern.
## `.X` — the bound component
Closing a mountable produces a ready point that is itself a React component
carrying the full point API. The same component is exposed two ways:
```tsx
// short — the ready point IS the component
// explicit — the bound component on `.X`
```
The short form only works because the variable starts with a capital letter —
JSX treats a lowercase tag (``) as an HTML element, so a lowercase
point forces you into the explicit `` form. Declare every
mountable in **PascalCase** and prefer ``.
A [provider](provider) is mounted the same way and hands its value out below:
```tsx
export const AppProvider = root.lets.provider().provider(() => ({ theme: 'dark' }))
// mount it:
{children}
// and read its value anywhere below:
AppProvider.useValue('theme') // => 'dark'
```
[Page](page) and [layout](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`.
## Reference
### Component-prop bag, per type
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`](mapper)-style function whose
return value becomes the provided context value.
### Methods per mountable type
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
- the closer `.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](action))
- `.head` — server-ssr-and-client
- `.scrollPosition` / `.scrollRestore` — client-only. Documented in full on
[navigation](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-only
Layout 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.
### What counts as a mountable
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](query) or [infinite query](infinite-query) is queryable but
not mountable (it renders nothing).
---
# Point0 — Query
Source: https://1gr14.dev/point0/v0/query.md
A query pairs an input schema with a server loader. It's a real HTTP endpoint
(its own path, in the OpenAPI spec) and, at the same time, an ordinary TanStack
Query — declared once, called anywhere by importing it directly.
```tsx
import { root } from '@/lib/root'
import { z } from 'zod'
export const ideaQuery = root.lets
.query()
.input(z.object({ id: z.number() }))
.loader(async ({ input }) => {
// cut from the client bundle — body and its imports removed, so it runs server-side
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: input.id },
})
return { idea }
})
.query()
```
```tsx
// anywhere in a component:
const { data, isLoading } = ideaQuery.useQuery({ id: 123 })
// or imperatively, on the server or the client:
const { idea } = await ideaQuery.fetchQuery({ id: 123 })
```
The first argument to every query method is the **input** — that's what forms
the cache key and gets validated and sent to the loader.
## Declaring a query
Open with `.query()`, declare input and a loader, close with `.query(options?)`:
```tsx
export const ideaQuery = root.lets
.query() // open
.input(z.object({ id: z.number() }))
.loader(async ({ input }) => ({ idea: await findIdea(input.id) }))
.query({ staleTime: 60_000 }) // close, with default react-query options
```
The options you pass to the closing `.query({...})` are standard TanStack Query
options (`staleTime`, `gcTime`, `retry`, `select`, `refetch*`, …); `queryKey`
and `queryFn` are supplied by Point0. They become the query's defaults and can
be overridden at every call site.
The closing `.query(...)` is **not cut from either bundle** — kept in both
(isomorphic): it has to resolve the query on the server and the client alike.
## A real endpoint
A query with a server loader is served over HTTP at an auto-generated path —
roughly `GET /_point0//query/` — and shows up in the
generated [OpenAPI](openapi) spec. It's a **GET with the input JSON-encoded in
the `?input=` search param**, so a read is safe, idempotent, and a CDN can cache
it. An input that carries a `File`/`Blob`, or that grows long enough to overflow
the URL, falls back to a `POST` body automatically — the endpoint answers to
both. You never write the path; calling `fetchQuery` / `useQuery` routes to it
for you.
A query whose only loader is a `.clientLoader` runs in the browser and has **no
endpoint** (and no OpenAPI entry) — it's a client-side query that still gets the
full cache and method surface.
## Input and validation
`.input(schema)` takes any [Standard Schema](validation) — zod, valibot,
arktype, typebox, and others — or a custom validate function:
```tsx
export const ideaQuery = root.lets
.query()
.input(z.object({ id: z.number(), withAuthor: z.boolean().optional() }))
.loader(async ({ input }) => ({ idea: await findIdea(input) }))
.query()
```
Input schemas **merge down the chain**: a parent (a [base](base) or
[plugin](plugin)) can declare part of the input and the query adds the rest. A
child can't _widen_ the parent's input, and input can't collide with
params/search/body.
A query uses `.input` (plus `.clientInput` / `.sharedInput` for client-loader
cases) — **not** `.params` and `.search` (those belong to [pages](page) and
[actions](action)) and not `.body` (actions only); all three are a type error on
a query. Full schema mechanics live in [Validation](validation).
`.input` is the **server schema** — cut from the client bundle: its body and the
imports it uses are removed, so it never ships to the browser (it runs
server-side). `.clientInput` is the mirror image — cut from the server bundle:
body and its imports removed (it runs client-side). `.sharedInput` is not cut
from either bundle — kept in both (isomorphic).
## The loader
`.loader` runs on the server and returns the data:
```tsx
export const ideaQuery = root.lets
.query()
.input(z.object({ id: z.number() }))
.loader(async ({ input, ctx, request, set }) => {
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: input.id },
})
return { idea }
})
.query()
```
The callback receives the parsed `input`, any `ctx` from `.ctx`/plugins, the
`request`, a `set` helper for response headers/status/cookies, and `points`. A
query's loader must return plain data, **not** a `Response`. Use `.clientLoader`
for a browser-side loader. See [Loader](loader) for the full surface and how
server code is removed from the client build.
`.loader` is **cut from the client bundle** — its body and the imports it uses
are removed, so it never ships to the browser (it runs server-side).
`.clientLoader` is the inverse: **cut from the server bundle** — body and its
imports removed, so it runs in the browser regardless of SSR.
## Using a query
**Inject it into a point.** Hand the query to a [page](page) or component with
[`.with`](with), mapping the surrounding context to its input:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.page(({ data: { idea } }) =>
{idea.title}
)
```
The injected query lands in `data` (when it's the first) and in `queries`. The
fuller forms — passing react-query options, resolving into props, using one
query's result as another's input — are all on the [`.with`](with) page.
**Call it directly.** Every query exposes the TanStack Query surface, with the
input as the first argument:
```tsx
const result = ideaQuery.useQuery({ id }) // standard useQuery result
ideaQuery.useQuery({ id }, { enabled: !!id }) // 2nd arg = react-query options
ideaQuery.useQuery(undefined, { enabled: false }) // read cache without fetching
```
When there's an error, `result.error` is an [`ErrorPoint0`](error-handling) (or
your own error class) — never `unknown`, so `result.error.message` is typed;
it's `null` otherwise.
**Suspense form.** Every query also has `useSuspenseQuery` with TanStack v5
semantics: `data` is non-optional in types, a pending query suspends into the
nearest [`.loading`](loading-error) declared above it, an error throws to the
nearest `.error`. It takes no `enabled`, `ssr`, or `suspend` options — a
suspense query always runs and always suspends:
```tsx
const { data } = ideaQuery.useSuspenseQuery({ id }) // data: never undefined
```
During SSR the shell ships with the fallback and the content streams into the
same response — see [SSR](ssr#usesuspensequery--usesuspenseinfinitequery).
## The query key
You rarely touch it, but knowing its shape helps when reading the cache. The key
is a two-element tuple:
```tsx
ideaQuery.getQueryKey({ id: 123 })
// [
// 'point0',
// {
// scope: 'root', // which client/root this point grows from
// type: 'query', // the point kind ('page', 'layout', … for self queries)
// name: 'idea',
// mode: 'server', // 'client' for a client-loader query
// finiteness: 'finite', // 'infinite' for an infinite query
// tags: [], // from .tag(...)
// output: 'data', // usually 'data'
// input: '{"id":123}', // deterministic, stable-stringified input
// },
// ]
```
The `input` is serialized deterministically (sorted keys), so `{ a, b }` and
`{ b, a }` produce the same key. With the default transformer that's plain JSON;
if you set [`.transformer(superjson)`](transformer), special types (Date, Map,
…) are encoded into the key too. For page/layout queries, search keys outside
the declared `.search` schema (and `undefined` values) are dropped from the key.
## Defaults and precedence
Set query defaults once and override outward. `.queryOptions(...)` on the root
or a base applies to every query beneath it:
```tsx
export const root = Point0.lets
.root()
.queryOptions({
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
})
.root()
```
For one query, options resolve lowest-to-highest:
1. root/base `.queryOptions(...)`
2. the type-specific default — `.pageQueryOptions`, `.componentQueryOptions`,
`.layoutQueryOptions`, … — which applies only to a **page / component /
layout self query**, never to a standalone query
3. the query's own closing `.query({...})`
4. the call-site options on `useQuery` / `fetchQuery`
For a standalone query only steps 1, 3, and 4 participate.
Besides the native TanStack options, every one of these sites accepts Point0's
two per-query SSR switches — `ssr?: boolean` (does the server execute the query
during SSR) and `suspend?: 'auto' | 'server' | 'client' | boolean` (does a
pending query suspend into the nearest `.loading`, and where). They merge like
any other option, so `.queryOptions({ suspend: 'server' })` on a root or layout
makes a whole subtree stream. Full semantics:
[the `ssr` and `suspend` query options](ssr#the-ssr-and-suspend-query-options).
On the **server**, Point0 hard-overrides a few of these regardless of what you
set — `retry: false`, no refetch-on-*, `staleTime`/`gcTime: Infinity` — because
a server render fetches once and ships the result. The full list of
`*QueryOptions` methods is in [stage-methods](stage-methods).
`.queryOptions` and the whole `*QueryOptions` family are **not cut from either
bundle** — kept in both (isomorphic): the same defaults have to apply server-
and client-side.
## mode, tags, scope
- **mode** (`'server'` vs `'client'`) is derived, not set: a `.loader` makes it
a server query, a `.clientLoader` a client query. There's one loader per
query, so the mode is unambiguous.
- **tags** come from `.tag('a', 'b')` and ride along in the key, so you can
invalidate or match groups of queries by tag. `.tag` is **not cut from either
bundle** — kept in both (isomorphic): the tag is part of the key on both
sides.
- **scope** identifies which client/root a query belongs to in a multi-client
setup (one server, many clients). It's set by the root you build from, not by
a method.
## Targeting one input, many, or all
The cache mutators — `invalidateQuery`, `refetchQuery`, `removeQuery`,
`resetQuery`, `cancelQuery` (and their infinite siblings) — each take their
first argument in one of three forms:
- an **exact input** (`{ id: 1 }`) — act on that single cache entry;
- a **predicate** `(input) => boolean` — act on every entry whose input matches;
- **`true`** — act on every entry of this query, regardless of input.
```tsx
ideaQuery.invalidateQuery({ id: 1 }) // one entry
ideaQuery.invalidateQuery((i) => i.id > 10) // matching entries
ideaQuery.invalidateQuery(true) // every entry of ideaQuery
```
So `invalidateQuery(true)` is the one-liner for "refresh this query everywhere"
after a mutation — no need to track which inputs are cached. The read helper
`getQueriesCache` takes the same three forms.
To match across _different_ queries — by tag, by scope, or several points at
once — drop down to a raw `invalidateQueries` with
[`getQueryPredicate`](query-client#matching-cache-entries-getquerypredicate).
## Edge cases
- **A disabled query stays pending.** `useQuery(input, { enabled: false })`
never resolves to data — handy for using one query's output as another's input
(it blocks downstream until enabled). [`.with`](with)'s `resolve` is the
cleaner way to express that.
- **`staleTime: Infinity` for hand-managed caches.** When you write the cache by
hand after a mutation (`setQueryData`), set `staleTime: Infinity` so it never
silently refetches.
## Reference
### Method surface
The query method surface is **not cut from either bundle** (server-and-client) —
kept in both. `useQuery` / `fetchQuery` and the cache helpers work on the server
(during SSR) and the client alike; only the underlying `.loader` body (and its
imports) is cut from the client bundle, and `fetchQuery` routes to its endpoint
over HTTP from the browser.
These are plain [TanStack Query](https://tanstack.com/query/latest) methods —
`useQuery`, `fetchQuery`, `invalidateQuery`, `setQueryData`, and the rest behave
exactly as they do in react-query. The only thing Point0 does is build the
`queryKey` and `queryFn` for you from the query's input (and route the `queryFn`
to the right endpoint or client loader), so instead of assembling a
`UseQueryOptions` object you pass the **input** and, optionally, the same
react-query options you'd pass anyway. For the behaviour of any individual
method or option, read the
[TanStack Query docs](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery).
Every method takes the **input** first. The imperative cache/fetch helpers
(`fetchQuery`, `getQueryData`, `invalidateQuery`, …) take a trailing options
object — `{ queryClient?, outputType?, fetchOptions? }`, members varying by
method; `useQuery` / `useInfiniteQuery` take `{ fetchOptions? }`, and
`getQueryKey` takes `{ outputType? }`.
| Method | Signature | Returns |
| -------------------------- | ------------------------------------------------------------ | ---------------------------------------------- |
| `useQuery` | `(input, queryOptions?, options?)` | TanStack `useQuery` result |
| `useSuspenseQuery` | `(input, queryOptions?, options?)` | TanStack suspense result (`data` non-optional) |
| `useInfiniteQuery` | `(input, infiniteOptions?, options?)` | TanStack infinite result |
| `useSuspenseInfiniteQuery` | `(input, infiniteOptions?, options?)` | TanStack suspense infinite result |
| `fetchQuery` | `(input, queryOptions?, options?)` | `Promise` |
| `prefetchQuery` | `(input, queryOptions?, options?)` | `Promise` |
| `ensureQueryData` | `(input, queryOptions?, options?)` | `Promise` |
| `getQueryData` | `(input, options?)` | `data \| undefined` |
| `setQueryData` | `(input, updater, setDataOptions?, options?)` | the new `data` |
| `refetchQuery` | `(input \| predicate \| true, refetchOptions?, options?)` | `Promise` |
| `invalidateQuery` | `(input \| predicate \| true, invalidateOptions?, options?)` | `Promise` |
| `cancelQuery` | `(input \| predicate \| true, cancelOptions?, options?)` | `Promise` |
| `removeQuery` | `(input \| predicate \| true, options?)` | `void` |
| `resetQuery` | `(input \| predicate \| true, resetOptions?, options?)` | `Promise` |
| `getQueryState` | `(input, options?)` | TanStack `QueryState \| undefined` |
| `getQueryCache` | `(input, options?)` | the `Query \| undefined` |
| `getQueriesCache` | `(input \| predicate \| true, options?)` | `Query[]` |
| `getQueryKey` | `(input, options?)` | the `QueryKey` tuple |
| `getQueryOptions` | `(input, queryOptions?, options?)` | fully built `UseQueryOptions` |
Each has an infinite sibling (`fetchInfiniteQuery`, `getInfiniteQueryKey`, …)
for infinite queries.
The `outputType` option selects what a fetch returns — `'data'` (the default) or
one of the dehydrated forms (`'queryClientDehydratedState'`,
`'queryClientDehydratedStateRedirect'`, `'html'`). It's prefetch/SSR plumbing
the framework drives for you, so call sites rarely set it; the [SSR](ssr) page
covers when dehydrated state is fetched. A query's `mode` is derived from its
loader — there's no `mode` argument on these methods.
---
# Point0 — Infinite Query
Source: https://1gr14.dev/point0/v0/infinite-query.md
An infinite query is a [query](query) that loads its data page by page. You
write one loader that returns a single page; Point0 turns it into a standard
TanStack
[`useInfiniteQuery`](https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery)
with a page cache, `fetchNextPage`, and `hasNextPage`. Close with
`.infiniteQuery(options)` instead of `.query()` — it takes the same options
you'd pass `useInfiniteQuery` (`getNextPageParam`, `initialPageParam`,
`maxPages`, `staleTime`, …) plus one Point0-specific addition:
`pageParamFromInput`, which tells Point0 where the page cursor lives in the
input.
```tsx
import { root } from '@/lib/root'
import { z } from 'zod'
export const ideaListQuery = root.lets
.infiniteQuery()
.input(
z.object({ cursor: z.number().optional(), limit: z.number().default(20) }),
)
.loader(async ({ input: { cursor, limit } }) => {
// runs on the server — one page per call
const ideas = await prisma.idea.findMany({
take: limit + 1,
orderBy: { sn: 'desc' },
where: cursor ? { sn: { lte: cursor } } : {},
})
return { ideas, nextCursor: ideas[limit]?.sn } // nextCursor undefined → last page
})
.infiniteQuery({
getNextPageParam: (lastPage) => lastPage.nextCursor, // undefined ⇒ no more pages
initialPageParam: undefined,
pageParamFromInput: 'cursor', // the cursor lives at input.cursor
})
```
Where each call ends up: `.input` and `.loader` are cut from the client bundle —
their bodies and the imports they use are removed, so this code never ships to
the browser (it runs on the server). `.infiniteQuery({...})` is the closer and
is not cut from either bundle — kept in both (isomorphic), so its options
(`getNextPageParam`, `pageParamFromInput`, …) ship to the client too.
```tsx
// anywhere in a component:
const query = ideaListQuery.useInfiniteQuery()
const ideas = query.data?.pages.flatMap((page) => page.ideas) ?? []
// query.fetchNextPage(), query.hasNextPage, query.isFetchingNextPage
```
## Finite vs infinite
A finite [query](query) is one request, one result. An infinite query is many
requests of the same shape stitched into a list. The chain is identical up to
the finalizer:
```tsx
export const ideaPageQuery = root.lets
.query()
.input(z.object({ cursor: z.number().optional() }))
.loader(async ({ input }) => loadPage(input))
.query() // finite: data is one page
export const ideaListQuery = root.lets
.infiniteQuery()
.input(z.object({ cursor: z.number().optional() }))
.loader(async ({ input }) => loadPage(input))
.infiniteQuery({/* ... */}) // infinite: data is { pages, pageParams }
```
The differences, all driven by that last call:
- **The loader contract is the same.** One server `.loader` (or `.clientLoader`)
that returns one plain page object. It must not return a `Response` — a
standalone infinite query (or action) throws a type error
`InfiniteQuery can not return response.` if it does; on a mountable point
(page/layout/component/provider) the same guard reports
`Query can not return response.`
- **The result shape differs.** `useQuery` gives you the page directly;
`useInfiniteQuery` gives you `InfiniteData` —
`{ pages: page[], pageParams: param[] }`.
- **`.infiniteQuery` needs three options** — `getNextPageParam`,
`initialPageParam`, and Point0's `pageParamFromInput` — where a finite
`.query()` takes an optional options object and can be called with nothing.
- **The cache key differs by one field.** The key carries
`finiteness: 'infinite'` instead of `'finite'`; everything else is the same.
So the same point fetched as a finite query and as an infinite query produces
two separate cache entries — they never collide.
Every cache/fetch helper has an infinite twin: `fetchInfiniteQuery` for
`fetchQuery`, `invalidateInfiniteQuery` for `invalidateQuery`, and so on.
## Declaring an infinite query
Open with `.infiniteQuery()`, declare input and a loader, close with
`.infiniteQuery(options)`:
```tsx
export const ideaListQuery = root.lets
.infiniteQuery() // open
.input(z.object({ cursor: z.number().optional() }))
.loader(async ({ input }) => loadPage(input))
.infiniteQuery({
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: undefined,
pageParamFromInput: 'cursor',
})
```
Input, validation, `.loader` / `.clientLoader`, `.ctx`, and the endpoint rules
all work exactly as on a finite [query](query) — see that page for the shared
mechanics.
Stripping is the same as on a finite query: `.input`, `.loader`, and `.ctx` are
cut from the client bundle; `.clientLoader` is cut from the server bundle (it
runs in the browser regardless of SSR); the `.infiniteQuery({...})` closer is
not cut from either bundle — kept in both (isomorphic).
## The three finalizer options
`.infiniteQuery({...})` is just the
[`useInfiniteQuery`](https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery)
options object — every native react-query infinite option passes straight
through. Three of them are required: two are TanStack's own (`getNextPageParam`,
`initialPageParam`), and one is Point0's (`pageParamFromInput`). Pass `{}` and
you get a type error naming the missing fields — `.infiniteQuery` is one
signature, not overloads, so the error points at the real gap instead of "no
overload matches":
```tsx
export const ideaListQuery = root.lets
.infiniteQuery()
.input(z.object({ cursor: z.number().optional() }))
.loader(async ({ input }) => loadPage(input))
.infiniteQuery({}) // type error: getNextPageParam / initialPageParam / pageParamFromInput missing
```
### getNextPageParam
Returns the page param for the **next** page from the last loaded page. Return
`undefined` to say "there are no more pages" — that's what sets `hasNextPage` to
`false`.
```tsx
export const ideaListQuery = root.lets
.infiniteQuery()
.input(z.object({ cursor: z.number().optional() }))
.loader(async ({ input }) => loadPage(input))
.infiniteQuery({
getNextPageParam: (lastPage) => lastPage.nextCursor, // typed as your loader's return
initialPageParam: undefined,
pageParamFromInput: 'cursor',
})
```
`lastPage` is one page — the exact type your loader returns. Offset and cursor
pagination both work: you compute the next param from whatever the last page
carries.
Backward pagination (`getPreviousPageParam` / `fetchPreviousPage`) is part of
TanStack's options and passes straight through.
### initialPageParam
The page param for the very first page. Use `0` for offset pagination, or
`undefined` for cursor pagination that starts with no cursor:
```tsx
export const ideaListQuery = root.lets
.infiniteQuery()
.input(z.object({ offset: z.number().optional() }))
.loader(async ({ input }) => loadPage(input))
.infiniteQuery({
getNextPageParam: (lastPage) => lastPage.nextOffset,
initialPageParam: 0, // offset: start at page 0
pageParamFromInput: 'offset',
})
export const ideaFeedQuery = root.lets
.infiniteQuery()
.input(z.object({ cursor: z.number().optional() }))
.loader(async ({ input }) => loadPage(input))
.infiniteQuery({
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: undefined, // cursor: no initial cursor
pageParamFromInput: 'cursor',
})
```
### pageParamFromInput
This one is Point0-specific and **required**. Native `useInfiniteQuery` keeps
the page param separate from the query; Point0 has a single typed `input` per
query, so it needs to know where in that input the page param goes.
Two forms. A string path points at the field that holds the cursor:
```tsx
export const ideaListQuery = root.lets
.infiniteQuery()
.input(z.object({ cursor: z.number().optional() }))
.loader(async ({ input }) => loadPage(input))
.infiniteQuery({
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: undefined,
pageParamFromInput: 'cursor', // input.cursor; 'page' would point at input.page
})
```
Or an explicit get/set pair for nested or computed placement:
```tsx
export const ideaListQuery = root.lets
.infiniteQuery()
.input(z.object({ paging: z.object({ cursor: z.number().optional() }) }))
.loader(async ({ input }) => loadPage(input))
.infiniteQuery({
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: undefined,
pageParamFromInput: {
get: ({ input, get }) => get(input, 'paging.cursor'),
set: ({ input, value, set }) => set(input, 'paging.cursor', value),
},
})
```
The string form supports dotted paths; `get`/`set` receive Point0's path
helpers, which also understand dotted paths and array indices.
**How it resolves at fetch time.** For each page the value used is
`pageParam ?? `. After the first page, the param from
`getNextPageParam` drives the loader. On the first page `pageParam` is
`initialPageParam`; when that is nullish (e.g. `undefined` for cursor
pagination), the value already in the input is used instead. The chosen value is
written back into the input before the loader runs. A practical consequence:
with `initialPageParam: undefined`, a deep link to `?cursor=500` reads its first
page straight from the input.
## Mountable-embedded: a mountable that paginates itself
Any [mountable](page) — a [page](page), [layout](layout),
[component](component), or [provider](provider) — with a loader is itself a
query, and is **finite by default**. Finalize that self query with
`.infiniteQuery({...})` after its loader instead of leaving the loader plain,
and the mountable paginates its own data. A mountable has no `.input` — it uses
`params` and `search` — so the cursor lives in the search params, and you reach
it with the `?.` prefix:
```tsx
export const ideaListPage = generalLayout.lets
.page('/ideas')
.search(
z.object({
page: z.coerce.number().default(0),
limit: z.coerce.number().default(2),
}),
)
.loader(async ({ search: { page, limit } }) => {
const ideas = await prisma.idea.findMany({
take: limit,
skip: page * limit,
})
const ideasCount = await prisma.idea.count()
const nextCursor = ideasCount > (page + 1) * limit ? page + 1 : undefined
return { ideas, ideasCount, nextCursor }
})
.infiniteQuery({
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
pageParamFromInput: '?.page', // ?. = the page's search-params namespace
})
.mapper(({ data }) => ({
ideas: data.pages.flatMap((page) => page.ideas), // data is InfiniteData here
total: data.pages[0].ideasCount,
}))
.page(({ data: { ideas, total }, queries: [query] }) => (
))
```
In `.mapper` and `.page`, the `data` you receive is the `InfiniteData` itself
(so `data.pages.flatMap(...)`, not a single page), and the finalized infinite
query shows up in `queries` — `queries: [query]` — so you can drive
`fetchNextPage` from the render. Under SSR, Point0 reads
`finiteness: 'infinite'` from the key and prefetches the page as an infinite
query automatically — you wire nothing.
Where each call ends up here: `.search` (and `.params`) on a mountable is not
cut from either bundle — kept in both (isomorphic) — so navigation can build the
query input in the browser. `.loader` is cut from the client bundle (body and
imports removed — it runs on the server). `.mapper` and the `.page` body are cut
from the SERVER bundle when `ssr:false` (or after a `.clientOnly()` earlier in
the chain) — kept in the client build always, and in the server build only when
SSR is on. `.infiniteQuery({...})` is not cut from either bundle — kept in both
(isomorphic).
An [action](action) can also be finalized with `.infiniteQuery({...})`. It needs
a server loader — without one it throws
`Point has no server loader. Please add .loader() before calling .infiniteQuery() to finalize action`.
## Using the result
`useInfiniteQuery` returns TanStack's infinite result, typed to your loader's
page:
```tsx
const query = ideaListQuery.useInfiniteQuery() // input optional when all keys are optional
const query = ideaListQuery.useInfiniteQuery({ authorSn }) // with input
const query = ideaListQuery.useInfiniteQuery(undefined, { staleTime: 0 }) // 2nd arg = options
query.data?.pages // page[] — each is one loader return
query.data?.pages.flatMap((p) => p.items) // the flat list you usually render
query.data?.pages.at(-1) // the last loaded page
query.hasNextPage // false once getNextPageParam returns undefined
query.fetchNextPage() // load the next page
query.isFetchingNextPage // true while a next page is loading
query.isLoading / query.error // first-load and error states
```
A "Load more" button is the common shape: show it while `hasNextPage`, disable
on `isFetchingNextPage`, call `fetchNextPage` on click (see the page-embedded
example above).
**Suspense form.** `useSuspenseInfiniteQuery` is the TanStack v5 suspense twin:
`data.pages` is non-optional in types, a pending FIRST page suspends into the
nearest [`.loading`](loading-error) declared above it, an error throws to the
nearest `.error`; `fetchNextPage` never suspends (TanStack semantics). No
`enabled`, `ssr`, or `suspend` options. During SSR the shell ships with the
fallback and the first page streams into the same response — see
[SSR](ssr#usesuspensequery--usesuspenseinfinitequery):
```tsx
const { data } = ideaListQuery.useSuspenseInfiniteQuery()
```
## Invalidating from a mutation
After a [mutation](mutation) changes the underlying data, invalidate the
infinite query so it refetches. The infinite helper is
`invalidateInfiniteQuery`:
```tsx
export const addIdeaMutation = root.lets
.mutation()
.input(z.object({ title: z.string() }))
.loader(async ({ input }) => ({ ideaId: await createIdea(input) }))
.mutation({
onSuccess: async ({ ideaId }) => {
void ideaListPage.invalidateInfiniteQuery(true) // refetch the list
},
})
```
## Targeting one input, many, or all
The infinite cache mutators — `invalidateInfiniteQuery`, `refetchInfiniteQuery`,
`removeInfiniteQuery`, `resetInfiniteQuery`, `cancelInfiniteQuery` — take their
first argument in the same three forms as the finite
[query helpers](query#targeting-one-input-many-or-all):
- an **exact input** — act on that single cache entry;
- a **predicate** `(input) => boolean` — act on every entry whose input matches;
- **`true`** — act on every entry of this infinite query, regardless of input.
```tsx
ideaListPage.invalidateInfiniteQuery(true) // refresh the whole list, any filter
```
The read helper `getInfiniteQueriesCache` takes the same three forms. To match
across _different_ queries — by tag, by scope, or several points at once — drop
down to a raw `invalidateQueries` with
[`getQueryPredicate`](query-client#matching-cache-entries-getquerypredicate).
## Reference
### `.infiniteQuery(options)`
A **finalizer** — terminal in the chain, like `.query()`. It applies to three
point kinds:
- a standalone **infinite query** point (`root.lets.infiniteQuery()…`);
- a **mountable** point ([page](page), [layout](layout), [component](component),
[provider](provider)) — finalizes that point's own self query as infinite;
- an [action](action) — finalizes the action as an infinite query (server loader
required).
It requires a loader. On a loader-less point it's a type error
(`…has no loaders. Please add .loader() or .clientLoader()…`); on an
already-finalized point it's a type error (`…already finalized`).
The closer is not cut from either bundle — kept in both (isomorphic) in all
three cases. On an **action**, `.input`/`.params`/`.search` are cut from the
client bundle — body and imports removed, never shipped to the browser — whereas
on a mountable they're kept in both bundles (isomorphic); this affects the
stage-methods, not the `.infiniteQuery` closer.
### Options
`.infiniteQuery({...})` takes TanStack's native `useInfiniteQuery` options
(minus `queryFn` / `queryKey`, which Point0 generates) plus the required
`pageParamFromInput`.
| Option | Required | What |
| ---------------------------------------------------------------- | -------- | -------------------------------------------------------------------- |
| `getNextPageParam` | yes | `(lastPage) => nextParam \| undefined` — `undefined` ends pagination |
| `initialPageParam` | yes | the page param for the first page |
| `pageParamFromInput` | yes | string path, or `{ get, set }` — where the cursor lives in the input |
| `staleTime`, `gcTime`, `retry`, `select`, lifecycle callbacks, … | no | any native TanStack infinite option, passes through |
Lifecycle callbacks (`onSuccess` / `onError` / `onSettled`) from multiple layers
are **chained, not overwritten** — each runs in order.
Point0's per-query SSR switches — `ssr?: boolean` and
`suspend?: 'auto' | 'server' | 'client' | boolean` — are accepted here and at
every call site too, same as on a finite query:
[the `ssr` and `suspend` query options](ssr#the-ssr-and-suspend-query-options).
### Defaults and precedence
Set infinite defaults with `.infiniteQueryOptions(...)` on the root or a base
(partial, so `pageParamFromInput` is optional there). For one query, options
resolve lowest-to-highest:
1. root/base `.queryOptions(...)`
2. root/base `.infiniteQueryOptions(...)`
3. the closing `.infiniteQuery({...})`
4. the call-site options on `useInfiniteQuery` / `fetchInfiniteQuery` / …
`.infiniteQueryOptions(...)` and `.queryOptions(...)` are option setters —
they're not cut from either bundle, kept in both (isomorphic) like the closer
itself.
On the **server**, Point0 hard-overrides retry/refetch/`staleTime` the same way
it does for finite queries (a server render fetches once). See
[Query → Defaults](query) and [stage-methods](stage-methods).
### Method surface
Each helper takes the **input** first and a trailing `options` object
(`{ queryClient?, fetchOptions?, outputType? }`, members varying by method). For
a client-loader-only infinite query only the server-fetch trio (`fetchServer`,
`fetchServerDetailed`, `getFetchServerOptions`) drops off; `fetch`,
`useInfiniteQuery`, and the cache/fetch helpers all stay.
| Method | Signature | Returns |
| -------------------------- | ------------------------------------------------------------ | ------------------------------------------------------- |
| `useInfiniteQuery` | `(input?, infiniteOptions?, options?)` | TanStack infinite result |
| `useSuspenseInfiniteQuery` | `(input?, infiniteOptions?, options?)` | TanStack suspense infinite result (`data` non-optional) |
| `fetchInfiniteQuery` | `(input?, infiniteOptions?, options?)` | `Promise` |
| `prefetchInfiniteQuery` | `(input?, infiniteOptions?, options?)` | `Promise` |
| `ensureInfiniteQueryData` | `(input?, infiniteOptions?, options?)` | `Promise` |
| `getInfiniteQueryData` | `(input?, options?)` | `InfiniteData \| undefined` |
| `setInfiniteQueryData` | `(input?, updater, setDataOptions?, options?)` | the new `InfiniteData` |
| `refetchInfiniteQuery` | `(input \| predicate \| true, refetchOptions?, options?)` | `Promise` |
| `invalidateInfiniteQuery` | `(input \| predicate \| true, invalidateOptions?, options?)` | `Promise` |
| `cancelInfiniteQuery` | `(input \| predicate \| true, cancelOptions?, options?)` | `Promise` |
| `removeInfiniteQuery` | `(input \| predicate \| true, options?)` | `void` |
| `resetInfiniteQuery` | `(input \| predicate \| true, resetOptions?, options?)` | `Promise` |
| `getInfiniteQueryState` | `(input?, options?)` | TanStack query state `\| undefined` |
| `getInfiniteQueryCache` | `(input?, options?)` | the `Query \| undefined` |
| `getInfiniteQueriesCache` | `(input \| predicate \| true, options?)` | `Query[]` |
| `getInfiniteQueryOptions` | `(input?, infiniteOptions?, options?)` | fully built infinite options |
| `getInfiniteQueryKey` | `(input?, options?)` | the infinite `QueryKey` tuple |
| `getQueryKey` | `(input?, options?)` | the finite `QueryKey` tuple |
### The infinite query key
An infinite read sits in its own cache entry, keyed with
`finiteness: 'infinite'`. The tuple is the same two-element shape as a finite
[query](query)'s, with that one field flipped:
```tsx
// the infinite cache entry's key:
// [ 'point0', { scope, type, name, mode, finiteness: 'infinite', tags, output, input } ]
```
That one flipped field is why a finite and an infinite read of the same point
sit in separate cache entries and never collide. The `input` is serialized
deterministically; for page/layout infinite queries, only declared `.search`
keys survive into the key. Full key mechanics are on the [Query](query) page.
Two key getters sit on the typed surface: `getInfiniteQueryKey` returns the
**infinite** key (`finiteness: 'infinite'`) — the one for this point's cache
entry — and `getQueryKey` returns the **finite** key, for matching a finite read
of the same point. Pass the same input to either. The infinite helpers
(`invalidateInfiniteQuery`, `getInfiniteQueriesCache`, …) target the infinite
entry without building a key by hand.
### Events
An infinite query emits its own lifecycle events around the fetch —
`pointInfiniteQueryStart`, `pointInfiniteQuerySettled`,
`pointInfiniteQuerySuccess`, `pointInfiniteQueryError` — distinct from the
finite `pointQuery*` events. See [Events](events).
### Other native options pass straight through
The option layers are merged and spread onto the final `useInfiniteQuery`
options without a key whitelist, so native TanStack options Point0 doesn't name
explicitly — `maxPages`, `select`, `structuralSharing`, and so on — flow through
unchanged. Point0 only overrides `queryKey` / `queryFn` (it generates those)
and, on the server, the retry/refetch/`staleTime`/`gcTime` fields. A non-default
`outputType` (passed in the trailing `options`) is honored as well.
---
# Point0 — Mutation
Source: https://1gr14.dev/point0/v0/mutation.md
A mutation pairs an input schema with a loader that does the write. It's a real
HTTP endpoint (its own `POST` path, in the OpenAPI spec) and, at the same time,
a thin wrapper over a classic
[TanStack (react-query) mutation](https://tanstack.com/query/latest/docs/framework/react/guides/mutations)
— declared once, called anywhere by importing it directly.
```tsx
import { root } from '@/lib/root'
import { authorizedOnlyPlugin } from '@/lib/auth'
import { z } from 'zod'
export const ideaCreateMutation = root.lets
.mutation()
.use(authorizedOnlyPlugin) // brings ctx.me — the current user
.input(z.object({ title: z.string().min(1), content: z.string().min(1) }))
.loader(async ({ ctx, input: { title, content } }) => {
// cut from the client bundle — this body and its imports never ship to the browser
const idea = await prisma.idea.create({
data: { title, content, authorId: ctx.me.user.id },
})
return { idea }
})
.mutation()
```
```tsx
// anywhere in a component:
const mutation = ideaCreateMutation.useMutation()
await mutation.mutateAsync({ title, content })
```
The first argument to every mutation call is the **input** — that's what gets
validated, sent to the loader, and used to find a specific call in the mutation
cache.
## Declaring a mutation
Open with `.mutation()`, declare input and a loader, close with
`.mutation(options?)`:
```tsx
export const ideaCreateMutation = root.lets
.mutation() // open
.input(z.object({ title: z.string().min(1) }))
.loader(async ({ input }) => ({ idea: await createIdea(input) }))
.mutation() // close
```
See [points](points) for the `.lets` notation.
The two `.mutation()` calls are not the same method: the first **opens** a
mutation-stage point, the second **closes** it into a ready mutation. You must
add a [`.loader`](loader) (or `.clientLoader`) before closing — calling
`.mutation()` with no loader is a type error:
```tsx
root.lets.mutation().input(/* ... */).mutation()
// type error: Point has no loaders. Please add .loader()
// or .clientLoader() before calling .mutation()
```
The closing `.mutation(options?)` is **server-and-client** — not cut from either
bundle, kept in both (isomorphic): the options carry react-query defaults the
browser needs.
## The loader is the mutation body
The [`.loader`](loader) is the function that runs when the mutation fires; its
return value becomes the mutation's `data`:
```tsx
export const ideaCreateMutation = root.lets
.mutation()
.input(z.object({ title: z.string().min(1) }))
.loader(async ({ input }) => {
const idea = await prisma.idea.create({ data: input })
return { idea } // => mutation.data is { idea }
})
.mutation()
```
The server `.loader` is **server-only** — cut from the client bundle: its body
and the imports it uses are removed, so it never ships to the browser.
A mutation has exactly one loader — either a server loader or a client loader,
never both (see [one loader per point](loader#one-loader-per-point)):
```tsx
// either: a server loader → cut from the client bundle (body + imports gone); the mutation is an HTTP endpoint
export const ideaCreateMutation = root.lets
.mutation()
.input(z.object({ title: z.string().min(1) }))
.loader(async ({ input }) => ({
idea: await prisma.idea.create({ data: input }),
}))
.mutation()
// or: a client loader → cut from the server bundle (body + imports gone); no server request at all
export const draftSaveMutation = root.lets
.mutation()
.clientInput(z.object({ title: z.string().min(1) }))
.clientLoader(async ({ input }) => ({ ok: await callSomeBrowserApi(input) }))
.mutation()
```
`.clientLoader` is **client-only** — cut from the server bundle: body and its
imports removed (regardless of SSR), so it runs only in the browser.
With a server loader, calling the mutation sends one request to the server; with
a client-only loader, nothing leaves the browser. See [Loader](loader) for the
full callback surface and how server code is removed from the client build.
## Input and validation
`.input(schema)` takes any [Standard Schema](validation) — zod, valibot,
arktype, and others — or a custom validate function:
```tsx
export const ideaCreateMutation = root.lets
.mutation()
.input(z.object({ title: z.string().min(1), content: z.string().min(1) }))
.loader(async ({ input }) => ({ idea: await createIdea(input) }))
.mutation()
```
For a client loader, use `.clientInput` (client side) or `.sharedInput` (both
sides). Input schemas **merge down the chain**: a parent — a [base](base) or
[plugin](plugin) — can declare part of the input and the mutation adds the rest.
`.input` is **server-only** — cut from the client bundle: the server validation
schema and the imports it uses are removed, so it never ships to the browser.
`.clientInput` is **client-only** — cut from the server bundle: body and its
imports removed. `.sharedInput` is **server-and-client** — not cut from either
bundle, kept in both (isomorphic), because the same schema validates on the
server and in the browser.
A mutation uses `.input` (plus `.clientInput` / `.sharedInput`) — **not**
`.params` and `.search` (those belong to [pages](page) and [actions](action))
and not `.body` (actions only); all three are a type error on a mutation:
```tsx
root.lets.mutation().params(/* ... */)
// type error: For "mutation" not allowed "params" schema.
// Only "input" are allowed.
```
A mutation with no input schema takes an optional input — `mutation.mutate()`
with no argument works. File inputs work too: a `z.instanceof(File)` field is
sent as multipart and the loader receives a real `File`. Full schema mechanics
live in [Validation](validation); file handling in [File upload](file-upload).
## Using a mutation
**As a hook.** `useMutation` returns the standard TanStack `UseMutationResult` —
`.mutate`, `.mutateAsync`, `.data`, `.isPending`, `.error`, and the rest:
```tsx
const mutation = ideaCreateMutation.useMutation()
// input is the first argument; per-call options follow it
await mutation.mutateAsync(
{ title, content },
{
onSuccess: async ({ idea }) => {
await navigate('ideaView', { id: idea.id })
},
},
)
mutation.isPending // disable the submit button while it runs
```
**Imperatively, no hook.** `fetchMutation` runs the mutation outside a React
render and resolves to the loader's output. Input is the first argument:
```tsx
const { idea } = await ideaUpdateMutation.fetchMutation({ id, title, content })
await navigate('ideaView', { id: idea.id })
```
Pass `{ queryClient }` in the trailing options to target a specific client;
otherwise the global one is used.
## react-query options
The closing `.mutation({...})` takes standard TanStack `useMutation` options —
everything **except** `mutationFn` and `mutationKey`, which Point0 supplies.
They become the mutation's defaults and are the natural place to update other
caches on success:
```tsx
export const ideaCreateMutation = root.lets
.mutation()
.input(ideaCreateSchema)
.loader(async ({ input }) => ({ idea: await createIdea(input) }))
.mutation({
onSuccess: async ({ idea }) => {
// seed the view query's cache so the next page reads it instantly
ideaViewQuery.setQueryData({ id: idea.id }, { idea })
},
})
```
`onSuccess` here receives the loader's output, typed; `onError`, `onSettled`,
`onMutate`, `retry`, `gcTime`, and the rest are all available and typed against
the real output and input.
Per-call options on `useMutation` / `mutateAsync` / `fetchMutation` **merge**
with these defaults rather than replacing them — the callbacks chain, so both an
`onSuccess` on `.mutation({...})` and an `onSuccess` on `mutateAsync(...)` run,
in order.
**`gcTime`** decides how long a settled call's entry stays in the mutation cache
— the cache's only memory cost. For a high-frequency mutation whose call history
nobody reads, `gcTime: 0` drops each entry as it settles;
`.mutationOptions({ gcTime: 0 })` on the root covers a whole subtree.
## Defaults from up the chain
Set mutation defaults once on the [root](root), a [base](base), or a
[plugin](plugin) with `.mutationOptions(...)`, and they apply to every mutation
beneath. There is no `.mutationOptions()` on the mutation itself — per-mutation
options go through the closing `.mutation({...})`. `.mutationOptions` is
**server-and-client** like the closer — not cut from either bundle, kept in both
(isomorphic). Resolution is lowest-to-highest:
1. root/base/plugin `.mutationOptions(...)`
2. the mutation's own closing `.mutation({...})`
3. the call-site options on `useMutation` / `mutateAsync` / `fetchMutation`
Callbacks (`onMutate` / `onSuccess` / `onError` / `onSettled`) at every level
run in order rather than overwriting each other.
## Authorization
`.ctx` 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 **when the
mutation runs its server loader**, so a `.use(plugin)` that resolves the current
user into `ctx` lets the loader gate the write server-side:
```tsx
export const ideaUpdateMutation = root.lets
.mutation()
.use(authorizedOnlyPlugin) // ctx.me — resolved server-side
.input(ideaUpdateMutationSchema)
.loader(async ({ ctx, input: { sn, title, content } }) => {
const existing = await prisma.idea.findUniqueOrThrow({
select: { authorId: true },
where: { sn },
})
if (existing.authorId !== ctx.me.user.id) {
throw new AppError('Only the author can edit this idea', {
code: 'FORBIDDEN',
})
}
return {
idea: await prisma.idea.update({
where: { sn },
data: { title, content },
}),
}
})
.mutation()
```
`AppError` here is your own error class (`ErrorPoint0` by default, or whatever
you set via `.errorClass(...)` — see [error handling](error-handling)); throwing
it from the loader rejects the mutation, and `mutation.error` is typed as that
class. Unlike a [page](page) — where a loader-less point never runs `.ctx`, so
auth must be gated in [`.with`](with) — a mutation with a server loader always
runs it, so a ctx gate inside that loader always fires. (A `.clientLoader`-only
mutation never reaches the server, so there is nothing to gate there.)
## A real endpoint
A mutation with a server loader is served over HTTP at an auto-generated path —
`POST /_point0//mutation/` — with the input in the request
body, and it shows up in the generated [OpenAPI](openapi) spec. You never write
the path; `useMutation` / `fetchMutation` route to it for you. Unlike a single
RPC-style endpoint, every mutation has its own stable URL, which is what gives
full OpenAPI coverage.
A mutation whose only loader is a `.clientLoader` runs in the browser and has
**no endpoint** (and no OpenAPI entry).
A mutation is always a `POST` — there's no per-mutation method or path override.
The HTTP method and a custom route are the [action](action) surface: passing a
method opens an action, not a mutation. To group endpoints under a custom
prefix, set it on the root/base; the default is `_point0`.
## The mutation key
You rarely touch it, but knowing its shape helps when reading the cache.
`getMutationKey()` takes **no arguments** and returns a two-element tuple:
```tsx
ideaCreateMutation.getMutationKey()
// [
// 'point0',
// {
// scope: 'root', // which client/root this point grows from
// type: 'mutation',
// name: 'ideaCreate',
// tags: [], // from .tag(...)
// },
// ]
```
Unlike a [query](query) key, the mutation key carries **no input** — input is
per call, so it lives in each cached mutation's `variables`, not in the key.
`tags` come from `.tag('a', 'b')` and let you match groups of mutations.
To filter the react-query mutation cache, the exported `getMutationPredicate`
helper matches on `id` (= `scope:type:name`), `tags`, `scope`, `type`, or
`name`:
```tsx
import { getMutationPredicate } from '@point0/core'
queryClient.getMutationCache().findAll({
predicate: getMutationPredicate({ tags: 'idea' }),
})
```
> **GOTCHA:** for a tags **array**, the mutation predicate matches if **any**
> tag is present (`some` / OR), whereas the query predicate requires **all**
> tags (`every` / AND). `getMutationPredicate({ tags: ['a', 'b'] })` matches a
> mutation tagged with `a` _or_ `b`.
## Reading the mutation cache
Each `.mutate(input)` creates a cache entry keyed by its `variables`. Two
accessors read it back:
```tsx
// one entry — exact input match
const m = ideaCreateMutation.getMutationCache({ id: 7 })
m?.state.variables // => { id: 7 }
m?.state.data // => the loader output, or undefined if not run yet
// many entries — three filter modes for the first argument:
ideaCreateMutation.getMutationsCache(true) // all entries for this mutation
ideaCreateMutation.getMutationsCache({ id: 7 }) // exact-input matches
ideaCreateMutation.getMutationsCache((v) => v.id === 7) // predicate over variables
```
`getMutationCache` returns the single matching `Mutation` or `undefined`;
`getMutationsCache` always returns an array. Input matching is exact on the
stringified variables (using the point's [transformer](transformer)), and
ignores tags. An absent input is the `{}` input on both sides of that compare,
so a mutation run with no input is found by `getMutationCache()` and by
`getMutationCache({})` alike.
Entries live in the cache for their `gcTime` after settling — a mutation running
with `gcTime: 0` leaves nothing behind for these accessors to find.
## Edge cases
- **Server execution throws.** A mutation's `mutationFn` is client-only; calling
it on the server throws. For a server-side write, use `engine.execute` /
`Executor.execute`, or the `execute` function passed to a loader/ctx.
- **A loader can redirect.** `throw redirect('ideaView', { id })` inside the
loader navigates the client after the mutation settles — see
[Navigation](navigation).
- **Events fire on every run.** Each mutation emits `pointMutationStart`, then
`pointMutationSettled` plus `pointMutationSuccess` (or `pointMutationError`).
Subscribe with `.on` / `.serverOn` / `.clientOn` — see [Events](events).
## Reference
### Method surface
A ready mutation point exposes:
| Method | Signature | Returns |
| -------------------- | ---------------------------------------- | ----------------------------------- |
| `useMutation` | `(mutationOptions?, options?)` | TanStack `UseMutationResult` |
| `fetchMutation` | `(input, mutationOptions?, options?)` | `Promise` |
| `getMutationOptions` | `(mutationOptions?, options?)` | built react-query `MutationOptions` |
| `getMutationKey` | `()` | the `MutationKey` tuple |
| `getMutationCache` | `(input, options?)` | `Mutation \| undefined` |
| `getMutationsCache` | `(input \| predicate \| true, options?)` | `Mutation[]` |
`mutationOptions` is the [react-query options](#react-query-options) (no
`mutationFn` / `mutationKey`); `options` is `{ fetchOptions?, queryClient? }`
(members vary by method). On `useMutation` / `getMutationOptions` the input is
not a method argument — it's passed to `.mutate` / `.mutateAsync` at call time;
`fetchMutation` and `getMutationCache` take the input as their first argument.
Also exposed: `id` (= `` `${scope}:${type}:${name}` ``), `type` (`'mutation'`),
`tags`, `point` (the self-reference), and `Infer` (type inference — see
[Infer](infer)).
A mutation with a server loader also exposes the lower-level server-fetch
helpers — `fetchServer`, `fetchServerDetailed`, and `getFetchServerOptions` —
that fire the mutation's `POST` directly, outside the mutation cache. (`fetch`
on a mutation is **not** one of these: it delegates to `fetchMutation`, running
through the mutation cache and its callbacks.) `fetchServer` resolves to the
loader output (throwing on error or redirect); `fetchServerDetailed` resolves to
`{ data, response, error, redirect }`, so you can read the raw status — handy
when the loader returns a `[status, data]` tuple. They take the input first,
then optional `fetchOptions`. These are an escape hatch; reach for `useMutation`
/ `fetchMutation` first.
### Methods that apply to a mutation
Data & context: [`.loader`](loader), `.clientLoader`, [`.ctx`](ctx),
[`.input` / `.clientInput` / `.sharedInput`](validation), `.use` (plugins).
Strip categories for these: `.loader`, `.ctx`, `.input` are **server-only** —
cut from the client bundle (body + the imports they use removed, so they never
ship to the browser); `.clientLoader`, `.clientInput` are **client-only** — cut
from the server bundle (body + imports removed); `.sharedInput` and `.use` are
**server-and-client** — not cut from either bundle, kept in both (isomorphic).
Shared: [`.middleware`](middleware), `.on` / `.serverOn` / `.clientOn` (events),
`.tag`, `.description`, `.fetchOptions`, `.headers`, `.cookies`.
Strip categories: `.middleware`, `.serverOn`, `.description`, `.headers`,
`.cookies` are **server-only** — cut from the client bundle (body + imports
removed); `.clientOn` is **client-only** — cut from the server bundle (body +
imports removed); `.tag`, `.on`, and `.fetchOptions` are **server-and-client** —
not cut from either bundle, kept in both (isomorphic).
This mutation's own react-query options are the argument to the closing
`.mutation({...})` (see [react-query options](#react-query-options)); chain-wide
defaults come from `.mutationOptions(...)` on the [root](root) / [base](base) /
[plugin](plugin) above. Both are **server-and-client**: not cut from either
bundle, kept in both (isomorphic).
---
# Point0 — Action
Source: https://1gr14.dev/point0/v0/action.md
An action is a point that becomes a real HTTP endpoint with a method and path
you choose. Queries and mutations also serve over HTTP, but at
framework-assigned URLs that always speak point0's own JSON; an action gives you
the route, the verb, and — unlike a query — the freedom to return a native
`Response`. Reach for it when you need a webhook, a REST-ish endpoint, or raw
bytes.
```ts
import { root } from '@/lib/root'
export const apiHealthAction = root.lets.action('GET', '/api/health').action(
() =>
new Response('OK', {
status: 200,
headers: { 'content-type': 'text/plain' },
}),
)
// GET /api/health → 200 "OK"
```
`apiHealthAction.route` is the route you declared, callable like any other:
`fetch(apiHealthAction.route.abs())`.
## When to use an action
A [query](query) and a [mutation](mutation) cover most server calls and give you
a TanStack cache for free, on a stable framework URL
(`POST /_point0/.../query/...`, `POST /_point0/.../mutation/...`). Use an
**action** when you need something they can't give:
- **A specific method and path** — `GET /api/health`, `PUT /api/ideas/:id`. A
query and a mutation always live at a framework-assigned `POST` URL.
- **A raw `Response`** — plain text, bytes, a custom `Content-Type`, a redirect
of your own. A [mutation](mutation) loader can also return a `Response`, so
this alone doesn't force an action — but a query loader is type-blocked from
returning one.
- **A webhook** — a third party posts to a URL you publish and often needs the
unparsed request body (e.g. signature verification).
- **A REST-ish endpoint** other tools call by URL.
If you don't need any of these, prefer a query or mutation — you get caching and
a flat input for free.
## Declaring an action
An action needs a **method** and a **route**, and is closed with `.action(...)`.
Two notations, both equivalent:
```ts
// short: name inferred from the variable
export const apiHealthAction = root.lets
.action('GET', '/api/health')
.action(/* ... */)
// shortest: the verb leads, '.action' is implied
export const apiHealthAction = root.lets('GET', '/api/health').action(/* ... */)
```
Both expand to the explicit
`root.lets('action', 'apiHealth', 'GET', '/api/health')`, where the name comes
from the variable — see [points](points) for the notation. The method is
uppercased for you, so `'get'` and `'GET'` are the same.
Both the method and the route are required — leave either off an action and you
get `Method is required for action point` /
`Route is required for action point`. The seven popular verbs
(`GET POST PUT DELETE PATCH OPTIONS HEAD`) work in the short form; an exotic
method needs the explicit long form:
```ts
root.lets('action', 'report', 'REPORT', '/api/report').action(/* ... */)
```
point0 auto-handles an `OPTIONS` request only when no action matches its route —
an unmatched `OPTIONS` gets a blank `204`. For real CORS preflight handling,
declare it yourself (an `OPTIONS` action, or the [CORS](cors) helper) — the
framework doesn't add CORS headers for you.
When the name is inferred, the action is named after its method and route:
```ts
const a = root.lets('POST', '/api/my-test/:id').action(/* ... */)
a.point.name // => "POST /api/my-test/:id"
a.method // => "POST"
a.route.definition // => "/api/my-test/:id"
```
### Wildcards
An action's route may end in a wildcard — the file-server shapes:
```ts
const files = root.lets('GET', '/files/*').action(({ params }) => {
return new Response(`serving ${params['*']}`)
})
const raw = root.lets('GET', '/raw/*.:ext').action(({ params }) => {
return new Response(`${params['*']} as ${params.ext}`)
})
```
Dispatch stays deterministic: endpoints route through the same
specificity-ordered matching pages use, so a wildcard answers only what no more
specific action claims — `GET /files/special` and `GET /files/:id[int]` both win
over `GET /files/*` regardless of declaration order, and two same-method actions
that genuinely overlap are still rejected as a conflict at startup.
One dispatch rule to know: **endpoints match before pages.** A `GET /files/*`
action owns everything under `/files/` for GET — a page at `/files/readme`
becomes unreachable. Keep GET wildcards on API-only prefixes, or serve the page
from the action yourself.
### The route prefix is inherited
Like a page, an action extends a [base](base)'s `basePath`:
```ts
const base = root.lets('base', 'api').basePath('/my/prefix').base()
const a = base.lets('POST', '/api/my-test/:id').action(/* ... */)
a.route.definition // => "/my/prefix/api/my-test/:id"
```
An action's route always resolves against the **server URL** (`serverUrl`), not
the client URL — actions are API endpoints that live on the server.
## Input: params, search, body — not `input`
A query uses one flat `.input`. An action **splits** its input by where it comes
from in the HTTP request:
```ts
const action = root.lets
.action('POST', '/api/my-test/:id')
.params(z.object({ id: z.string().min(1) })) // from the path
.headers(z.object({ x: z.string().min(1) })) // from request headers
.search(z.object({ y: z.string().min(1) })) // from the query string
.body(z.object({ b: z.number().min(1) })) // from the request body
.action(({ params, search, headers, body }) => {
return { params, search, headers, body }
})
```
Each schema is any [Standard Schema](validation) (zod, valibot, arktype, …). The
route's param keys are auto-merged into the `params` schema. Writing `.input`
(or `.clientInput` / `.sharedInput`) on an action is a type error:
`For "action" not allowed "input" schema. Only "params", "search" and "body" are allowed.`
Cut from the client bundle — their bodies and the imports they use are removed,
so they never ship to the browser. On an action, `.params`, `.search`, `.body`,
`.headers`, and `.cookies` parse the incoming HTTP request on the server. (On a
non-action mountable, `.params`/`.search` are isomorphic; an action has no
client render, so here they're server-only.)
`.cookies(schema)` is also accepted, alongside `.headers`. It validates
`request.cookies` — the `Cookie` header parsed into a flat `{ [name]: value }`
map of decoded strings — and the parsed result lands on the loader's `cookies`
field.
## The body, and webhooks
If you declare a `.body` schema, point0 reads and parses the request body for
you — JSON by default, or multipart form data when the `Content-Type` is
`multipart/form-data` — and keeps the original on `request.rawBody`:
```ts
// POST /api/raw-body/store with {"b":777}
export const storeRawBodyAction = root.lets
.action('POST', '/api/raw-body/store')
.body(z.object({ b: z.number() }))
.loader(({ request, body }) => {
body // => { b: 777 } (parsed)
request.rawBody // => '{"b":777}' (original text)
request.original.bodyUsed // => true (framework consumed it)
})
.action()
```
With **no** `.body` schema, point0 doesn't touch the body — `request.original`
stays unread, so you can read the raw stream yourself. This is the webhook
shape: skip `.body`, read the raw text, verify the signature:
```ts
export const stripeWebhookAction = root.lets
.action('POST', '/api/webhooks/stripe')
.loader(async ({ request }) => {
const event = await stripe.webhooks.constructEvent(
await request.original.text(), // raw, unparsed body
request.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET,
)
await handleStripeEvent(event)
return { received: true }
})
.action()
```
## The loader and the return value
An action's loader receives the parsed request and returns the output. The
callback object is
`{ request, params, search, body, headers, cookies, set, ctx, data, points }`
(each schema-derived field present only when its schema is set).
Cut from the client bundle — its body and the imports it uses never ship to the
browser. The `.loader` (and the `.action` server fn below) is the endpoint body,
so DB calls and secrets stay on the server.
You can pass the loader **inline** to `.action(fn)` instead of a separate
`.loader`:
```ts
root
.lets('POST', '/api/echo')
.body(z.object({ msg: z.string() }))
.action(({ body }) => body)
```
…or declare `.loader(fn)` and close with a bare `.action()`:
```ts
root
.lets('POST', '/api/echo')
.body(/* ... */)
.loader(({ body }) => body)
.action()
```
A bare `.action()` with no prior loader is an error.
**What the loader may return**:
```ts
export const echoAction = root.lets
.action('POST', '/api/echo')
.body(z.object({ msg: z.string() }))
.action(({ body }) => {
// plain data → serialized as JSON
return { received: true }
// a native Response → returned as-is (text, bytes, custom Content-Type)
return new Response('OK', {
status: 200,
headers: { 'content-type': 'text/plain' },
})
// [status, data] → sets the HTTP status
return [201, { id }]
})
```
Returning a plain object serializes it to JSON (with your
[`.transformer`](transformer), so `Date` / `bigint` round-trip when point0's own
client fetches it). Returning a `Response` ships it untouched — when there's a
response there's no `data`. A returned `Error` is thrown; a returned
`RedirectTask` redirects.
Whether the transformer runs is decided by one request header,
`x-point0-transform`, not by who's calling — so any caller, point0 or not, can
opt in or out:
- **`x-point0-transform: true`** — point0 serializes the response with your
[`.transformer`](transformer), and a client that deserializes with the same
transformer gets `Date` / `bigint` back as real values. point0's own client
sets this header automatically when it fetches the endpoint.
- **Header absent** (a plain `fetch`, curl, a third-party service) — no
transformer round-trip; the body is plain JSON, so a `Date` arrives as an ISO
string `"2026-03-11T12:00:00.000Z"`. You can still send the header yourself
from a plain `fetch` if you want the transformed shape, as long as your side
knows how to deserialize it.
The header is also surfaced in the generated [OpenAPI](openapi) spec (for any
endpoint whose point has a transformer), so callers reading the spec can see the
opt-in.
### Setting status, headers, cookies
The loader's `set` helper writes response status, headers, and cookies — it
applies to a plain return and to a manually returned `Response` alike:
```ts
export const signInAction = root.lets
.action('POST', '/api/sign-in')
.body(z.object({ email: z.string(), password: z.string() }))
.action(async ({ set, body }) => {
const { id, token } = await signIn(body)
set.status(201)
set.cookies('session', token, { httpOnly: true })
return { id }
})
```
`set` is the same response-effects helper documented for [mutations](mutation)
and on the [Response](response) page; the full shape lives there.
## Consuming an action as a query, mutation, or infinite query
Besides closing with `.action()`, an action can be **finalized** as a query, a
mutation, or an infinite query. You keep the custom route and the split
`{ params, search, body }` input, and gain the matching TanStack surface.
```ts
// finalize as a query — gives useQuery / fetchQuery
const action = root.lets('GET', '/api/my-test/:id').loader(/* ... */).query()
// inject it like any query
page.lets
.page('/:id')
.with(action, ({ params }) => ({ params }))
.page(/* ... */)
```
```ts
// finalize as a mutation — gives useMutation
const action = root
.lets('POST', '/api/my-test')
.body(z.object({ y: z.number() }))
.loader(/* ... */)
.mutation()
const mutation = action.useMutation()
mutation.mutate({ body: { y: 123 } }) // input is the structured { params?, search?, body? }
```
Each of these needs a prior `.loader`
(`Point has no server loader. Please add .loader() before calling .query() to finalize action`).
And finalizing as a `.query()` rejects a loader that returns a `Response`:
`Query can not return response. Last loader should provide plain object data, not response.`
So a `Response`-returning action can only close with `.action()`.
For the infinite case, wire the cursor with `pageParamFromInput` (e.g.
`'body.cursor'`) exactly as on a [page's self query](infinite-query).
## OpenAPI
An action shows up in the generated [OpenAPI](openapi) spec under its method and
route, with `params`/`search`/`headers` mapped to path/query/header parameters.
The output type is **not** inferred from the loader — declare it with
`.response`:
```ts
export const ideaUpdateAction = root.lets
.action('PUT', '/api/ideas/:id')
.body(z.object({ title: z.string().min(1), content: z.string().min(1) }))
.response(z.object({ idea: ideaSchema })) // documents the 200 response body
.openapi({ summary: 'Update idea', tags: ['ideas'] }) // operation metadata
.action(async ({ params: { id }, body }) => {
const idea = await prisma.idea.update({ where: { id }, data: body })
return { idea }
})
```
`.response` takes one schema (wrapped as a `200` `application/json` response) or
a `{ [status]: schema }` map. `.openapi` sets operation metadata (`summary`,
`description`, `operationId`, `tags`, `deprecated`). Without `.response`, the
spec lists only `200: Successful response`. Full details on [OpenAPI](openapi).
Cut from the client bundle — `.response`, `.openapi`, and `.description` only
feed the server-generated spec and never ship to the browser.
## Gating an action
An action is a public HTTP endpoint — anyone who knows the URL can hit it. Its
loader body, and the imports it uses, are cut from the client bundle at compile
time, so DB calls and secrets never ship; but the _route_ is open, so
authorization has to happen on the server, in the request path.
Gate it from [`.ctx`](ctx) (or a server-side [`.middleware`](middleware) /
[plugin](plugin)): return (or throw) an error there. `.ctx` runs in the endpoint
executor and always fires when the action is fetched, so it's the right place
for the check.
Cut from the client bundle — `.ctx`, `.middleware`, and the server work of a
[`.use`](plugin) plugin never ship to the browser, so the check can't be
bypassed from there: it isn't in the client build. (`.use` itself stays in both
bundles; only its server hooks are cut from the client.)
```ts
import { authPlugin } from '@/lib/auth' // puts the user into props.me
import { AppError } from '@/lib/error' // your own error class (or ErrorPoint0)
export const adminExportAction = root.lets
.action('GET', '/api/export')
.use(authPlugin)
.ctx(({ props: { me } }) => {
if (!me?.isAdmin) return new AppError('Forbidden', { code: 'FORBIDDEN' })
return { me }
})
.action(/* ... */)
```
## Edge cases
- **Conflicting routes throw at startup.** Two actions whose routes collide for
the same method (`POST /api/my-test/:id` vs `POST /api/my-test/:sn` — param
names don't matter) fail when the engine builds:
`Conflicted endpoint routes for method "POST"`.
- **A missing action returns 404.** Fetching a route whose point isn't
registered (deleted on the server, stale client) returns a `404`. The body's
shape follows the environment: development gets the **private** projection
(`{ message, code, status, stack, … }`), production the **public** one
(`{ message, code }`, dropping `status`, `stack`, and `meta`). So in
production the body is `{ message: 'Not Found', code: 'POINT0_NOT_FOUND' }`.
- **A preset `rawBody` wins.** If a middleware sets `request.rawBody` before
body parsing, point0 uses it and leaves `request.original.bodyUsed` `false` —
even if the actual HTTP body is non-JSON.
## Reference
### Methods on an action
These are the [stage-methods](stage-methods) you can call while building an
action.
Input: [`.params`](validation), [`.search`](validation), [`.body`](validation),
`.headers`, `.cookies` (**not** `.input` / `.clientInput` / `.sharedInput`). All
**server-only** on an action — cut from the client bundle (they parse the
request on the server).
Data: [`.loader`](loader), [`.ctx`](ctx). Both **server-only** — cut from the
client bundle. An action has **no `.clientLoader`** — it's a server endpoint
with no client render, so the chain never offers one.
OpenAPI: `.response`, `.openapi` (see [OpenAPI](openapi)). Both **server-only**
— cut from the client bundle (they only feed the server-generated spec).
Shared: [`.use`](plugin), [`.middleware`](middleware), `.on` / `.serverOn` /
`.clientOn` (events), [`.transformer`](transformer), `.fetchOptions`, `.tag`,
`.description`. Strip categories differ per method: `.middleware`, `.serverOn`,
and `.description` are **server-only** — cut from the client bundle; `.clientOn`
is **client-only** — cut from the server bundle (inert here — an action has no
client render); `.use` itself, `.on`, `.transformer`, `.fetchOptions`, and
`.tag` are **server-and-client** — not cut from either bundle, even where their
server-side hooks run server-only.
Closers: `.action(loaderFn?)`, or finalize as [`.query`](query) /
[`.mutation`](mutation) / [`.infiniteQuery`](infinite-query). The `.action`
server fn is **server-only** — cut from the client bundle; the closer calls
themselves are **server-and-client** — not cut from either bundle, so the chain
stays intact and types resolve while only the server fn's body is cut.
### The action ready point
A finalized action exposes `id`, `point` (`.name`, `.method`), `route`,
`method`, `type`, `tags`, [`Infer`](infer), plus fetch helpers `fetch`,
`fetchServer`, `fetchServerDetailed`, `getFetchServerOptions`. The rest of the
surface depends on how you closed it:
| Closed with | You get |
| --------------------------- | --------------------------------------------------------------------------------------- |
| `.action()` / `.mutation()` | `useMutation`, `fetchMutation`, `getMutationKey`, … (full [mutation](mutation) surface) |
| `.query()` | `useQuery`, `fetchQuery`, `getQueryKey`, … (full [query](query) surface) |
| `.infiniteQuery(...)` | the `*InfiniteQuery*` surface ([infinite-query](infinite-query)) |
`action.route` is a callable [Route0](navigation) route, so you can build the
URL to fetch it directly. `.abs()` gives the absolute URL — an action's route
resolves against the server URL, which point0 fills in as the route's origin:
```ts
action.route({ id: 123 }) // => "/api/my-test/123" (path only)
action.route.abs({ id: 123 }) // => "https://example.com/api/my-test/123" (absolute)
fetch(action.route.abs()) // for a no-params route
```
### Input shape for the fetch helpers
Every fetch helper takes the structured input as its first argument —
`{ params?, search?, body? }` — matching the declared schemas:
```ts
await action.fetchMutation({ params: { id }, body: { title, content } })
```
---
# Point0 — Subscription
Source: https://1gr14.dev/point0/v0/subscription.md
A subscription streams values from the server: its loader is an async
**generator**, and every `yield` travels to the client the moment it happens.
The transport is plain HTTP — an NDJSON response on a real endpoint, not a
WebSocket — so there are no tickets, no rooms, no socket to keep alive: the
cheapest way to watch a long task, tail a log, or follow a feed.
```tsx
import { root } from '@/lib/root'
import { z } from 'zod'
export const taskProgressSubscription = root.lets
.subscription()
.input(z.object({ taskId: z.string() }))
.loader(async function* ({ input, signal }) {
// cut from the client bundle — the generator runs on the server
for await (const percent of watchProgress(input.taskId, { signal })) {
yield { percent } // one streamed value per yield
}
})
.subscription()
```
```tsx
export const TaskProgress = ({ taskId }: { taskId: string }) => {
const { data, status } = taskProgressSubscription.useSubscription(
{ taskId },
{ lastMessageFromServerAsData: true }, // keep the latest message in `data`
)
if (status === 'closed') return Done // the generator returned
return
}
```
## Declaring a subscription
Open with `.subscription()`, declare input and a **generator** loader, close
with `.subscription(options?)`:
```tsx
export const countSubscription = root.lets
.subscription() // open
.input(z.object({ upTo: z.number().max(10) }))
.loader(async function* ({ input }) {
for (let n = 1; n <= input.upTo; n++) {
yield { n } // each yield is one streamed value
}
// the generator returns → the stream completes, consumers see 'closed'
})
.subscription({ reconnect: false }) // close, with point-level options
```
The loader is an async generator — that's the whole declaration of the data:
each `yield` is one streamed value, a `return` completes the stream, and the
**data type is the union of the yields** — nothing is declared. A plain
(non-generator) loader can't close with `.subscription()`, and a generator
loader can't close with `.query()` or `.mutation()` — both are type errors.
Beyond `signal` (covered [below](#stopping-the-stream--the-signal)), the
generator receives the standard [loader](loader) surface — the parsed `input`,
any `ctx` from `.ctx`/plugins, the `request`, a `set` helper, and `points`.
`.input(schema)` takes any [Standard Schema](validation) and merges down the
chain, exactly as on a [query](query).
## A real endpoint
A subscription is served at `GET /_point0//subscription/` — a
**GET with the input JSON-encoded in the `?input=` search param**, same as a
query; an input that carries a `File`/`Blob` or overflows the URL falls back to
a `POST` body automatically. The response is NDJSON (`application/x-ndjson`):
one transformer-serialized line per yield, closed by a terminal line that says
whether the generator completed or threw. While the generator sits quiet between
yields, blank-line heartbeats keep idle proxies from reaping the stream. You
never write the path — `useSubscription` / `fetchSubscription` route to it for
you.
The connect is a full request: [middleware](middleware), [plugins](plugin),
[`.ctx`](ctx), and input validation all run, like on any endpoint. An input that
fails its schema answers as a plain typed error (status 422) before anything
streams; a loader that throws **mid-stream** delivers the typed error after the
values already yielded — see [error handling](error-handling).
## Consuming with the hook
`useSubscription(input?, options?)` holds the stream while the component is
mounted. By default **messages never re-render the component** — react to them
through the `onMessageFromServer` callback:
```tsx
const { error, status, isLoading } = taskProgressSubscription.useSubscription(
{ taskId },
{ onMessageFromServer: (tick) => setPercent(tick.percent) }, // fires on every message
)
status // 'connecting' | 'open' | 'closed' | 'error'
isLoading // status === 'connecting'
```
When you want to **render** the latest message directly, opt into the
query-shaped result with `lastMessageFromServerAsData: true` — the result gains
`data` (typed as the union of the yields) and the component re-renders on every
message:
```tsx
const { data } = taskProgressSubscription.useSubscription(
{ taskId },
{ lastMessageFromServerAsData: true },
)
data // the latest streamed value — undefined until the first one
```
Pick by what you do with a message: a callback for reactions (update a store,
toast, feed a query cache — no render churn), `lastMessageFromServerAsData` for
rendering the latest value (a ticker, a progress bar — the re-render is the
point). `status` walks `connecting` → `open` → `closed` (the generator
completed) or `error` (a typed error — final, never restarted); `error` is typed
by the point's error class, as everywhere. All options together:
```tsx
taskProgressSubscription.useSubscription(
{ taskId },
{
enabled: !!taskId, // false → nothing opens, status is 'closed'
onMessageFromServer: (tick) => console.info(tick.percent), // fires on every message
lastMessageFromServerAsData: true, // keep the latest message in `data`; default false
reconnect: { delay: 1000 }, // per-call override — see Reconnect
},
)
```
A listener can also live on the point itself —
`.subscription({ onMessageFromServer })` runs for every consumer's every message
(per-call listeners add up, nothing doubles). It's client code: the compiler
cuts it from the server bundle with its imports.
Scope-wide defaults live on `.subscriptionOptions({...})` — declarable on root,
base, or a plugin, like [`.queryOptions`](stage-methods): the same `reconnect`
and `onMessageFromServer` shape, applied to every subscription in scope. Options
resolve lowest-to-highest — `.subscriptionOptions()`, then the closing
`.subscription({...})`, then the `useSubscription` call — and
`onMessageFromServer` listeners at every level all run, in that order (nothing
doubles).
Changing the input closes the old stream and opens a new one (the input's
serialized form is the identity). Each hook holds its **own** stream — two
components subscribing with the same input are two independent streams, with no
cross-component dedup (an HTTP stream is per-reader by nature — the deliberate
contrast with [channel](socket) connections and space memberships, which dedupe
by input). The values live in the hook, not in the react-query cache: unmount
and they're gone.
During [SSR](ssr) nothing streams — the hook renders `connecting` on the server
and does the real work after mount. Nothing is prefetched or dehydrated.
## The imperative consumer: `for await`
`fetchSubscription(input?, options?)` returns an `AsyncIterable` of the streamed
values:
```tsx
for await (const { percent } of taskProgressSubscription.fetchSubscription({
taskId,
})) {
render(percent)
}
// the loop ends when the generator completes
```
A typed error — or a broken stream — **throws** out of the loop; there is no
auto-restart (an imperative consumer loops on its own terms). Breaking out of
the loop cancels the stream — the server generator's `signal` fires. To cancel
from outside the loop, pass a signal:
```tsx
const controller = new AbortController()
for await (const value of feedSubscription.fetchSubscription(undefined, {
signal: controller.signal,
})) {
// controller.abort() anywhere stops the stream
}
```
`fetchSubscription` is client-only — a runtime error on the server.
## Stopping the stream — the `signal`
The generator receives `signal` — an `AbortSignal` that fires when the consumer
unsubscribes (the component unmounts, the `for await` loop breaks, the tab
closes). Hand it to whatever feeds the generator, or check it in the loop —
without it, a generator waiting on an external source outlives its consumer
forever.
**The server pulls its own source** — poll, sleep, check the signal:
```tsx
export const logTailSubscription = root.lets
.subscription()
.input(z.object({ file: z.string() }))
.loader(async function* ({ input, signal }) {
let offset = 0
while (!signal.aborted) {
const { lines, nextOffset } = await readNewLines(input.file, offset)
offset = nextOffset
if (lines.length > 0) yield { lines }
await sleep(1000)
}
})
.subscription()
```
**Outside events drive the stream** — `on` from `node:events` turns an emitter
into an async iterable and takes the signal directly, so a mutation firing an
event anywhere on the server streams to every subscribed client, still without a
WebSocket:
```tsx
import { on } from 'node:events'
import { postsEmitter } from '@/lib/posts' // .emit('add', post) in a mutation
export const newPostsSubscription = root.lets
.subscription()
.loader(async function* ({ signal }) {
for await (const [post] of on(postsEmitter, 'add', { signal })) {
yield { post }
}
})
.subscription()
```
The teardown is symmetric: when the consumer goes away, the server aborts the
signal **and** closes the generator, so `finally` blocks in the loader run and
nothing leaks.
## Reconnect
Only a **broken** stream restarts — the bytes stopped without a terminal line (a
network drop, a server restart, a dev reload), or a line arrived that the
transformer cannot parse, which says the same thing: what comes out of this
connection is no longer the framing. The first retry fires immediately (a drop
is usually accidental); from there the waits run `delay`, `delay·backoff`,
`delay·backoff²`, … capped at `maxDelay` — by default 300 ms doubling up to 5 s.
The attempt counter resets whenever a stream opens successfully. While
reconnecting, `status` is `connecting` and `data` keeps the last streamed value.
A **completed** stream (the generator returned) and a **typed error** (the
loader threw, the input failed validation) are answers — they never restart.
```tsx
export const newPostsSubscription = root.lets
.subscription()
.loader(async function* ({ signal }) {
for await (const [post] of on(postsEmitter, 'add', { signal })) {
yield { post }
}
})
.subscription({ reconnect: { immediately: false, delay: 1000 } }) // never retry instantly
// the other forms the closer takes:
// { reconnect: true } the default — same as omitting it
// { reconnect: { backoff: 1 } } constant 300ms waits, no exponent
// { reconnect: { retries: 3 } } give up after 3 attempts
// { reconnect: false } a break surfaces as a typed error
```
The policy lives on the closing `.subscription({...})` and every
`useSubscription` call can override it. With `reconnect: false` (or the
`retries` cap exhausted) a break reports `status: 'error'` with a typed
`POINT0_SUBSCRIPTION_LOST` error. `fetchSubscription` never reconnects — a break
throws, and the `for await` loop retries if you wrap it in one.
A reconnect restarts the generator from scratch — whatever streamed during the
gap is lost unless the point resumes instead: see
[Resuming after a break](#resuming-after-a-break).
## Resuming after a break
Declare the **tracked-cursor pair** on the closing `.subscription({...})` and a
broken stream resumes where it left off: the client plucks a cursor out of every
delivered value, and when the auto-reconnect redials it writes the last one into
the input it resubscribes with.
```tsx
export const feedSubscription = root.lets
.subscription()
.input(
z.object({ channelId: z.string(), lastEventId: z.string().optional() }),
)
.loader(async function* ({ input, signal }) {
// a fresh start and a resume read the cursor the SAME way — out of the input
for (const missed of await db.eventsAfter(
input.channelId,
input.lastEventId,
)) {
yield missed // { id, ... }
}
for await (const event of live(input.channelId, { signal })) {
yield event
}
})
.subscription({
cursorParamFromInput: 'lastEventId', // the input field the reconnect rewrites
cursorParamFromData: 'id', // where the cursor lives inside each streamed value
})
```
- **`cursorParamFromData`** names the field **inside each streamed value** the
client remembers — the data of a tracked subscription is an object carrying
its own cursor. There is no separately computed cursor: an event that needs
one gets a field.
- **`cursorParamFromInput`** names the **input field** the auto-reconnect
rewrites with the last delivered cursor before resubscribing. The first
subscribe sends your input untouched — leave the field unset to start from the
top, or set it to start "from N". Make the field optional in the schema, or
give it a meaning on the first subscribe.
- Both are **paths**, dots included (`'filter.since'`, `'meta.at'`) — the same
rule as `pageParamFromInput` on an [infinite query](infinite-query) — and both
are **typed**: each path must exist in its shape, and the data cursor's type
must fit the input field it lands in, all checked at the close. The pair comes
only together, and only here — not per call, not in `.subscriptionOptions()`.
The feature is entirely client-side. The server, the wire and the loader don't
change: the loader reads the cursor out of its input — identically on a fresh
start and on a resume — and serves the missed events itself (nothing is buffered
for replay). The [transformer](transformer) covers the cursor like any input: a
`Date` plucked from the data arrives in the loader a `Date`. Serve
strictly-after the cursor and nothing duplicates across the break.
The tRPC contrast: there resumption is `tracked(id, data)` plus the
`Last-Event-ID` HTTP header — a string outside the transformer, under an imposed
name. Here the cursor is a field of your input: typed by your schema, serialized
by the transformer, named whatever you like.
`fetchSubscription` never reconnects, so it never rewrites anything — the pair
changes nothing for the imperative consumer, which loops with its own cursor.
## Lifecycle callbacks
The same three callbacks a [channel](socket) declares, in the subscription
vocabulary — on the closing `.subscription({...})` (fires for every consumer)
and per `useSubscription` call; both levels run, point level first:
```tsx
export const taskProgressSubscription = root.lets
.subscription()
.input(z.object({ taskId: z.string() }))
.loader(async function* ({ input, signal }) {
yield* watchProgress(input.taskId, { signal })
})
.subscription({
onConnect: ({ connectionIndex }) => console.log('open', connectionIndex), // 0 — the first connect, > 0 — a re-open after a break
onDisconnect: ({ connectionIndex }) =>
console.log('stopped', connectionIndex),
onError: ({ error }) => toast.error(error.message),
})
```
- **`onConnect`** — the stream opened, the first time and on every successful
re-open after a break alike; `connectionIndex` counts the opens before this
one (`0` = the first, `> 0` = a re-open). Per consumer: two hooks are two
streams, each fires its own.
- **`onDisconnect`** — an **open** stream stopped: a break (the reconnect policy
redials next), a completion, or a mid-stream typed error. A stream that never
opened fires nothing, and neither does your own teardown — unmount and an
early `break` are you leaving, there is nobody left to tell.
- **`onError`** — the terminal `error` status: a typed error or a break the
policy gave up on. A request that never opened (a 401, failed validation)
fires `onError` alone.
The props carry `{ point, input, connectionIndex }` (`error` too on `onError`) —
the `input` identifies the stream, since a subscription has no shared facade.
All three are client code, cut from the server bundle with their imports.
`fetchSubscription` fires the point-level callbacks too (one attempt,
`connectionIndex: 0`); its call site takes no callbacks — the imperative
consumer IS the loop.
A [clientHandler](socket)'s pushes have none of these on purpose: they have no
connection of their own — the moments belong to the channel connection or the
space membership that carries them (`onConnect` on the channel, `onEnter` on the
space).
## A stream on your own URL
Open with `.action(method, path)` instead — a generator loader closed with
`.subscription()` streams on the method and path you declared:
```tsx
export const feedSubscription = root.lets
.action('GET', '/api/feed')
.loader(async function* ({ signal }) {
for await (const item of watchFeed({ signal })) {
yield { item }
}
})
.subscription()
```
Closing a generator loader with `.action()` (or `.query()` / `.mutation()`) is a
type error — a stream closes with `.subscription()` only. The endpoint answers
the declared method only (no POST fallback), and the input rides the ACTION
shape: route params fill the path, `.search` keys ride the query string, a
`.body` rides the body — `useSubscription`/`fetchSubscription` take the same
`{ params, search, body }` input the action's own callers would. On this form
`signal` is typed `AbortSignal | undefined` — an action opener also accepts
plain loaders, so the two share one loader shape — but for a generator loader it
is always there at runtime.
## Foreign clients: SSE
The wire is content-negotiated. Point0's own client asks for the native NDJSON
framing (`Accept: application/x-ndjson`); any other client — including a plain
browser `EventSource` — gets the SAME envelopes in **SSE** framing
(`Content-Type: text/event-stream`, one `data:` event per envelope, comment
lines as heartbeats). No option to set — the endpoint answers whatever asks:
```ts
const source = new EventSource(
'/_point0/root/subscription/task-progress?input=' + input,
)
source.onmessage = (event) => {
const envelope = JSON.parse(event.data) // { v: value } | { e: error } | { d: true }
}
```
The envelope semantics survive the framing: `{ v }` is one value, `{ e }` is a
typed failure, `{ d: true }` is completion — an SSE stream ending without a
terminal envelope is a break, same as NDJSON. SSE `id:`/`event:` fields are
unused: resumption does not ride the SSE protocol — it is the
[cursor pair](#resuming-after-a-break), which travels as a field of the input,
so a foreign client resumes the same way Point0's own does (put the cursor in
the input); a foreign reconnect without one restarts the generator. Responses
carry `Vary: Accept` so caches never mix the framings.
## Subscription vs channel
Both push live; they solve opposite directions.
- A **subscription** is the server **pulling or generating** a stream for one
consumer: the progress of a long task, a tail, a feed. One-way, one HTTP
request per consumer, nothing shared — no socket, no tickets, no rooms. When
the reader is what starts the flow, reach here first: it's the cheaper
machine.
- A **[channel](socket)** is **push from outside** to many clients: chats,
presence, notifications. Two-way messages, spaces of rooms deciding who's
together, one WebSocket carrying everything — a bigger machine for a bigger
job.
The line between them is the REQUEST: a subscription is one, a clientHandler's
pushes are not — which is why there is no `.subscription()` on socket points.
The socket side consumes its pushes with `useOnMessageFromServer` /
`onMessageFromServer` and iterates them with
[`iterateMessagesFromServer`](socket#iterating-the-pushes).
## The usual point machinery
- **Stripping** — the generator `.loader` is **cut from the client bundle**: its
body and the imports it uses are removed, so it never ships to the browser (it
runs server-side) — the standard server-only loader rule. `.input` is cut from
the client bundle likewise. The closing `.subscription(...)` is **not cut from
either bundle** — kept in both (isomorphic): it has to resolve the point on
the server and the client alike.
- **Transformer** — every streamed line goes through the point's
[transformer](transformer), like query payloads: set `.transformer(superjson)`
up the chain and Dates or Maps survive the stream too.
- **Middleware, ctx, plugins** — run per subscription request like on any
endpoint; the connect is a full request.
- **OpenAPI** — the endpoint appears in the generated [OpenAPI](openapi) spec
under `filter: 'all'`; the stream's line-by-line body is not expressible in
OpenAPI, so the operation describes the endpoint, not the values.
- **No react-query cache** — a subscription's values live in the hook that holds
the stream, not in the query cache; there is nothing to invalidate or
dehydrate.
- **[Events](events)** — the side-split `pointSubscriptionServer*` /
`pointSubscriptionClient*` families fire per stream attempt on both sides:
`Start` (the client one's `attempt` counts its reconnects — 0 is the first;
the server cannot know the retry count and carries none), `Data` on every
streamed value, `Settled` with the outcome (`completed` / `failed` /
`broken`), and `Error` whenever the consumer sees an error. Deliberately
stopping the stream (unmount, breaking out of the loop) emits nothing further.
## Reference
### Statuses
| Status | Meaning |
| ------------ | -------------------------------------------------------------------------------------------------- |
| `connecting` | the request is in flight — also during a reconnect wait, on the server during SSR |
| `open` | the stream answered; values are arriving |
| `closed` | the generator completed (`return`) — final; also the state under `enabled: false` |
| `error` | a typed error (a loader throw, failed validation, retries exhausted) — final, never auto-restarted |
### Options
`.subscription({...})` — point-level defaults, overridable per `useSubscription`
call (except the cursor pair — it names schema fields, a point contract, so
neither the call site nor `.subscriptionOptions()` carries it):
| Option | Default | What it does |
| ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `reconnect` | on | restart a **broken** stream — a boolean or `{ enabled, immediately, delay, backoff, maxDelay, retries }` (first retry immediate, then 300 ms doubling up to 5 s); completed streams and typed errors never restart |
| `cursorParamFromInput` | — | the input field (a dot-path) the auto-reconnect rewrites with the last delivered cursor — resumes instead of restarting; always paired with `cursorParamFromData`, see [Resuming after a break](#resuming-after-a-break) |
| `cursorParamFromData` | — | the field (a dot-path) **inside each streamed value** the client plucks the cursor from; always paired with `cursorParamFromInput` |
| `onMessageFromServer` | — | module-level listener — fires for every consumer's every message; client code, cut from the server bundle |
| `onConnect` | — | a consumer's stream opened — every successful open, first and re-opens alike (`connectionIndex`: `0` = the first, `> 0` = a re-open) — see [Lifecycle callbacks](#lifecycle-callbacks) |
| `onDisconnect` | — | an open stream stopped (break / completion / mid-stream error); never on the consumer's own teardown |
| `onError` | — | the terminal `error` status — the typed error in the props |
`useSubscription(input, {...})` — on top of the point-level options:
| Option | Default | What it does |
| ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
| `enabled` | `true` | subscribe or not — `false` renders `closed` and opens nothing |
| `onMessageFromServer` | — | fires on every message — the reactive path, no re-renders |
| `lastMessageFromServerAsData` | `false` | keep the latest message in state as `data`, re-rendering per message (query-shaped result) |
| the three lifecycle callbacks | — | `onConnect` / `onDisconnect` / `onError`, per call — compose with (never replace) the point-level ones |
`fetchSubscription(input, {...})`:
| Option | Default | What it does |
| -------- | ------- | ----------------------------------------------------------------------- |
| `signal` | — | abort to stop the stream — the iterator ends, the server `signal` fires |
### Method surface
Both take the **input** first (omit it when the subscription declares none). The
hook is kept in both bundles (it renders the connecting state during SSR); the
imperative consumer is client-only.
| Method | Signature | Returns |
| ------------------- | ------------------- | -------------------------------------------------------------------------------------------- |
| `useSubscription` | `(input, options?)` | `{ error, status, isLoading }`; `+ data` (latest message) with `lastMessageFromServerAsData` |
| `fetchSubscription` | `(input, options?)` | `AsyncIterable` — a typed error or a break throws |
A subscription also exposes the standard `id` (`scope:type:name` — e.g.
`root:subscription:taskProgress`), `type` (`'subscription'`), `tags`, `point`,
and `Infer`.
---
# Point0 — Root
Source: https://1gr14.dev/point0/v0/root.md
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.
```tsx
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(() => )
.error(({ error }) => )
.root() // close
```
Every other point is then opened off this `root` — `root.lets.page(...)`,
`root.lets.query(...)`, and so on — and inherits everything set here.
## 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". While the chain is open it's
a `StagePoint` and every default-setter is available; the closing `.root()`
turns it into the finalized `ReadyPoint` that children grow from — the
stage-methods are gone, leaving the ready surface (`.lets`, `.id`, `.point`, …).
The closing `.root()` is server-and-client — not cut from either bundle, kept in
both (isomorphic). See [points](points) for the stage-method / ready-method
split and the `.lets` notation.
```tsx
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](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`:
```tsx
export const root = Point0.lets
.root()
.serverUrl('https://app.example.com')
.root()
// 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:
```tsx
export const root = Point0.lets
.root()
.serverUrl(sharedEnv.SERVER_URL) // API origin
.clientUrl(sharedEnv.CLIENT_URL) // page origin
.root()
```
The 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
(they configure URL resolution on either side).
## 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.
```tsx
import superjson from 'superjson'
export const root = Point0.lets
.root()
.transformer(superjson) // Date, Map, Set, BigInt survive the round-trip
.root()
```
The 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](query). Details on [Transformer](transformer).
`.transformer` is server-and-client — not cut from either bundle (it runs on
both sides: serialize on send, deserialize on receive).
## The schema helper
`.schemaHelper` teaches Point0 about your validation library, mainly for
[OpenAPI](openapi) generation and a few search-param edge cases. It's optional —
validation already works through Standard Schema:
```tsx
import { zodSchemaHelper } from '@point0/core/schema/zod'
export const root = Point0.lets.root().schemaHelper(zodSchemaHelper()).root()
```
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](validation).
`.schemaHelper` is server-and-client — not cut from either bundle (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`:
```tsx
import { AppError } from '@/lib/error' // your own error class
export const root = Point0.lets.root().errorClass(AppError).root()
```
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](error-handling) is one way, but it's optional.
Full surface on [Error handling](error-handling).
`.errorClass` is server-and-client — not cut from either bundle (errors are
raised, serialized, and rendered on both sides).
## Prefetch policies
The root sets how pages are prefetched. Two triggers, and a shorthand that sets
both:
```tsx
export const root = Point0.lets
.root()
.prefetchPageOnNavigate('serverAndClientQuery') // when a navigation starts
.prefetchPageOnLinkHover('serverQuery', 200) // on link hover, after 200ms
.root()
export const adminRoot = Point0.lets
.root()
.prefetchPagePolicy('serverAndClientQuery') // sets both triggers at once
.root()
```
The 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.
Costs differ: `pageDehydratedStateAndClientQuery` is the most reliable but the
most expensive (it runs a full SSR render). The policies live on
[Navigation](navigation) (and [SSR](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:
```tsx
export const root = Point0.lets
.root()
.queryOptions({
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
})
.root()
```
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](query) for precedence and [stage-methods](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
(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:
```tsx
export const root = Point0.lets
.root()
.on('error', ({ side, name, error, meta }) => {
console.error({ ...meta, side, name, error })
})
.root()
```
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](events).
Strip categories differ by setter. `.on` is server-and-client — not cut from
either bundle (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 (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:
```tsx
export const root = Point0.lets
.root()
.loading(() => )
.error(({ error }) => )
.root()
```
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:
```tsx
export const root = Point0.lets
.root()
.error(({ error }) => )
.componentError(({ error }) => )
.root()
```
Any point below can override these. Full rules in
[Loading & error](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 watch
what it exposes: if it prints `error.stack`, the stack ends 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
``, so it never reaches the SSR output. The basic root does 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 `` from the app's loading/error state:
```tsx
export const root = Point0.lets
.root()
.head('global', ({ loading, error }) => ({
...(loading ? { title: 'Loading...' } : {}),
...(error ? { title: error.message } : {}),
titleTemplate: '%s | IdeaNick',
htmlAttrs: { lang: 'en' },
}))
.root()
```
The return is an [unhead](head) 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).
`.head` is server-ssr-and-client — cut from the SERVER bundle when `ssr: false`
(or after a `.clientOnly()` earlier in the chain); kept in the server build when
SSR is on, so the document head is server-rendered.
## 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, even on a root with no
pages or queries. This is rarely needed for your own code, but it's how
third-party handlers — better-auth, an OpenAPI doc server — plug in:
```tsx
export const root = Point0.lets
.root()
.middleware(
openapi({ route: '/openapi.json', scalar: '/scalar', filter: 'all' }),
)
.middleware('/api/auth/*', async ({ request }) =>
authServer.handler(request.original),
)
.root()
```
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; on the client the call no-ops to `next()`. 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](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:
```tsx
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.
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](engine-config).
## Base vs. root
A [base](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:
```tsx
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](base).
`.use` (attaching a plugin) and the `.base()` closer are server-and-client — not
cut from either bundle (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.()` 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](stage-methods); the sections above
cover the ones whose default belongs on the root.
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.
- `serverUrl` is required on the server, or `route.abs()` throws.
- `clientUrl` splits page vs. action origin by route kind — actions always use
`serverUrl`.
- 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.
---
# Point0 — Base
Source: https://1gr14.dev/point0/v0/base.md
A base is a point that holds shared settings — a route prefix, query defaults,
loading and error UI, a [plugin](plugin), injected queries — for a _subset_ of
your points. You build other points off it, and they inherit everything it set.
A base is authoring-time only: it has no route and no endpoint of its own, and
it never reaches the server or client points files.
```tsx
import { root } from '@/lib/root'
import { adminOnlyPlugin } from '@/modules/auth/plugins'
export const adminBase = root.lets
.base()
.basePath('/admin') // every child route gets the /admin prefix
.use(adminOnlyPlugin) // every child is gated by this plugin
.base()
```
Every admin point now grows from `adminBase` and inherits both:
```tsx
export const adminUserListQuery = adminBase.lets
.query()
.input(adminUserListSchema)
.loader(/* ... */)
.query()
// served under /admin, gated by adminOnlyPlugin — nothing extra to write
```
## Declaring a base
A base opens with `.lets.base()` and **closes with a second `.base()`**:
```tsx
export const adminBase = root.lets.base().basePath('/admin').base()
// ▲ open ▲ close
```
`.lets.base()` starts the composition; the trailing `.base()` finalizes it. The
name (`'adminBase'`) comes from the variable — see [points](points) for how the
short `.lets` notation works.
The `.base` closer is **server-and-client** — not cut from either bundle. A base
is authoring-time only, so the closer carries no payload to cut.
A base can grow from the [root](root), from another base (bases chain), or from
a [layout](layout):
```tsx
export const base = root.lets.base().base() // from root
export const apiBase = base.lets.base().basePath('/api').base() // from a base
export const sectionBase = someLayout.lets.base().base() // from a layout
```
After the closing `.base()` you can only start children off it (`.lets`) or read
`.id` / `.type` / `.tags` / `.Infer`; to add more shared settings, chain a new
base off it (above).
## A route prefix
`.basePath(prefix)` extends the route for every descendant. It accumulates:
```tsx
export const adminBase = root.lets.base().basePath('/admin').base()
export const usersPage = adminBase.lets
.page('/users') // final route: /admin/users
.page(/* ... */)
```
Prefixes from chained bases (and the root's own `basePath`) stack in order:
```tsx
export const apiBase = root.lets.base().basePath('/api').base()
export const v2Base = apiBase.lets.base().basePath('/v2').base()
// a page off v2Base at '/users' → /api/v2/users
```
A base is **transparent to the layout chain**: it can sit between two layouts
and add a route prefix, but it never shows up in a page's `layouts` array — it
isn't a layout in the render tree. `.basePath` is also available on the
[root](root), but sharing a prefix is the main reason a base exists.
`.basePath` is **server-and-client** — not cut from either bundle: the route
prefix is needed for routing on both sides.
## Shared defaults
Set a default on the base and every child inherits it. Most config methods are
available while composing a base.
```tsx
export const base = root.lets
.base()
.queryOptions({ retry: false, staleTime: 60_000 }) // default for child queries
.loading(() => ) // default loading UI for child pages/layouts/components
.error(({ error }) => ) // default error UI
.base()
```
Strip categories here: `.queryOptions` (and the per-type `*QueryOptions` family)
is **server-and-client** — not cut from either bundle. `.loading` and `.error`
(and their `.page*` / `.layout*` / `.component*` variants) are
**server-ssr-and-client** — cut from the SERVER bundle when `ssr:false` (or
after a `.clientOnly()` earlier in the chain): 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.
A child's own setting **wins** over the inherited default:
```tsx
export const specialPage = base.lets
.page('/special')
.loading(() => ) // overrides the base's .loading for this page
.page(/* ... */)
```
Two details:
- **`.loading` / `.error` on a base cover every child kind.** On a base,
`.loading(c)` sets the page, layout, _and_ component loading default at once
(same for `.error`). Use the variant-specific setters — `.pageLoading` /
`.layoutLoading` / `.componentLoading`, `.pageError` / `.layoutError` /
`.componentError` — to target just one.
- **`queryOptions` merges, it doesn't replace.** A base's `.queryOptions(...)`
stacks with whatever the child sets, rather than overwriting it. Full
precedence (root/base → type default → the query's own options → call site) is
on the [query](query) page; the per-type `*QueryOptions` methods are in
[stage-methods](stage-methods).
## Injecting a query into every child
`.with(query)` on a base injects that query into every child — its result lands
in the child's `queries`, before any query the child adds itself:
```tsx
export const base = root.lets.base().with(bannerQuery).base()
export const homePage = base.lets
.page('/')
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.page(({ queries: [banner, idea] }) => /* banner from base, idea from page */)
```
`.with` is **server-ssr-and-client** — cut from the SERVER bundle when
`ssr:false` (or after a `.clientOnly()`); kept in the client build always, in
the server build only when SSR is on.
See [`.with`](with) for the full range of forms.
## Context and gating
A base can carry `.ctx`, `.middleware`, and `.use(plugin)`, and all of them
reach its children:
```tsx
export const base = root.lets
.base()
.ctx(async ({ request }) => ({ me: await resolveUser(request) }))
.base()
```
Strip categories: `.ctx` and `.middleware` are **server-only** — cut from the
client bundle: their bodies and the imports they use are removed, so they never
ship to the browser. `.use` is **server-and-client** — not cut from either
bundle; the plugin it attaches carries its own per-method strip behavior. The
`.with` gate below is **server-ssr-and-client** — cut from the SERVER bundle
when `ssr:false` (or after a `.clientOnly()`); kept in the client build always,
in the server build only under SSR.
But mind the security rule that applies everywhere in Point0: **`.ctx` runs only
when the point has a loader.** A loader-less page makes no server request, so
its `.ctx` never executes and can't protect anything. For an authorization gate
that always fires, gate in [`.with`](with) — returning an error (`ErrorPoint0`,
or your own [error class](error-handling)) short-circuits to the error
component:
```tsx
import { AppError } from '@/lib/error' // your own error class
export const adminBase = root.lets
.base()
.use(adminOnlyPlugin) // resolves `me` into props upstream
.with(({ props: { me } }) => {
if (!me?.isAdmin) return new AppError('Forbidden', { code: 'FORBIDDEN' })
return { me }
})
.base()
```
This is exactly what `adminBase` does in Start0: it doesn't inline the gate, it
`.use(adminOnlyPlugin)` — and the plugin's `.ctx` (for loaders) and `.with` (for
the render) both ride along to every admin point.
## Base or plugin?
A base and a [plugin](plugin) overlap heavily: both can carry `.ctx`, `.with`,
`.use`, `.loading`, `.error`. **Prefer a plugin** for shared behavior — it's the
more flexible of the two.
The difference is in how each attaches:
```tsx
// a base: the child must start its chain FROM the base object
export const adminUserQuery = adminBase.lets.query()./* ... */.query()
// a plugin: injected mid-chain on a point that started anywhere
export const adminUserQuery = root.lets.query().use(adminOnlyPlugin)./* ... */.query()
```
A plugin drops a bundle of methods into any point's chain at any position; a
base requires every consumer to build _off_ it. So:
- **Use a plugin** for shared `.ctx` / `.with` / `.loading` / `.error` / gating
— it composes anywhere, and a point can use several.
- **Use a base** when you specifically want a shared **route prefix**
(`.basePath`) plus a single parent to grow a section from. A plugin has no
`.basePath` — it can't add a route prefix.
In practice the two combine: a thin base for the prefix, a plugin (used by the
base) for the behavior — the Start0 `adminBase` above.
## Inheritance, briefly
Children inherit a base's settings through two mechanisms:
- **The child continues the base's chain.** `base.lets.()` carries the
base's middlewares, plugins (`.use`), wrappers, injected queries (`.with`),
context (`.ctx`), input schemas, and its `.head`. `.onPrefetchPage` rides
along too and runs during the prefetch of any page built under the base.
- **The base's broadcast defaults are re-applied** to each child: `basePath`,
all the `*QueryOptions`, `fetchOptions`, loading/error components, and the
scroll (`.scrollPosition` / `.scrollRestore`) and prefetch (`.prefetchPage*`)
settings.
You don't manage either by hand — set it on the base, read it on the child.
## What you can't do on a base
A base has no route of its own and isn't a query, so a few methods are off the
table:
- **No `.params`** — params come from a consumer's route, not the base.
- **No `.loader` / `.clientLoader`** — a base doesn't load; its children do.
- **`.search` / `.body` are allowed but risky for query children.** A base can
set a `.search` or `.body` schema, and children inherit it — but a
[query](query) doesn't accept `search` / `body`, so a query child of such a
base is a **type error**:
```tsx
const baseWithSearch = root.lets
.base()
.search(z.object({ id: z.string() }))
.base()
baseWithSearch.lets
.query()
// @ts-expect-error — search schema not allowed for query
.loader(() => ({ x: 1 }))
.query()
```
Shared input schemas on a base only make sense when the children are pages or
actions, never queries.
## Reference
### A real base (Start0)
```tsx
import { root } from '@/lib/root'
import { adminOnlyPlugin } from '@/modules/auth/plugins'
export const adminBase = root.lets
.base()
.basePath('/admin')
.use(adminOnlyPlugin)
.base()
```
Children pick up the `/admin` prefix and the gate for free — a paged query, a
cursor query, a mutation, a layout, all built with `adminBase.lets.()`:
```tsx
export const adminUserListPagedQuery = adminBase.lets.query()./* ... */.query()
export const adminUserListCursorQuery = adminBase.lets.infiniteQuery()./* ... */.infiniteQuery({})
export const adminUserCreateMutation = adminBase.lets.mutation()./* ... */.mutation()
export const adminLayout = adminBase.lets.layout().layout(/* admin shell */)
```
Mount admin pages on `adminLayout` (itself a child of the base) so they also get
the admin shell, not just the prefix and the gate.
### Methods that apply to a base
Each group below carries its strip category (per the four categories:
server-only / client-only / server-and-client / server-ssr-and-client).
Routing & defaults: `.basePath`, [`.queryOptions`](query) and the per-type
`*QueryOptions` / `.mutationOptions` / `.fetchOptions` are **server-and-client**
(not cut from either bundle). `.scrollPosition`, `.scrollRestore`,
`.prefetchPageOnNavigate` / `.prefetchPageOnLinkHover` / `.prefetchPagePolicy`
are **client-only** — cut from the server bundle. `.onPrefetchPage` is
**server-and-client**; it runs on the client during prefetch and on the server
before the first render. Its side-pinned variants `.serverOnPrefetchPage`
(**server-only**) and `.clientOnPrefetchPage` (**client-only**) keep one side's
body out of the other bundle. `.scrollPosition` / `.scrollRestore` are
documented in full on the [navigation](navigation) page.
UI: [`.loading`](loading-error) (and `.pageLoading` / `.layoutLoading` /
`.componentLoading`), [`.error`](loading-error) (and `.pageError` /
`.layoutError` / `.componentError`) are **server-ssr-and-client** — cut from the
SERVER bundle when `ssr:false` (or after a `.clientOnly()`); kept in the client
build always, in the server build only when SSR is on.
Data & context: [`.ctx`](ctx), `.headers`, `.cookies`, `.body` are
**server-only** — cut from the client bundle. `.search` is **server-only** only
when it sits on an action; on a base it's **server-and-client** — not cut from
either bundle. [`.with`](with) and [`.mapper`](mapper) are
**server-ssr-and-client** — cut from the SERVER bundle when `ssr:false` (or
after a `.clientOnly()`); kept in the client build always, server build only
under SSR.
Shared: [`.use`](plugin) (plugins) is **server-and-client** — not cut from
either bundle (the plugin carries its own per-method strip behavior).
[`.middleware`](middleware) and `.serverOn` are **server-only** — cut from the
client bundle. `.clientOn` is **client-only** — cut from the server bundle.
`.on` is **server-and-client** — not cut from either bundle. `.wrapper` and
`.head` are **server-ssr-and-client** — cut from the server bundle when
`ssr:false`, kept in the client build always and the server build only under
SSR. `.description` and `.openapi` are **server-only** — cut from the client
bundle; `.tag` is **server-and-client** — not cut from either bundle.
Not available on a base: `.loader` / `.clientLoader`, `.params`, and `.query` /
`.mutation` finalizers (a base is not itself a query or mutation).
---
# Point0 — Plugin
Source: https://1gr14.dev/point0/v0/plugin.md
A plugin is a point that does nothing on its own. Instead it bundles methods —
`.ctx`, `.with`, `.middleware`, input schemas, related queries — and injects
them into another point's chain when you call `.use(plugin)`. It's the tool for
sharing setup across points in _your_ app, not a third-party extension system.
```tsx
import { Point0 } from '@point0/core'
import { getMe } from '@/modules/auth/server'
import { getMeQuery } from '@/modules/auth/api'
export const mePlugin = Point0.lets
.plugin()
.ctx(async ({ request }) => {
return { me: await getMe({ request }) } // server: resolve the user, put it in ctx
})
.with(({ resolve }) => {
// client: resolve the same user into props (returned from `with` so it lands in props, not queries)
return resolve(getMeQuery.useQuery(), ({ data }) => ({ me: data.me }))
})
.plugin()
```
Any point can now `.use(mePlugin)` and read `me` — from `ctx` in its loader,
from `props` in its component:
```tsx
export const generalLayout = root.lets
.layout() // no argument — the route defaults to `/`, so this is a root-level layout
.use(mePlugin)
.layout(({ children, props: { me } }) => (
{me ? : }
{children}
))
```
## Declaring a plugin
Open with `.plugin()`, add methods, close with `.plugin()`:
```tsx
export const tagsPlugin = Point0.lets
.plugin()
.search(z.object({ tag: z.string().optional() }))
.plugin()
```
The explicit equivalent is `Point0.lets('plugin', 'me')` — the second argument
is the name, and the scope is always the literal `'plugin'` (it's reserved):
```tsx
export const mePlugin = Point0.lets('plugin', 'me') // explicit form
.ctx(/* ... */)
.plugin()
```
Unlike every other point, a plugin always grows from `Point0` directly, not from
a `root` / `base` / `layout` — it isn't tied to any client or route. See
[points](points) for the notation.
The `.plugin()` closer itself is **server-and-client** — not cut from either
bundle. The methods it bundles keep _their own_ strip categories: a plugin's
`.ctx` is still cut from the client bundle (server-only), its `.with` still cut
from the server bundle when `ssr:false` (server-ssr-and-client), and so on.
`.use()` merges those bodies into the consumer at the call site; it doesn't
change what gets cut from which bundle.
A plugin is never an endpoint and never has a route of its own. It carries no
`.loader`, `.clientLoader`, `.mapper`, `.head`, `.params`, or `.query` — writing
any of those on a plugin is a type error. It only carries methods that _merge
into_ the point that uses it.
## Injecting with `.use()`
`.use(plugin)` is available on every point that has a setup stage — `root`,
`base`, `page`, `layout`, `component`, `provider`, `query`, `infiniteQuery`,
`mutation`, `action`, and `plugin` itself (so plugins can nest). Call it
**before** the point is finalized — before `.loader()` / before the closing
terminal:
```tsx
export const profilePage = generalLayout.lets
.page('/profile')
.use(redirectUnauthorizedPlugin) // me is guaranteed non-null after this
.page(({ props: { me } }) =>
{me.user.name}
)
```
You can `.use()` as many plugins as you like, interleaved with the point's own
methods. Each plugin's middleware, ctx, and with-functions slot in at the call
site, in order.
The argument must be a finalized plugin. Pass anything else and you get a type
error; bypass the type with `as any` and it throws at startup:
```tsx
export const profilePage = generalLayout.lets
.page('/profile')
.use(somePage) // throws: .use() expects a plugin created via .plugin(), but received a point of type "page" (...). Used on point .
.page(() => )
```
## What `.use()` merges
A plugin contributes everything it declared into the consuming point:
- **`ctx`** — appended to the consumer's `ctx` (visible in its loader and later
`.ctx`).
- **`props`** — props the plugin's `.with` returns merge into the consumer's
`props`.
- **Input schemas** — `.input` / `.search` / `.body` / `.headers` / `.cookies`
merge down (a schema can only narrow, never widen, what's already declared).
- **`.middleware`** — runs at the `.use()` call site, between the consumer's
before- and after-middlewares.
- **Related queries**, event subscriptions, wrappers, tags, and description —
all fold in.
```tsx
const accountPage = root.lets
.page('/account')
.ctx(() => ({ page: 'account' }))
.use(mePlugin) // adds `me` to ctx
.loader(({ ctx }) => {
ctx.page // 'account' — the page's own ctx
ctx.me // from the plugin
})
.page(/* ... */)
```
### Encapsulation
A plugin sees only its own accumulated `ctx`/`props`, never the consumer's
surrounding state — so a plugin can't accidentally depend on whatever point used
it.
```tsx
const plugin = Point0.lets
.plugin()
.ctx(({ ctx }) => {
ctx.page // ❌ does not exist — the consumer's ctx is invisible here
return { fromPlugin: true }
})
.plugin()
```
Nesting is the exception in one direction: when a plugin does
`.use(otherPlugin)`, the inner plugin's `ctx`/`props` _are_ visible to the outer
one — that's how the gating plugins below build on `mePlugin`.
## The only point you can build dynamically
Every other point must be exported exactly as written, so the compiler can find
and analyze it statically — you can't create one inside a function. A plugin is
the exception: you can wrap it in a factory and parametrize it.
```tsx
export const requirePermission = (permission: string) =>
Point0.lets
.plugin()
.use(mePlugin)
.ctx(({ ctx: { me } }) => {
if (!me?.permissions.includes(permission)) {
throw new AppError('Forbidden', { code: 'FORBIDDEN' })
}
return { me }
})
.plugin()
// each call site gets its own configured plugin:
const ideaPage = root.lets
.page('/ideas/:id')
.use(requirePermission('ideaRead'))
.page(/* ... */)
```
Most plugins read better as a plain exported const (`mePlugin`,
`authorizedOnlyPlugin`); reach for a factory only when you need to parametrize.
## Auth plugins: `.ctx` + `.with` together
The headline use of a plugin is an authorization gate. It needs **both** `.ctx`
and `.with` — two different mechanisms, not two copies of one check:
- `.ctx` builds **server context**. Cut from the client bundle, so it never
ships to the browser (server-only). This is where you resolve a value from the
request — the session, the current user — for the server data path; it runs
only when the point has a loader.
- `.with` is a **render wrapper**. Each `.with` wraps the rendered remainder of
the chain, exactly like nesting one React component inside another; it just
reads as a builder method. It runs at render — on the client, and on the
server under SSR — can call hooks, and decides what renders next by what it
returns (props, a query, an `Error`, a `redirect`, an element). It never sees
`ctx`. Cut from the SERVER bundle when `ssr:false` (or after a `.clientOnly()`
earlier in the chain); kept in the client build always, in the server build
only when SSR is on (server-ssr-and-client).
So a gate written in `.ctx` alone protects only the server loader path: a
loader-less page is rendered straight on the client, where `.ctx` never runs,
and nothing stops it. The render wrapper is what actually guards what the user
sees, so the gate has to live in `.with`. You add the matching `.ctx` not to
duplicate the check but to cover the other path — to resolve the same value
server-side for loaders and short-circuit there before any data work happens.
One side or the other always runs, on any point — page, query, mutation, layout.
See [`.with`](with) and [ctx](ctx) for each side in depth.
First, the building block — resolve the user once, on both sides:
```tsx
export const mePlugin = Point0.lets
.plugin()
.onPrefetchPage(async () => {
await getMeQuery.prefetchQuery() // warm the client cache to avoid a waterfall
})
.ctx(async ({ request }) => {
return { me: await getMe({ request }) }
})
.with(({ resolve }) => {
// returned from `with` (not a bare `useQuery()`) so `me` lands only in props, not in `queries`
return resolve(getMeQuery.useQuery(), ({ data }) => ({ me: data.me }))
})
.plugin()
```
`.onPrefetchPage` is **server-and-client** — not cut from either bundle: it runs
on the client during prefetch _and_ on the server before the first render
(`.serverOnPrefetchPage` / `.clientOnPrefetchPage` pin it to one side). `.ctx`
and `.with` keep their categories from above.
Then the gate — `.use(mePlugin)` to get `me`, throw on the server, return the
error on the client:
```tsx
export const authorizedOnlyPlugin = Point0.lets
.plugin()
.use(mePlugin)
.ctx(({ ctx: { me } }) => {
if (!me) {
throw new AppError('Only for authorized users', { code: 'UNAUTHORIZED' })
}
return { me } // returning the narrowed value makes `me` non-null downstream
})
.with(({ props: { me } }) => {
if (!me) {
return new AppError('Only for authorized users', { code: 'UNAUTHORIZED' })
}
return { me }
})
.plugin()
```
Two details worth copying:
- `.ctx` **throws** the error; `.with` **returns** it. A returned `Error` from
`.with` short-circuits to the error component — see
[Loading & error](loading-error).
- Both branches `return { me }` on success. That narrows `me` to non-null for
every point that uses the plugin, so consumers never re-check it.
`AppError` here stands in for your own error class. Point0's built-in
`ErrorPoint0` works just as well; you can swap in any class of the same-or-wider
shape via `.errorClass(...)` — see [Error handling](error-handling).
### Gate by redirect instead of error
Same shape, but return a [`redirect`](navigation) instead of throwing —
friendlier for sign-in flows:
```tsx
export const redirectUnauthorizedPlugin = Point0.lets
.plugin()
.use(mePlugin)
.ctx(({ ctx: { me } }) => {
if (!me) {
return redirect('signIn')
}
return { me } // return so the type narrows to me !== null
})
.with(({ props: { me } }) => {
if (!me) {
return redirect('signIn')
}
return { me }
})
.plugin()
```
Apply the gate once on a [base](base) or [layout](layout) and every point
beneath it inherits the check — e.g. an `adminBase` that
`.use(adminOnlyPlugin)`, so admin pages get the gate for free.
## Related queries in a plugin
A plugin can declare a [`.relatedQuery`](query); it merges into whatever
mountable point uses the plugin, even though the plugin isn't bound to a route.
Inside the related query's input getter, `location` is typed as a generic
location (the plugin doesn't know which route it'll end up on):
```tsx
export const sidebarPlugin = Point0.lets
.plugin()
.relatedQuery(sidebarQuery, ({ location }) => ({ path: location.pathname }))
.plugin()
```
`.relatedQuery` is **server-and-client** — not cut from either bundle. It adds
its query to the consumer's `queries` array, exactly like a `.with(query)`
result; 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 found by rendering and so is prefetched only under the expensive,
SSR-only `pageDehydratedState*`. See [query](query).
## Reference
### Construction
| Form | Notes |
| ------------------------------- | ----------------------------------------- |
| `Point0.lets.plugin()` | name read from the variable |
| `Point0.lets('plugin', 'name')` | explicit name; scope is always `'plugin'` |
Close the chain with `.plugin()`. A plugin always grows from `Point0`, never
from a `root` / `base` / `layout`.
### Methods allowed inside a plugin
A plugin only bundles these methods; each keeps the strip category it has
anywhere else, and `.use()` carries that category into the consumer unchanged.
Setup & data: [`.ctx`](ctx) (server-only), [`.with`](with)
(server-ssr-and-client), [`.use`](plugin) (nesting; server-and-client),
[`.middleware`](middleware) (server-only), `.relatedQuery` (server-and-client),
`.input` (server-only) / `.clientInput` (client-only) / `.sharedInput`
(server-and-client), `.search` / `.body` / `.headers` / `.cookies`
(server-only), `.wrapper` (server-ssr-and-client).
Defaults & UI: `.queryOptions` / `.pageQueryOptions` / `.componentQueryOptions`
/ `.layoutQueryOptions` / `.mutationOptions` / `.infiniteQueryOptions`,
`.fetchOptions` — all server-and-client. `.openapi` is server-only. `.loading` /
`.error` (and per-state variants) are server-ssr-and-client. `.scrollPosition` /
`.scrollRestore` are client-only — cut from the server bundle (see
[navigation](navigation)). `.onPrefetchPage` is **server-and-client** — it runs
on the client during prefetch and on the server before the first render;
`.serverOnPrefetchPage` (server-only) / `.clientOnPrefetchPage` (client-only)
pin it to one side.
Events & meta: `.on` / `.use` config (server-and-client), `.serverOn`
(server-only), `.clientOn` (client-only), `.clientOnly` (the switch — flips the
rest of the chain to behave as `ssr:false`), `.tag` (server-and-client),
`.description` (server-only).
**Not available on a plugin:** `.params`, `.loader`, `.clientLoader`, `.mapper`,
`.head`, `.query`, `.layout`, `.transformer`, `.serverUrl` / `.clientUrl` /
`.basePath`, `.prefetchPageOnNavigate` / `.prefetchPageOnLinkHover`. A plugin
contributes methods; it never _is_ a mountable or routable point.
### Where `.use()` applies
`.use(plugin)` is on every point with a setup stage: `root`, `base`, `page`,
`layout`, `component`, `provider`, `query`, `infiniteQuery`, `mutation`,
`action`, and `plugin`. It's a setup-stage method — a type error after
`.loader()` or once the point is closed.
When two plugins (or a plugin and its consumer) declare the same input key, the
schemas merge by key: a plugin can only narrow a key, never widen it, and the
type rejects a widening or a kind clash.
---
# Point0 — Validation
Source: https://1gr14.dev/point0/v0/validation.md
A point's input is validated before its loader runs. You attach a schema with
one of eight methods — `.input`, `.params`, `.search`, `.body`, `.clientInput`,
`.sharedInput`, `.headers`, `.cookies` — and from then on that data is parsed
and typed everywhere it flows: the loader, the component, the cache key, the
OpenAPI spec. Any [Standard Schema](https://standardschema.dev) library works
(zod, valibot, arktype, typebox, yup, superstruct), or a plain validate
function.
```tsx
import { root } from '@/lib/root'
import { z } from 'zod'
export const ideaQuery = root.lets
.query()
.input(z.object({ id: z.number() })) // input is { id: number } from here on
.loader(async ({ input }) => {
// input is already parsed and typed — never `unknown`
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: input.id },
})
return { idea }
})
.query()
ideaQuery.useQuery({ id: 123 }) // { id: 123 } is checked against the schema
ideaQuery.useQuery({ id: 'x' }) // type error — id must be a number
```
## Which method for which point
Each method is only available — on the type and at runtime — on the point types
it makes sense for. Pick by what the point is:
```tsx
// query / infiniteQuery / mutation / component / provider → .input
export const ideaQuery = root.lets
.query()
.input(z.object({ id: z.number() }))
.loader(/* ... */)
.query()
// page / layout → .params (route segments) and .search (query string)
export const ideaPage = root.lets
.page('/ideas/:id')
.params(z.object({ id: z.coerce.number() }))
.page(/* ... */)
export const ideaListPage = root.lets
.page('/ideas')
.search(z.object({ page: z.coerce.number().default(0) }))
.page(/* ... */)
// action → .params, .search, and .body
export const ideaUpdateAction = root.lets
.action('POST', '/api/ideas/:id')
.params(z.object({ id: z.string() }))
.body(z.object({ title: z.string() }))
.loader(/* ... */)
.action()
```
Reaching for the wrong one is a **type error**, not a silent no-op. The
per-point-type gating is enforced when the point is finalized (at `.query()` /
`.page()`), so the error surfaces on the trailing finalizer rather than on the
schema method itself:
```tsx
root.lets.query().search(/* ... */).query() // type error at .query(): .search is for page/layout/action
root.lets.page('/x').input(/* ... */).page() // type error at .page(): .input is for query/mutation/…
```
`.headers` and `.cookies` are the exception — they apply to **every** point
type, because any server-handled point can read request headers and cookies. The
full matrix is in the [reference](#reference) below.
## Any Standard Schema library
`.input(schema)` and its siblings accept any object that implements the
[Standard Schema](https://standardschema.dev) interface. zod is the default in
our examples, but the validation path only touches the `~standard` contract, so
the library is interchangeable:
```tsx
import * as v from 'valibot'
export const ideaQuery = root.lets
.query()
.input(v.object({ id: v.number() })) // valibot — same as zod here
.loader(/* ... */)
.query()
```
```tsx
import { type } from 'arktype'
export const ideaQuery = root.lets
.query()
.input(type({ id: 'number' })) // arktype
.loader(/* ... */)
.query()
```
Instead of a schema you can pass a **validate function** — it receives the raw
input and returns the parsed value, throwing to reject:
```tsx
export const ideaQuery = root.lets
.query()
.input((raw) => {
if (typeof raw.id !== 'number') throw new Error('id must be a number')
return { id: raw.id }
})
.loader(/* ... */)
.query()
```
A thrown error becomes one validation issue and fails the parse — on the client
/ mount path with the code `INPUT_SCHEMA_INVALID`; server-side the error
surfaces as your error class without that specific code. Server-side validation
runs **asynchronously** — a schema whose `~standard` validate returns a Promise
is awaited and accepted. Only the **client / mount** validation path is
synchronous: there, a Promise-returning schema is rejected at runtime with
`INPUT_SCHEMA_PROMISE_NOT_ALLOWED` (its message reads "for client input
schemas").
## Schema helpers — register your library on the root
A schema library is fully usable for validation on its own. But two things — the
routable keys of `.search`, and [OpenAPI](openapi) generation — need Point0 to
_look inside_ the schema (read its keys, detect file fields, emit JSON Schema).
That introspection is library-specific, so you register a **schema helper** once
on the [root](root):
```tsx
import { zodSchemaHelper } from '@point0/core/schema/zod'
export const root = Point0.lets
.root()
.schemaHelper(zodSchemaHelper()) // teach Point0 how to read zod schemas
.root()
```
Six helpers ship, each as a subpath of `@point0/core` — import the one matching
your library:
```tsx
import { zodSchemaHelper } from '@point0/core/schema/zod'
import { valibotSchemaHelper } from '@point0/core/schema/valibot'
import { arktypeSchemaHelper } from '@point0/core/schema/arktype'
import { yupSchemaHelper } from '@point0/core/schema/yup'
import { typeboxSchemaHelper } from '@point0/core/schema/typebox'
import { superstructSchemaHelper } from '@point0/core/schema/superstruct'
```
You can register more than one
(`.schemaHelper(zodSchemaHelper()).schemaHelper(valibotSchemaHelper())`) — they
accumulate, and the first one that recognizes a given schema wins.
`.schemaHelper` is **server-and-client** — not cut from either bundle, because
introspection (search keys, OpenAPI) runs on both sides.
Without a helper, `.search` treats **every** query-string key as a search param
— see [`.search`](#search--the-query-string) below.
## `.input` — queries, mutations, components
`.input` is the schema for a [query](query), [infiniteQuery](infinite-query),
[mutation](mutation), [component](component), or [provider](provider). It forms
the cache key, is validated on the server, and is sent to the loader:
```tsx
export const ideaListQuery = root.lets
.infiniteQuery()
.input(
z.object({ limit: z.number().optional(), cursor: z.number().optional() }),
)
.loader(async ({ input: { limit = 20, cursor } }) => {
/* ... */
})
.infiniteQuery(/* ... */)
```
`.input` validates on the **server only**. The component never sees an
unvalidated input, but it also doesn't re-validate input at render. For input
that must be checked on the client too, use the two variants:
```tsx
export const themeQuery = root.lets
.query()
.clientInput(schema) // validate at render / on the client — not on the server
.clientLoader(/* ... */)
.query()
export const ideaQuery = root.lets
.query()
.sharedInput(schema) // validate on BOTH server and client
.loader(/* ... */)
.query()
```
Use `.clientInput` / `.sharedInput` when a client-loader query (or a component)
must validate its input in the browser. Plain `.input` is the right default for
a server query.
`.input` is **server-only** — cut from the client bundle: its schema and the
imports it pulls in never ship to the browser. `.clientInput` is the mirror:
**client-only** — cut from the server bundle. `.sharedInput` is
**server-and-client** — not cut from either bundle, validating in both places.
## `.params` and `.search` — pages and layouts
A [page](page) or [layout](layout) gets its input from the URL: `.params` from
the route segments, `.search` from the query string.
### `.params` — route segments
The route already types its own segments: `.page('/ideas/:sn')` gives you a
typed `params.sn` (a `string`) with no schema at all. You reach for `.params`
**only** when you want to transform or coerce those strings into something else
— turn `"123"` into a number, validate a format, narrow a union:
```tsx
export const ideaViewPage = generalLayout.lets
.page('/ideas/:sn')
.params(z.object({ sn: z.coerce.number() })) // "123" → 123
.page(({ params }) =>
Idea #{params.sn}
) // params.sn is a number
```
A validate function passed to `.params` receives `Record` —
every value is a string, because that's what the router extracts from the path.
On a page or layout, `.params` is **server-and-client** — not cut from either
bundle, because the route parses the URL on either side. (On an [action](action)
the same method is **server-only** — cut from the client bundle, since an action
has no client side.)
`.params` can only _refine_ the keys already in the route — it can't introduce a
key that isn't a route segment. Adding an unknown key is a type error
(`Previous provided params should not have another keys…`). `.search` and
`.body` have no such restriction.
### `.search` — the query string
`.search` parses the URL query string. It's the one validation method that also
drives routing — its keys are registered as the page's recognized search params:
```tsx
export const ideaListPage = generalLayout.lets
.page('/ideas')
.search(
z.object({
page: z.coerce.number().default(0),
limit: z.coerce.number().default(2),
}),
)
.page(({ search, setSearch }) => {
// search is { page: number, limit: number }
// setSearch updates the URL query (client-only; a no-op during SSR)
})
```
To extract those keys, `.search` needs a
[schema helper](#schema-helpers--register-your-library-on-the-root) registered
on the root. **Without one**, Point0 can't read the schema's keys and falls back
to treating _every_ query-string key as a search param — register the helper for
your library.
Like `.params`, `.search` is **server-and-client** on a page or layout — not cut
from either bundle, so the query string parses on either side — and
**server-only** on an [action](action), where it's cut from the client bundle.
`setSearch` and the rest of the search/routing surface live on
[Navigation](navigation).
### Or declare both in the route string
The route string itself can carry what `.params` and `.search` express — typed
path params in square brackets, search params `&`-declared after the path
([route0](https://github.com/1gr14/route0)'s syntax):
```tsx
export const ideaViewPage = generalLayout.lets
.page('/ideas/:sn[int]') // ≡ .params(z.object({ sn: z.coerce.number().int() }))
.page(({ params }) =>
Idea #{params.sn}
) // params.sn is a number
export const ideaListPage = generalLayout.lets
.page('/ideas&page[int]=0&limit[int]=2') // ≡ .search(z.object({ page: …, limit: … }))
.page(({ search, setSearch }) => {
// search is { page: number, limit: number } — coerced, defaults filled
})
```
The effect is the same as the schema calls: the values arrive parsed and typed
in the loader, the component, the cache key and the OpenAPI spec, and a declared
search param needs **no schema helper** — the declaration itself names the keys.
A typed param goes further than a coercing schema: it also narrows **matching**,
so `/ideas/abc` is a 404 instead of a validation error.
Three differences to know:
- **A typed param decides the match.** `.params(z.coerce.number())` on
`'/ideas/:sn'` still matches `/ideas/abc` and then fails validation loudly;
`'/ideas/:sn[int]'` never matches it in the first place.
- **An invalid declared search value degrades, it doesn't throw.** On a matched
page `?page=abc` parses as an absent `page` (here: the default `0`) — the page
renders instead of erroring. A missing **required** declaration (`&token!`)
still fails validation like a required schema key.
- **Declarations close the search object** to the declared keys (a trailing `&`
keeps it open); undeclared keys still reach `request.location.search`, they
just stay untyped and out of the cache key.
Both can combine — `.search` refines on top of the declared keys (a nested
filter object, a cross-field rule), `.params` on top of the typed ones. Actions
take the same syntax: `root.lets('GET', '/api/items/:id[int]&page[int]=0')`. The
full pattern language — enums `(a|b)`, arrays `[]`, `[uuid]`/`[date]` params,
prefixes and tails — is route0's:
[typed params](https://github.com/1gr14/route0#give-a-param-a-type) and
[search declarations](https://github.com/1gr14/route0#declare-search-params-in-the-pattern).
## `.body`, `.headers`, `.cookies` — actions and request data
An [action](action) is a raw HTTP endpoint, so it splits its request across the
request schemas. This is the only place all of them appear together:
```tsx
export const updateAction = root.lets
.action('POST', '/api/ideas/:id')
.params(z.object({ id: z.string().min(1) })) // → path params (always required)
.search(z.object({ draft: z.coerce.boolean() })) // → query string
.headers(z.object({ 'x-api-key': z.string().min(1) })) // → request headers
.body(z.object({ title: z.string().min(1) })) // → request body
.loader(({ params, search, headers, body }) => {
/* all four are parsed and typed */
return { ok: true }
})
.action()
```
Each schema maps to an OpenAPI location: `.params` → path, `.search` → query,
`.headers` → header, `.cookies` → cookie, `.body` → request body. For a query or
mutation, `.input` is what OpenAPI emits as the body. See [OpenAPI](openapi).
`.headers` and `.cookies` are available on every point type — a page or layout
can read auth headers or cookies on its server request the same way. They
validate on the server.
`.body`, `.headers`, and `.cookies` are all **server-only** — cut from the
client bundle: each reads from the incoming HTTP request on the server, so its
schema never ships to the browser. (`.params` / `.search` on the action above
are also **server-only**, since an action has no client side; on a page or
layout those two are server-and-client — see above.)
## Schemas merge down the chain
A schema declared up the chain is **merged** with one declared lower — they
union by key. A [base](base) or [plugin](plugin) can declare part of an input,
and the point fills in the rest:
```tsx
export const tenantBase = root.lets
.base()
.input(z.object({ tenantId: z.string() })) // every query off this base carries tenantId
.base()
export const ideaQuery = tenantBase.lets
.query()
.input(z.object({ id: z.number() })) // merged input: { tenantId: string, id: number }
.loader(async ({ input }) => {
input.tenantId // ✓ from the base
input.id // ✓ from the query
})
.query()
```
`.sharedInput` merges into both the server and client input slots.
## Narrowing is allowed, widening is a type error
When a child redeclares a key the parent already set, it may **narrow** the type
(make it more specific) but never **widen** it. Widening a parent's schema is a
compile error:
```tsx
export const idBase = root.lets
.base()
.input(z.object({ id: z.number() }))
.base()
export const narrowedQuery = idBase.lets
.query()
.input(z.object({ id: z.literal(1) })) // ✓ narrows number → 1
.loader(/* ... */)
.query()
export const widenedQuery = idBase.lets
.query()
.input(z.object({ id: z.string() })) // type error: not assignable to the base schema
.loader(/* ... */)
.query()
```
## Security: validation runs on the server
For a server query, page, or action, input is parsed on the server before the
loader runs — the client can't smuggle an unvalidated shape into your loader.
But validation is not authorization. Gate access with a [`.with`](with) wrapper
that throws your [error class](error-handling) (`ErrorPoint0` by default, or
your own), not by trusting `.ctx` (which runs only when the point has a loader).
A `.params`/`.search`/`.input` schema decides _what shape_ the data has, never
_who_ may send it.
## Reference
### Method → point types → request location
| Method | Available on | Validated on | OpenAPI location |
| --------------- | ------------------------------------------------------------------------ | --------------- | ---------------- |
| `.input` | query, infiniteQuery, mutation, component, provider (+ root/base/plugin) | server | request body |
| `.clientInput` | same as `.input` | client / mount | — |
| `.sharedInput` | same as `.input` | server + client | request body |
| `.params` | page, layout, action | server + client | path (required) |
| `.search` | page, layout, action (+ root/base/plugin) | server + client | query |
| `.body` | action (+ root/base/plugin) | server | request body |
| `.headers` | every point type | server | header |
| `.cookies` | every point type | server | cookie |
| `.schemaHelper` | root only | — | — |
`.params` is deliberately **not** available on root/base/plugin — params are
route-bound and only mean something on a page, layout, or action.
### Overloads
Each schema method accepts the same three input shapes (where supported):
```tsx
.input(schema) // a Standard Schema object (zod/valibot/…)
.input((raw) => parsed) // a custom validate function
.input<{ id: number }>() // zero-arg: declare the shape at the type level only
```
The zero-arg type-only form exists on `.input`, `.clientInput`, `.sharedInput`,
and `.search`. `.params` and `.body` have only the schema and validate-function
forms (no zero-arg overload).
### How validation runs
Schemas are validated in **declaration order**; the first failure short-circuits
and returns the error — the loader never runs. Each schema reads from its own
bucket (`params` from route params, `search` from the query string, `body` from
the request body, and so on). One quirk: on an **action**, an `.input` schema
would read from `search` — but `.input` isn't available on actions, so in
practice actions use `.params` / `.search` / `.body`.
### `.input` vs component props
On a [component](component) (or provider), `.input` and the outer props declared
with the `.component<{…}>()` / `.provider<{…}>()` generic are two different
things. `.input` is the **validated** schema that forms the cache key and feeds
the loader — passed as `input={{…}}` at the mount site. Outer props are the
plain attributes the element accepts (``), spread directly and
not schema-validated. See [component](component) for both side by side.
---
# Point0 — Loader
Source: https://1gr14.dev/point0/v0/loader.md
A loader is the callback that produces a point's data. Each point has **one**
loader. The server `.loader` is **server-only** — cut from the client bundle:
its body and the imports it uses are removed, so it never ships to the browser,
and your database code never reaches it. `.clientLoader` is the inverse —
**client-only**: cut from the server bundle, regardless of SSR (it runs in the
browser).
```tsx
export const ideaViewQuery = root.lets
.query()
.input(z.object({ sn: z.string() }))
.loader(async ({ input: { sn } }) => {
// cut from the client bundle — this whole callback is removed from the client
// build, and so is every import only it uses (your Prisma client included)
const idea = await prisma.idea.findUniqueOrThrow({ where: { sn } })
return { idea } // becomes the point's `data`
})
.query()
```
Whatever the loader returns becomes the point's `data`. On a
[mountable](mountable) — page, layout, component, provider — that `data` is then
ready for `.with`, `.head`, `.mapper`, and the component itself. And the moment
you put a `.loader` on a mountable, that point **is also a query**: it exposes
the full query surface (`useQuery`, `fetchQuery`, prefetch, `getQueryKey`, …) so
you can load and cache its data like any other [query](query).
## Which points have loaders
`.loader` and `.clientLoader` are available on every concrete point:
[page](page), [layout](layout), [component](component), [provider](provider),
[query](query), [infinite-query](infinite-query), [mutation](mutation), and
[action](action).
```tsx
// a query is just input + loader
export const listAccountsQuery = root.lets
.query()
.loader(async ({ request }) => ({
accounts: await authServer.api.listUserAccounts({
headers: request.original.headers,
}),
}))
.query()
```
Two exceptions:
- An **action** has `.loader` (it's a server-only mutation) but **no
`.clientLoader`** — there's no browser side to run.
- The composition stages — [root](root), [base](base), [plugin](plugin) — have
neither, because their final point type isn't fixed yet. You add a loader once
you've branched into a concrete point (`.page`, `.query`, …).
A [page](page) or [component](component) without any loader is a pure
[mountable](mountable): it renders, issues no server request, and exposes no
useful query.
## What the loader receives
The server loader gets one object. Every key below is always present except the
parsed inputs, which appear only when you declared their schema:
```tsx
export const ideaUpdateAction = root.lets
.action('POST', '/api/ideas/:id')
.params(z.object({ id: z.string() }))
.search(z.object({ draft: z.coerce.boolean().optional() }))
.body(z.object({ title: z.string() }))
.loader(async ({ params, search, body, ctx, request, set, data, points }) => {
// ...
})
.action()
```
```tsx
export const ideaNewsPage = root.lets
.page('/ideas/:id/news')
.search(z.object({ page: z.coerce.number().default(0) }))
.loader(async ({ params, search }) => {
// params and search are parsed and typed — params from the route, search because its schema exists
return { news: await loadNews(params.id, search.page) }
})
.page(({ data }) => )
```
- **`input` / `params` / `search` / `body` / `headers` / `cookies`** — the
parsed, validated input. Each key exists only when you declared the matching
schema (`.input`, `.params`, …). See [Validation](validation).
- **`ctx`** — the server context built by `.ctx` and plugins up the chain. `ctx`
is server-only; you can read it here and in `.ctx`, never at render. The keys
a `.ctx(..., { expose })` exposed are also spread at the top level of this
object, alongside the nested `ctx`.
- **`data`** — the data accumulated so far up the chain (a shallow clone, so
reassigning top-level keys is harmless, but mutating nested objects still
reaches shared references). An empty `.loader()` returns exactly this.
- **`request`** — the incoming [request](request): `request.original` (the raw
`Request`), `request.method`, `request.location`, the lazy getters
`request.headers` and `request.cookies` (direct on the request), and the
caller details under `request.from` (`request.from.ip`,
`request.from.clientIp`, `request.from.ips`, `request.from.userAgent`).
- **`set`** — a [response](response) helper: `set.headers(name, value)`,
`set.cookies(name, value, options?)`, `set.status(code)`. It also carries
`set.inspect` — a read-only snapshot of the headers, cookies, and status set
so far (`set.inspect.headers.x`) — and `set.apply(response)`, which returns a
new `Response` with the accumulated headers, cookies, and status applied.
- **`points`** — the server points registry (`points.findPoint`,
`points.findEndpoint`, `points.collection`).
```tsx
export const meQuery = root.lets
.query()
.use(authorizedOnlyPlugin) // puts the user in ctx
.loader(async ({ ctx, set }) => {
set.cookies('seen', '1') // attach a Set-Cookie to the response
return { userId: ctx.me.user.id } // ctx populated by an upstream plugin
})
.query()
```
## What the loader returns
The return value becomes `data`. Six shapes are accepted:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.params(z.object({ id: z.string() }))
.loader(async ({ params }) => {
return { idea } // 1. plain data → becomes `data`
return undefined // 2. undefined / nothing → empty data {}
return [404, { idea }] // 3. [status, data] → also sets the HTTP status
return redirect('home') // 4. a redirect → redirects
throw new AppError('Not found') // 5. an Error (thrown or returned) → error state
return // 6. a React element → element as data (RSC)
})
.page(({ data }) => )
```
- **Plain data** — a plain object (`Record`). Arrays, strings,
and other primitives are a type error; data must be an object (or a React
element).
- **`undefined` / `void`** — treated as empty data `{}`.
- **`[status, data]`** — a tuple applies `set.status(...)` then uses the second
element as the data (or a `Response`); a redirect or error in the second slot
short-circuits first, so the status is not applied in that case.
- **A redirect** — return (or throw) a `RedirectTask` from the
[`redirect(...)`](navigation) helper; Point0 turns it into an HTTP redirect.
- **An `Error`** — returning an `Error` is the same as throwing one: it
short-circuits to the error state. A thrown error is converted through your
[error class](error-handling), and its `status` is applied to the response.
- **A React element** — the element becomes `data` and travels to the client
like any other value: server components render on the server, component points
hydrate as interactive islands. Elements may also sit inside the data object —
opt in with `.rsc({ depth: n })`. The whole model is on the [RSC](rsc) page.
```tsx
// real production loader: read ctx, gate, then return data — or throw a typed error
export const ideaUpdateMutation = root.lets
.mutation()
.use(authorizedOnlyPlugin) // puts the user in ctx
.input(ideaUpdateMutationSchema)
.loader(async ({ ctx, input: { sn, title, content } }) => {
const existing = await prisma.idea.findUniqueOrThrow({
select: { authorId: true },
where: { sn },
})
if (existing.authorId !== ctx.me.user.id) {
throw new AppError('Only the author can edit this idea', {
code: 'FORBIDDEN',
})
}
const idea = await prisma.idea.update({
where: { sn },
data: { title, content },
})
return { idea }
})
.mutation()
```
### Returning a `Response`
Only a [mutation](mutation) or an [action](action) loader may return a raw
`Response`; doing so sets the response and forces `data` to `undefined` ("a
response carries no data"). On a page, query, layout, component, or provider,
returning a `Response` is a type error:
```tsx
export const exportMutation = root.lets
.mutation()
.loader(() => new Response('ok'))
.mutation()
// ✓ on a mutation / action
// ✗ on a page or query — "Output can not be type of \"Response\" for point of type \"page\""
```
## Client loaders
`.clientLoader` runs in the browser instead of the server. Use it for data that
lives client-side — there's no `ctx`, `request`, `set`, or `points`, because
those are server-only:
```tsx
export const settingsQuery = root.lets
.query()
.clientLoader(() => ({ theme: localStorage.getItem('theme') ?? 'light' }))
.query()
```
The client loader's argument has `data`, `response` (the server `Response`, if
any), and the parsed client inputs (`input` from `.clientInput`, plus `params`
and `search`). Its return shapes match the server loader, **minus** the
`[status, data]` tuple — status is a server concern. A `RedirectTask` redirects,
an `Error` (thrown or returned) shows the error state, `undefined` is empty data
`{}`.
A query whose only loader is a `.clientLoader` runs entirely in the browser and
has **no HTTP endpoint** (and no [OpenAPI](openapi) entry). A `.loader` makes
the query a real endpoint; a `.clientLoader` does not. See [Query](query).
Data returned by a loader round-trips through the configured
[transformer](transformer), so non-JSON values survive the server-to-client hop:
```tsx
export const lastSeenQuery = root.lets
.query()
.clientLoader(() => ({ date: new Date('2026-01-01') }))
.query()
// in the component, `data.date` is a real Date instance, not a string
```
## One loader per point
A point has exactly one loader — server or client, never both, never two of the
same kind. The order is fixed: `.ctx`, all input schemas, then the single
loader. Nothing comes after it.
```tsx
root.lets.query().loader(fn).loader(fn) // ✗ second loader
root.lets.query().clientLoader(fn).loader(fn) // ✗ one loader total
root.lets.query().loader(fn).ctx(fn) // ✗ ctx must come before the loader
```
Both the type system and the runtime enforce this. The type error reads "You can
not use loader() after the loader — only one loader per point, and
ctx/input/schemas must be defined before it"; the runtime backstop throws "You
can not call .loader() … its setup stage is \"loadedStage\"".
## What runs where, and what stays secret
The compiler empties the server `.loader` callback for the client build and
drops the imports only it used — your Prisma client, secrets, and server SDKs
all vanish. Stripping is about which bundle the code is removed from, not where
rendering happens: when SSR is enabled, the first page load is still
server-rendered; only navigation between pages afterward runs client-side,
SPA-style.
A server `.loader` is a safe place for secrets and database access, but it does
**not** make the point itself a security gate. A page's `.ctx` and `.loader` run
only when the page actually has a loader and is requested — a loader-less page
makes no server call, so its `ctx` never runs. Gate authorization in
[`.with`](with) (or a [plugin](plugin) that combines `.ctx` and `.with`), not in
the loader alone:
```tsx
export const authorizedOnlyPlugin = Point0.lets
.plugin()
.with(({ props: { me } }) => {
if (!me)
return new AppError('Only for authorized users', { code: 'UNAUTHORIZED' })
return { me }
})
.plugin()
```
`me` comes from an upstream plugin that resolves the current user. `AppError` is
your own error class — anything with the shape of the default `ErrorPoint0`. See
[error handling](error-handling) for how to build one (with
[Error0](error-handling) or otherwise).
## Reference
### Server loader argument
| Key | Type | When |
| ---------------- | ---------------------------------------------------------- | ------------------------------------- |
| `input` | parsed `.input` value | when `.input` is set |
| `params` | parsed route params | when the route has params / `.params` |
| `search` | parsed query string | when `.search` is set |
| `body` | parsed request body | when `.body` is set |
| `headers` | parsed request headers | when `.headers` is set |
| `cookies` | parsed request cookies | when `.cookies` is set |
| `ctx` | server context | always (`{}` if no `.ctx`/plugin) |
| exposed ctx keys | whatever `.ctx(expose)` exposed | spread at top level alongside `ctx` |
| `data` | accumulated data up the chain | always (clone) |
| `request` | the [request](request) | always |
| `set` | [response](response) helper (`headers`/`cookies`/`status`) | always |
| `points` | server points registry | always |
### Return values
| Return | Effect |
| ---------------------------- | --------------------------------------------------------------------------- |
| plain object | becomes `data` |
| `undefined` / `void` | empty data `{}` |
| `[status, data]` | sets the HTTP status, then handles `data` (server only) |
| `Response` | sets the response, `data` becomes `undefined` (mutation / action only) |
| `RedirectTask` | redirects (from the [`redirect(...)`](navigation) helper) |
| `Error` (thrown or returned) | error state; status applied; normalized via your error class |
| React element | element as data — see [RSC](rsc); nested elements need `.rsc({ depth: n })` |
| array / string / primitive | **type error** — data must be a plain object or a React element |
The client loader (`.clientLoader`) takes the same return values **except** the
`[status, data]` tuple, and a `Response` only on a mutation. Its argument is
`{ data, response }` plus the parsed client inputs (`input`, `params`, `search`)
— no `ctx`, `request`, `set`, or `points`.
---
# Point0 — Context
Source: https://1gr14.dev/point0/v0/ctx.md
`.ctx` adds values to a server-only context. The function runs on the server
during a request; what it returns is merged into the running context, and every
later `.ctx` and the `.loader` can read it. It's the place to resolve
request-scoped values — the current user, a per-request flag — once, before the
loaders. (A DB client or anything that doesn't depend on the request belongs in
a plain `import`, not in `ctx` — the import is stripped from the client bundle
anyway.)
```tsx
export const myIdeasQuery = root.lets
.query()
.ctx(async ({ request }) => {
return { me: await getMe({ request }) } // me is now in ctx for every loader below
})
.loader(async ({ ctx }) => {
const ideas = await prisma.idea.findMany({
where: { authorId: ctx.me?.id },
})
return { ideas }
})
.query()
```
**Cut from the client bundle — its body and the imports it uses are removed, so
it never ships to the browser.** This holds whether or not the point has
`ssr: true`. (It runs on the server, during the request, before the loader.)
## Which points
`.ctx` is available on every point — [root](root), [base](base),
[plugin](plugin), [page](page), [layout](layout), [component](component),
[provider](provider), [query](query), [infinite-query](infinite-query),
[mutation](mutation), and [action](action) — while it's still being composed
(before the loader). After you finalize a point, `.ctx` is gone; calling it on a
finalized or already-loaded point is a type error and throws at runtime.
It runs only on points that issue a server request. An [action](action) always
has a server loader, so its `.ctx` always runs when it executes. A
[query](query), [infinite-query](infinite-query), or [mutation](mutation) runs
its `.ctx` only when it has a server `.loader()`; a `.clientLoader()`-only
query/mutation runs in the browser with no endpoint, so — like a loader-less
[page](page) — its server-only `.ctx` never runs. A [page](page) or
[layout](layout) **without** a loader also makes no request — see
[the gotcha](#it-runs-only-when-the-point-has-a-loader) below.
## The merge
What you return is shallow-merged onto the previous context — later keys win:
```tsx
export const myIdeasQuery = root.lets
.query()
.ctx(() => ({ x: 1 }))
.ctx(({ ctx }) => ({ y: ctx.x + 1, x: 999 })) // reads x from the previous ctx
.loader(/* ... */)
.query()
// nextCtx = { ...prevCtx, ...returned } => { x: 999, y: 2 }
```
You can stack as many `.ctx` calls as you like — each sees the context built up
by the ones before it. There is still only **one loader** per point.
Return `undefined` (or nothing) and the context is left untouched — useful for a
`.ctx` that only validates or redirects:
```tsx
export const myIdeasQuery = root.lets
.query()
.ctx(({ ctx }) => {
if (!ctx.feature) return // no override
return { extra: load() }
})
.loader(/* ... */)
.query()
```
## What the function receives
The callback gets one object:
```tsx
export const myIdeasQuery = root.lets
.query()
.ctx(({ ctx, request, set, points }) => {
// ctx — the context accumulated so far (fully typed)
// request — the incoming Request0
// set — response helper for headers / cookies / status
// points — the server-side points collection (e.g. openapi reads it to build its schema)
return {}
})
.loader(/* ... */)
.query()
```
Parsed `input` / `params` / `search` / `body` / `headers` / `cookies` are also
present when the matching schema is declared **above** this `.ctx` in the chain.
## Plain object instead of a function
When the values don't depend on the request, pass an object directly:
```tsx
export const myIdeasQuery = root.lets
.query()
.ctx({ tenant: 'acme' }) // same as .ctx(() => ({ tenant: 'acme' }))
.ctx(() => ({ now: Date.now() })) // a function for request-time values
.loader(/* ... */)
.query()
```
Passing a function where an object is expected is a type error
(`Use ctx(fn) for function values`). Returning an array from a `.ctx` function
is also a type error (`Ctx fn should not return array`).
## Redirect and error
Return (or throw) a `RedirectTask` to redirect — later `.ctx` calls and the
loader don't run:
```tsx
export const myIdeasQuery = root.lets
.query()
.ctx(({ ctx }) => {
if (!ctx.me) return redirect('signIn')
return { me: ctx.me } // narrows me to non-null for the rest of the chain
})
.loader(/* ... */)
.query()
```
Return (or throw) an `Error` to abort the request with that error:
```tsx
export const myIdeasQuery = root.lets
.query()
.ctx(({ ctx }) => {
if (!ctx.me)
throw new ErrorPoint0('Only for authorized users', {
code: 'UNAUTHORIZED',
})
return { me: ctx.me }
})
.loader(/* ... */)
.query()
```
`ErrorPoint0` is the framework's default error class; returning it and throwing
it behave the same. You can swap it for your own class of the same-or-wider
shape — `AppError` in the rest of this page is just such a class. See
[Error handling](error-handling).
## Exposing keys
By default a `.ctx` value is reachable only through `ctx.x`. Pass `expose` to
also spread the key at the top level of later `.ctx` and `.loader` arguments:
```tsx
export const allExposedQuery = root.lets
.query()
.ctx({ x: 1 }, true) // expose every returned key
.loader(({ ctx, x }) => ({ x })) // both ctx.x and bare x are available
.query()
export const oneExposedQuery = root.lets
.query()
.ctx({ x: 1, y: 2 }, ['x']) // expose only x
.loader(({ x }) => ({ x })) // x is here; y is NOT — only ctx.y
.query()
```
`expose: true` exposes all returned keys; a string array exposes only those.
Exposed keys accumulate across multiple `.ctx` calls. These keys are reserved
and can't be exposed (it's a type error): `request`, `input`, `inputRaw`,
`data`, `set`, `execute`, `ctx`.
## It runs only when the point has a loader
`.ctx` runs **only when the point issues a server request** — that is, only when
it has a loader. A loader-less [page](page) or [layout](layout) makes no
request, so its `.ctx` never executes.
```tsx
// This page has no loader → no request → this .ctx never runs.
export const profilePage = root.lets
.page('/profile')
.ctx(({ ctx }) => {
if (!ctx.me) throw new AppError('nope', { code: 'UNAUTHORIZED' }) // NEVER fires
return { me: ctx.me }
})
.page(({ props: { me } }) => )
```
**Do not gate access in `.ctx` alone** — gate it in [`.with`](with), which runs
at render on both server and client, so it fires on the initial SSR render and
on every later client-side navigation. The production pattern pairs a `.ctx`
(for server loaders) with a `.with` (for the render):
```tsx
export const authorizedOnlyPlugin = Point0.lets
.plugin()
.use(mePlugin) // an upstream plugin that puts `me` in ctx and props
.ctx(({ ctx: { me } }) => {
if (!me)
throw new AppError('Only for authorized users', { code: 'UNAUTHORIZED' })
return { me } // narrows me to non-null in ctx
})
.with(({ props: { me } }) => {
if (!me)
return new AppError('Only for authorized users', { code: 'UNAUTHORIZED' })
return { me } // narrows me to non-null in props
})
.plugin()
```
`me` comes from the upstream `mePlugin` — `props.me` doesn't appear on its own.
See [`.with`](with) and [Plugin](plugin) for the full gate.
Unlike `.ctx`, `.with` is **server-ssr-and-client**: cut from the server bundle
when `ssr: false` (or after a `.clientOnly()`), kept in the client build always,
and in the server build only when SSR is on. `.ctx` (cut from the client bundle)
and `.with` (cut from the server bundle only when SSR is off) cover the two
halves: loaders and render.
## Reference
### Signature
```tsx
.ctx(fn) // function: receives { ctx, request, set, points, ...parsed }
.ctx(object) // plain object, when values don't depend on the request
.ctx(fnOrObject, true) // expose every returned key
.ctx(fnOrObject, ['a', 'b']) // expose only these keys
```
Returns the same point chain with `ctx` advanced. Chainable.
### Function return values
| Return | Effect |
| ------------------------------------- | ------------------------------------------------------- |
| a plain object | shallow-merged into `ctx` (later keys override earlier) |
| `undefined` / `void` | leaves `ctx` unchanged |
| an `Error` (returned or thrown) | aborts the request with that error |
| a `RedirectTask` (returned or thrown) | redirects; later `.ctx` / loader don't run |
| an array | **type error** — `Ctx fn should not return array` |
The function may be `async`; it's awaited. A plain object argument must not be a
function (`Use ctx(fn) for function values`).
### Forbidden expose keys
`request`, `input`, `inputRaw`, `data`, `set`, `execute`, `ctx`. Listing any in
`expose` is a type error (`Forbidden to expose ctx keys: ...`).
---
# Point0 — Middleware
Source: https://1gr14.dev/point0/v0/middleware.md
`.middleware` adds a server-side function that runs around a request, the same
idea as Express or Koa middleware. You reach for it to mount things that live
_outside_ the point model — CORS, a third-party auth library, an OpenAPI doc
server — not to load data or guard a point. For point0's own data and access
control you use [loaders](loader), [`.ctx`](ctx), and [`.with`](with) instead;
middleware is the escape hatch for everything else.
```tsx
export const root = Point0.lets
.root()
// hand the whole /api/auth/* tree to better-auth
.middleware('/api/auth/*', async ({ request }) =>
authServer.handler(request.original),
)
// serve the OpenAPI spec + Scalar/Swagger UIs
.middleware(
openapi({
route: '/openapi.json',
scalar: '/scalar',
swagger: '/swagger',
filter: 'all',
before: basicAuth({ users: serverEnv.OPENAPI_CREDENTIALS }),
}),
)
.root()
```
Mounted on the root, a middleware runs on **every** server request.
## Where it runs, and where it doesn't
`.middleware` is **server-only** — cut from the client bundle: the
[compiler](compiler) empties its arguments, so the body and anything it pulls in
(your `authServer`, `prisma`, secrets) never ships to the browser; on the client
it's a no-op. It stays in the server build, where it runs — call server-only
code inside it freely.
This is also why middleware is the wrong tool for an authorization gate. After
the initial SSR render, navigation is client-side (SPA-style), and a point that
isn't server-rendered still renders in the browser — whether or not a server
middleware ran. Gate access in a [`.with`](with) wrapper, which runs at render
on both server and client.
## Which points have it
`.middleware` is a **stage-method** — you call it while building a point, before
the closing `.root()` / `.page()` / `.query()` / etc. It's available on every
stage point: [root](root), [base](base), [plugin](plugin), [page](page),
[layout](layout), [component](component), [provider](provider), [query](query),
[infinite-query](infinite-query), [mutation](mutation), and [action](action). It
is **not** a ready-method — once the point is closed it's gone.
In practice it lives on the [root](root) (run on every request) or on an
individual point. A point's own middleware runs only when that point is hit:
```tsx
const page = root.lets
.page('/dashboard')
.middleware(async ({ next }) => {
// runs only for a request to /dashboard, after root's middleware
return next()
})
.page(/* ... */)
```
A query, infinite-query, mutation, and action each have their own server
endpoint, so their middleware fires on a request to that endpoint. A component
or provider has an endpoint only when it carries a [loader](loader); without one
it's never hit on the server, so its middleware never runs. A plugin is not a
server point at all — its middleware folds into whatever point applies the
plugin, and runs there. Whichever point a request resolves to, that point's full
middleware list — inherited (from the root down) then its own — runs around it.
## The middleware function
A middleware receives one options object and returns a `Promise` of either a
`Response` or the result of `next()`:
```tsx
export const root = Point0.lets
.root()
.middleware(async ({ request, set, next, points }) => {
// ...do something before the request is handled
const result = await next() // run the rest of the chain + the point
// ...do something after
return result
})
.root()
```
`next()` runs the remaining middleware and finally the point itself. Call it
once and return either its result or your own `Response`. Calling `next()` twice
throws `next() called multiple times`.
The options object:
| Key | What |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `request` | the [Request0](request) wrapper. `request.original` is the native Fetch `Request`; also `request.headers`, `request.method`, `request.location`, `request.from.ip`, and `request.state` / `request.cache` for passing data downstream |
| `set` | the [response effects](response) helper — `set.headers(...)`, `set.cookies(...)`, `set.status(n)`, plus `set.inspect` to read back what's set |
| `next` | run the rest of the chain; returns a detailed result (see below) |
| `points` | the server [points collection](engine-runtime) — `collection`, `findPoint`, `findEndpoint`, `findPage` |
| `scope` | the points [scope](query#mode-tags-scope) this middleware belongs to |
| `params` | parsed route params — **present only when the scoping route has params** |
### What `next()` returns
`next()` does not return a bare `Response` — it returns a detailed result so an
outer middleware can inspect what the inner chain produced before deciding what
to send:
```tsx
export const root = Point0.lets
.root()
.middleware(async ({ next }) => {
const result = await next()
// result.variant.type: 'page' | 'endpoint' | 'middleware' | 'error' | 'options' | 'publicdir' | 'asset'
if (result.variant.type === 'page') {
return new Response('overriden page response', { status: 200 })
}
return result // pass it through unchanged
})
.root()
```
`result` carries `response`, `request`, `scope`, `error` (your error class, or
`undefined`), and `variant`. Return a new `Response` to override, or the
`result` to forward.
## Scope by route
Pass a route string (or a [Route0](navigation) route object) as the first
argument, then the function(s). The middleware runs only on an **exact** path
match; on any other path it transparently forwards via `next()`:
```tsx
// only on /api/auth/* — better-auth owns that whole subtree
export const root = Point0.lets
.root()
.middleware('/api/auth/*', async ({ request }) =>
authServer.handler(request.original),
)
.root()
```
A trailing `*` is a wildcard segment that captures the remainder of the path, so
`/api/auth/*` matches `/api/auth/sign-in/email`. Named params arrive typed in
`params`:
```tsx
export const root = Point0.lets
.root()
.middleware('/zxc/:id', async ({ params }) => {
// params is { id: string }
return Response.json({ id: params.id }, { status: 201 })
})
.root()
```
This only illustrates route params and a returned `Response` — don't build your
own JSON endpoints this way. For an endpoint, author an [action](action) point
instead.
The wildcard's captured remainder arrives in `params` under the key `*`. For
`/api/auth/*` matching `/api/auth/sign-in/email`, `params` is
`{ '*': '/sign-in/email' }`:
```tsx
export const root = Point0.lets
.root()
.middleware('/api/auth/*', async ({ request, params }) => {
// params['*'] is the part after /api/auth, e.g. '/sign-in/email'
return authServer.handler(request.original)
})
.root()
```
A **string** route is extended off the point's own route, so a route on a based
point composes with its inherited prefix — exactly like a [page's route](page).
A route object is used as-is.
## Scope by method
Put a method (or array of methods) before the route to also filter by HTTP
method. A request that misses the method falls through — for an
otherwise-unknown path that means a 404:
```tsx
export const root = Point0.lets
.root()
.middleware('POST', '/zxc/:id', async ({ params }) => {
return Response.json({ id: params.id }, { status: 201 })
})
// POST /zxc/123 => 201
// PUT /zxc/123 => 404 Not Found (method didn't match, nothing else handles it)
.middleware(['POST', 'PUT'], '/zxc/:id', () => {
/* ... */
})
// POST and PUT => handled; DELETE => 404
.root()
```
A route-only middleware (no method) reacts to **any** method on that path.
## Setting headers, cookies, status
`set` mutates the response that the chain finally returns — the effects are
applied even when a later middleware returns its own `Response` or throws:
```tsx
export const root = Point0.lets
.root()
.middleware(async ({ set, next }) => {
set.headers('x-timing', 'on')
return next()
})
.root()
```
`set` in a middleware is the same helper a loader gets, so `set.cookies(...)`
and `set.status(n)` work alongside `set.headers(...)`:
```tsx
export const root = Point0.lets
.root()
.middleware(async ({ set, next }) => {
set.headers('x-timing', 'on')
set.cookies('seen', '1')
set.status(201)
return next()
})
.root()
```
`set.inspect` reads back what's been set so far (`set.inspect.headers.y`),
useful when one middleware reacts to another's headers. Full surface is on the
[Response](response) page.
## Composition and order
Multiple `.middleware` calls accumulate in declaration order, and several
functions in one call merge into one. Inherited middleware runs **first**: a
point built off the root runs the root's middleware, then its own.
```tsx
const root = Point0.lets
.root()
.middleware(async ({ next }) => {
/* 'root' */ return next()
})
.root()
const page = root.lets
.page('/home')
.middleware(async ({ next }) => {
/* 'page' */ return next()
})
.page(/* ... */)
// order for a /home request: root, then page
```
**A scope with no middleware of its own runs the server scope's chain.** A
multi-client app declares a root per scope, and a client root that adds no
`.middleware()` is not opting out of the gates the server root declares — the
server chain runs for its requests too, socket upgrades included. Declare a
middleware on that root and its own chain takes over.
Under [SSR](ssr) a page request can run the middleware chain more than once:
point0 uses a render-to-discover loop, so a page may re-render a few times (or
just once, if it has no pending data) before the HTML settles. Keep middleware
side-effect-light and idempotent. See [SSR](ssr) for how the loop works and
`request.renders` on the [Request0](request) page for the live render-pass
count.
## Errors thrown inside
Throwing from a middleware turns into an error response. A plain `Error` becomes
`500`; an [`ErrorPoint0`](error-handling) (or your own error class) carries its
status:
```tsx
export const root = Point0.lets
.root()
.middleware(async () => {
throw new ErrorPoint0('restricted error', { status: 403 }) // => 403
})
.middleware(async () => {
throw new Error('custom error') // => 500
})
.root()
```
Headers you set with `set.headers` before the throw survive onto the error
response.
## Passing data to loaders
A middleware can stash data on the request for a later loader or [`.ctx`](ctx)
to read, instead of forcing it through the point chain. There are two scratch
maps — `request.state` is per request instance, `request.cache` is shared along
the whole request chain (and across SSR render passes), so a value written in
middleware reaches every downstream loader:
```tsx
export const root = Point0.lets
.root()
.middleware(({ request, next }) => {
request.state.x = 123 // per-instance scratch
request.cache.y = 456 // chain-shared — survives across SSR hops & re-renders
return next()
})
.root()
```
See [Request0](request#per-request-storage-state-vs-cache) for when to use
which.
## Built-in middleware
These ship as functions you pass straight to the functions-only form — they are
`MiddlewareFn`s. Each is server-only too: cut from the client bundle (body and
its imports removed), so it never ships to the browser and is a no-op there.
```tsx
export const root = Point0.lets
.root()
.middleware(cors({ origin: 'https://app.example.com', credentials: true }))
.middleware(basicAuth({ users: { admin: 'adminpassword' } }))
.middleware(openapi({ route: '/openapi.json', scalar: '/scalar' }))
.middleware(compress())
.middleware(cacheControl())
.root()
```
- **[`cors`](cors)** (`@point0/cors`) — sets CORS headers and answers preflight
`OPTIONS`. Accepts-all by default; configure `origin`, `methods`,
`allowedHeaders`, `credentials`, `maxAge`, `preflight`.
- **[`basicAuth`](basic-auth)** (`@point0/basic-auth`) — HTTP Basic auth gate.
`users` is a `Record` (or a string / list / `validator` fn);
either forwards via `next()` or returns a `401`/`429`.
- **[`openapi`](openapi)** (`@point0/openapi`) — serves the generated OpenAPI
JSON (and optional Scalar / Swagger UIs) on `GET` of the configured routes.
Its options and the separate per-point `.openapi()` method live on the
[openapi](openapi) page.
- **[`compress`](compress)** (`@point0/compress`) — streaming brotli/gzip
compression at the origin, negotiated per `Accept-Encoding`.
- **[`cacheControl`](cache-control)** (`@point0/cache-control`) — a correct
`Cache-Control` per response variant: immutable hashed assets, no-store SSR
HTML.
## Reference
### Signatures
```tsx
.middleware(...fns) // run on every request to this point
.middleware(route, ...fns) // only on an exact route match
.middleware(method, route, ...fns) // ...and only for these HTTP method(s)
```
- `route` is a route string (extended off the point's own route) or a
[Route0](navigation) route object.
- `method` is one of point0's request methods (`'GET'`, `'POST'`, …) or an array
of them — any string is accepted.
- At least **one** function is required. Each returns
`Promise`. Multiple functions in one call
merge into a single koa-style chain.
- Returns the same stage point, so it chains.
### Result variants from `next()`
`result.variant.type` is one of: `'page'`, `'endpoint'`, `'middleware'`,
`'error'`, `'options'`, `'publicdir'`, `'asset'`. Each result also has
`response`, `request`, `scope`, and `error` (`undefined` when there was no
error). `'asset'` vs `'publicdir'` is the static-file split by URL contract —
content-hashed build files vs stable names — see
[request.variant](request#requestvariant).
---
# Point0 — Loading & error
Source: https://1gr14.dev/point0/v0/loading-error.md
A point with pending data shows a **loading** component; a point whose data
throws shows an **error** component. You declare both with `.loading` and
`.error` — usually once near the [root](root), overriding per point when you
need to. Point0 picks the nearest one up the chain, so a page never has to write
a loading branch itself.
```tsx
export const root = Point0.lets
.root()
.loading(() => )
.error(({ error }) => )
.root()
```
Every page, layout, and component below this root now has a loading and an error
screen — without repeating them.
## The component contracts
A loading component receives only its render position; an error component also
receives the error:
```tsx
export const root = Point0.lets
.root()
.loading(({ type }) => )
// type is 'page' | 'component' | 'layout' — where this loading renders
.error(({ type, error }) => )
// error is an ErrorPoint0 (or any error class you configured), never a raw Error
.root()
```
`type` lets one component branch on where it sits — a full-page spinner for
`'page'`, an inline one for `'component'`. `error` is normalized through the
configured error class first (`ErrorPoint0.from(...)` by default), so
`error.message` is typed and you can read `error.status`, `error.code`, and the
rest. Swap `ErrorPoint0` for your own error class — any class with a compatible
(same-or-wider) structure works. See [Error handling](error-handling).
If you set nothing, Point0 falls back to its defaults: `Loading...` text, and a
`
` dump of the error. The default error component **never renders the stack
in production** — only in dev — to avoid baking a server stack trace into the
SSR HTML.
`.loading` / `.error` and their per-variant forms (`.pageLoading`,
`.layoutError`, …) are render-side [stage-methods](stage-methods): under
`ssr: false`, or after a `.clientOnly()` earlier in the chain (which makes the
rest of the chain behave as `ssr: false`), their bodies are stripped from the
server bundle along with the other render methods (`.page` / `.layout` / `.with`
/ …). They never carry server-only secrets, so gate auth in a [`.with`](with),
not here (see [below](#security-dont-gate-auth-with-loadingerror-alone)).
> **Strip category — server-ssr-and-client.** Applies to `.loading` and `.error`
> and every per-variant form (`.pageLoading` / `.pageError`, `.layoutLoading` /
> `.layoutError`, `.componentLoading` / `.componentError`): kept on the client
> always; on the server only when SSR is enabled.
## Where to declare them
`.loading` and `.error` live on the points that _render_ — [root](root),
[base](base), [plugin](plugin), [page](page), [layout](layout),
[component](component), and [provider](provider). They are **not** available on
a [query](query), [infinite-query](infinite-query), [mutation](mutation), or
[action](action): a data-only point has no UI, so you declare its loading and
error on whatever page, layout, or component consumes it.
```tsx
// ✅ on the page that shows the query
export const ideaPage = root.lets
.page('/ideas/:id')
.loading(() => )
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.page(({ data: { idea } }) =>
{idea.title}
)
// ❌ a query has no .loading / .error — type error
export const ideaQuery = root.lets.query() /* .loading(...) */
```
A page, component, or provider has only the unified `.loading` and `.error`. A
[provider](provider) renders in the page position, so its loading and error
components receive `type: 'page'`. The [root](root), [base](base), and
[plugin](plugin) have the full set, and a [layout](layout) has six of them — see
[the matrix](#which-points-have-which) at the bottom.
## Nearest up the chain wins
When data is pending, Point0 walks up the chain and uses the nearest `.loading`;
on a thrown error, the nearest `.error`. Declare once high, override low:
```tsx
export const root = Point0.lets
.root()
.loading(() => ) // the default for everything below
.error(({ error }) => )
.root()
export const ideaPage = root.lets
.page('/ideas/:id')
.loading(() => ) // overrides the root spinner for this page
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.page(/* ... */)
```
Loading and error resolve **independently**: a page can override `.loading` and
still inherit the root's `.error`, or the other way round.
A [plugin](plugin) can carry its own `.loading` / `.error`; when you `.use` it
on a point, its declarations join the chain at that position and override what
came before — handy for shipping a loading/error theme as a plugin.
## Where to put `.loading` in the chain
**Above the loaders and queries.** One habit covers every case:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.loading(() => ) // fallbacks first…
.error(({ error }) => )
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) })) // …data below
.page(/* ... */)
```
The reason is that each `.loading` / `.error` is **positional**: it opens a
Suspense/error boundary around the REST of the chain below it. Whatever suspends
or throws is caught by the closest boundary declared **above** it:
- A [streamed or suspending query](ssr#the-ssr-and-suspend-query-options)
(`suspend: 'server' | 'client' | true`, the
[suspense hooks](ssr#usesuspensequery--usesuspenseinfinitequery)) suspends
into that boundary — one declared below the query cannot catch it.
- A `.with` that returns `'loading'` or an error (see
[below](#driving-loading-and-error-by-hand-from-with)) renders the nearest
`.loading` / `.error` declared above it; one declared lower in the chain
wasn't in scope yet.
- A render **throw** lands in the nearest `.error` above it the same way.
Only for a plain blocking query is placement relaxed: it doesn't suspend — every
chain query fetches in **parallel** and the point blocks on pending data right
before it renders (`.page` / `.layout` / `.component`), so the entry fallback
(the point's **last-declared** `.loading`) shows either way. That relaxation is
easy to outgrow — add `suspend` to that query later and a below-the-query
`.loading` silently stops applying. Declare fallbacks first and you never think
about it.
Chain position is what counts, not where the data was declared: a page's
`.loading` placed above such a `.with` overrides the loading even for a query
inherited from a [base](base) upstream.
## Per-variant declarations
On the [root](root), [base](base), [plugin](plugin), or a [layout](layout) — the
points that span more than one render variant — you can target a single variant.
A layout, for example, renders its own UI _and_ hosts a page below it, so it can
set loading for each independently:
```tsx
export const ideasLayout = root.lets
.layout('/ideas')
.layoutLoading(() => ) // while the layout's own loader runs
.pageLoading(() => ) // while a page below it loads
.layoutError(({ error }) => )
.pageError(({ error }) => )
.layout(/* ... */)
```
The full set, per render position:
- `.pageLoading` / `.pageError` — for a page rendered below this point.
- `.layoutLoading` / `.layoutError` — for this point's own layout render.
- `.componentLoading` / `.componentError` — for a component rendered below.
The plain `.loading` / `.error` are the **collapsing aliases**: on these
multi-variant points they set page, layout, and component at once. For the
active variant, a unified `.loading` wins over the variant-specific one — when
you declare both on the same point, the unified one is resolved first. A layout
has the page and layout variants only — it can't host arbitrary components in
this surface, so it has no `.componentLoading` / `.componentError`.
## SSR hides the first-paint loading — for server data
With SSR on, the server runs each point's loaders, renders the resolved HTML,
and ships the query cache alongside it as a **dehydrated React Query state**
(the same `dehydrate`/`hydrate` mechanism you'd use with TanStack Query by hand
— not loader data hand-inlined into the markup). The browser hydrates that
snapshot, so every server-loaded query is already `success` on first paint and
its loading component never appears:
```tsx
// page + component both use a server .loader →
// the SSR HTML shows the real content, and the dehydrated cache
// hydrates so no query re-fetches or flashes "Loading..."
```
The catch: this only holds for data the server actually has in that snapshot. A
[`.clientLoader`](loader) runs in the browser, so the server has nothing to
dehydrate for it — its loading component **does** show in the SSR HTML and
resolves once the client takes over. See [SSR](ssr).
## Prefetch decides loading on navigation
On client navigation, whether the loading component flashes depends on the
[prefetch policy](navigation). With no prefetch, the destination's data starts
loading after the click, so its `.loading` shows:
```tsx
// prefetchPagePolicy 'none' / false → loading shows on every navigation
```
Prefetch the page before the transition — on hover, or eagerly — and the data is
already in cache when the route commits, so no loading appears:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.prefetchPageOnLinkHover('serverAndClientQuery') // fetch on hover → no flash
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.page(/* ... */)
```
`serverAndClientQuery` is the cheap policy (fetch the data, no SSR render);
`pageDehydratedStateAndClientQuery` does a full server render and is heavier.
The policies and `.prefetchPageOnNavigate` / `.prefetchPageOnLinkHover` are
covered on [Navigation](navigation) and [SSR](ssr).
## A top progress bar (NProgress)
`.loading` covers a point that has no data yet. For the _transition itself_ —
the gap between click and the next page committing — drive a top bar from the
navigation hook. Mount this once in your client app:
```tsx
import { useOnNavigate } from '@point0/core/navigation'
import nprogress from 'nprogress'
export const NProgress = () => {
useOnNavigate(() => {
// runs when a navigation starts
const timeout = setTimeout(() => nprogress.start(), 30)
// the returned cleanup runs when the navigation ends
return () => {
clearTimeout(timeout)
nprogress.done()
}
})
return null
}
```
The 30 ms timeout keeps the bar from flashing on instant transitions — a
convention, not a framework rule. Related: `useIsNavigating()` returns a boolean
you can use to dim the page during a transition (the basic example does this),
and `useNavigationPageState()` exposes the `status` / `loading` / `error` of the
current transition. All ship from `@point0/core/navigation` — see
[Navigation](navigation).
## Driving loading and error by hand from `.with`
When data isn't a plain query — you compute readiness from a hook, say — return
the reserved values from a [`.with`](with) function to render the same
components:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(() => {
const ready = useSomethingReady()
if (!ready) return 'loading' // → renders the active loading component
if (broke) return new ErrorPoint0('Failed', { status: 500 }) // → error component
})
.page(() => )
```
`.with` also hands you `LoadingComponent` and `ErrorComponent` as props, so you
can render them directly — `return `. The full set of
reserved returns is on [`.with`](with).
## Security: don't gate auth with loading/error alone
A loading or error screen is UI — it doesn't keep data off the client. The
browser bundle is public (after the initial SSR render, navigation is
client-side, SPA-style), so the page body ships to the browser; only server-only
code — `.ctx`, server `.loader` bodies — is stripped at compile time. Gate
access in a [`.with`](with) by returning an error, not by hiding content behind
a loading state:
```tsx
import { authPlugin } from '@/lib/auth' // puts the user in props.me
import { ErrorPoint0 } from '@point0/core'
export const adminPage = root.lets
.page('/admin')
.use(authPlugin)
.with(({ props: { me } }) => {
if (!me?.isAdmin) return new ErrorPoint0('Forbidden', { code: 'FORBIDDEN' })
return { me }
})
.page(/* ... */)
```
The returned error short-circuits to the error component. Because it carries a
`status`, it also sets the HTTP status during SSR. Don't use `.ctx` for this — a
`.ctx` gate runs only when the point has a loader, so a loader-less page never
fires it. See [`.with`](with).
## Reference
### Which points have which
| Method | root / base / plugin | page / component / provider | layout |
| --------------------------------------- | :------------------: | :-------------------------: | :----: |
| `.loading` / `.error` | ✅ | ✅ | ✅ |
| `.pageLoading` / `.pageError` | ✅ | — | ✅ |
| `.layoutLoading` / `.layoutError` | ✅ | — | ✅ |
| `.componentLoading` / `.componentError` | ✅ | — | — |
[query](query), [infinite-query](infinite-query), [mutation](mutation), and
[action](action) expose none of these. Neither does a finalized (ready) point —
declare loading and error during the build phase, before the point is closed.
### The component props
| Component | Props received | Notes |
| --------- | ----------------- | ------------------------------------------------------------ |
| loading | `{ type }` | `type` is `'page' \| 'component' \| 'layout'` |
| error | `{ type, error }` | `error` is an `ErrorPoint0` (or your configured error class) |
### Resolution rules
- **Nearest up the chain.** Pending data → nearest `.loading`; thrown data →
nearest `.error`. Loading and error resolve independently.
- **Position matters.** Each `.loading` / `.error` opens a boundary around the
rest of the chain below it; a suspending query, a `.with` short-circuit, or a
render throw uses the nearest one declared **above** it. Only a plain blocking
query relaxes this (the point blocks at render, so the last-declared
`.loading` shows either way) — declare fallbacks above the loaders anyway.
- **Render throws are caught.** Every mountable render is wrapped in an error
boundary; a throw in a component body, a `.with`, or a `.mapper` renders the
nearest `.error` in place and the rest of the page stays alive. The boundary
resets on navigation.
- **Unified beats variant.** For the active render variant, a plain `.loading` /
`.error` wins over the variant-specific setter, which wins over the built-in
default.
- **Plugins override.** A `.use`d plugin's `.loading` / `.error` joins the chain
at the `.use` position and overrides what came before.
- **SSR hides loading** for server-available data only; a `.clientLoader`'s
loading still renders in the SSR HTML.
- **Prefetch hides loading** on navigation when the data is fetched before the
route commits (`serverAndClientQuery`, `pageDehydratedStateAndClientQuery`,
the on-hover variants); `'none'` / `false` shows it.
- **Errors with `.redirect`** render a redirect instead of the error component.
- **The error sets the HTTP status** (`error.status`) during SSR; off-server
it's a no-op.
- **Strip category — server-ssr-and-client.** `.loading` / `.error` and all
their per-variant forms ship to the client always and to the server only when
SSR is on (stripped under `ssr: false` or after `.clientOnly()`).
## `.error` IS a React error boundary (and a data error path)
`.error` handles both error phases. As the **data** path, it fires when a loader
or query resolves to an error state, or when a [`.with`](with) returns an error
— Point0 surfaces that as the point's error status and renders the nearest
`.error` in its place. As a **render boundary**: every mountable render (page,
layout, component, provider) is wrapped in a React error boundary, so a throw
from a component body, a [`.with`](with), or a [`.mapper`](mapper) is caught and
renders the nearest `.error` above it while the rest of the page stays alive.
During SSR the throw is contained the same way and carries the same effects into
the response a returned error does — its HTTP `status` applies, and a thrown
redirect becomes the real HTTP redirect; the HTML itself ships the mountable's
loading fallback and `.error` renders after hydration. The engine's SPA fallback
(serving the bare `index.html`) remains only for errors outside every boundary:
a broken root, a failing wrapper.
Details of the boundary behavior:
- A thrown **redirect** passes through the boundary and navigates as usual —
during SSR it produces the real HTTP redirect.
- The boundary **resets on navigation** — moving to another location drops the
caught error and re-renders. It does not reset on a query refetch: query
errors normally travel through query state (never thrown), so if your error UI
refetches without navigating, render a fresh state from the data phase instead
of relying on the boundary to clear.
- Prefer signaling errors from the **data phase** anyway — return an error from
a [`.with`](with): it's typed, and it puts `.error` into the SSR HTML itself
(a throw ships the loading fallback there). The render boundary is the safety
net, not the API.
---
# Point0 — .with
Source: https://1gr14.dev/point0/v0/with.md
`.with` is the point builder's swiss-army knife: inject a query, drive the
loading and error states by hand, pass computed props down the chain, or wrap
the render. It's available on every [mountable](mountable) — [page](page),
[layout](layout), [component](component), [provider](provider) — and on
[root](root), [base](base), and [plugin](plugin), but **not** on a
[mutation](mutation) or a standalone [query](query).
You've already seen the simple shape: inject a query, map the route into its
input, and read the loaded data in `.page`:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.page(({ data: { idea } }) =>
{idea.title}
)
```
The page renders only after the injected query loads — the component gets
`data`, never a loading branch. You can stick more than one query into a single
point; they run in parallel by default, you see the loading state until all of
them have loaded, and on error you see the first one that comes up. To bring
several queries' data into one shape before the render, reach for
[`.mapper`](mapper).
**Strip category: server-ssr-and-client.** Cut from the **server** bundle when
`ssr: false` (or after a [`.clientOnly()`](ssr) 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. (It runs at
render: in the browser always, on the server only under SSR.) The full mechanics
are in [When the `.with` body is stripped](#when-the-with-body-is-stripped)
below.
The rest of this page covers the other shapes: feeding one query's data into
another's input, deriving a query's input from an external hook, and reacting to
each query's status before it loads.
## `.with` as a query injector
Injecting a single query into a page — the same shape as above, now also reading
`queries`:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.page(
({
// here we have the loaded data from the query
data: { idea },
// here we have the loaded queries themselves
queries: [ideaViewQueryResult],
}) =>
{idea.title}
,
)
```
The point you inject doesn't have to be a standalone [`.query()`](query). Any
point that carries a query works — including a [component](component) (or a
[layout](layout)/[page](page)). It's read as its **query**: the data is injected
and flows into `data`, while its own UI is **not** rendered. So a component
built for one screen can be reused elsewhere purely for its data, and the input
is required exactly when that point's input is — the same rule as a query:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
// IdeaStats is a component with its own loader and UI, but here we inject it
// for its query alone: its data lands in `data`, its UI is never rendered
.with(IdeaStats, ({ params }) => ({ id: params.id }))
.page(({ data: { fans } }) =>
{fans} fans
)
```
The same thing can be written by passing a function — each `.with` acts as a
component wrapper around the component below it, so you can call hooks inside:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(({ params }) => {
// it's specifically by returning the result of useQuery()
// that we get data and queries in the following methods
return ideaViewQuery.useQuery({ id: params.id })
})
// from here on nothing changes
.page(({ data: { idea }, queries: [ideaViewQueryResult] }) => (
{idea.title}
))
```
To inject several queries from one function, return an array of them:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(({ params }) => {
// you can return an array of queries straight from a single .with()
return [ideaViewQuery.useQuery({ id: params.id }), ideaBestQuery.useQuery()]
})
.page(({ queries: [ideaViewQueryResult, ideaBestQueryResult] }) => (
<>
{ideaViewQueryResult.data.idea.title}
{ideaBestQueryResult.data.bestIdea.title}
>
))
```
The first way to use one query's data as another's input:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(({ params }) => {
const ideaViewQueryResult = ideaViewQuery.useQuery({ id: params.id })
// say ideaViewQueryResult.data.idea.similarIds holds the ids of similar ideas
const ideaListQueryResult = ideaListQuery.useQuery(
// the first argument is the input as usual,
// but this isn't very convenient — the input probably expects ids strictly as
// an array, not "array or undefined", so we'd have to tack on `as never`,
// which is awful; that's why another way exists, covered below
{ ids: ideaViewQueryResult.data?.idea.similarIds } as never,
// in the second argument we can pass the options of the classic useQuery,
// and we simply say not to enable this query until ideaViewQueryResult loaded
{ enabled: !!ideaViewQueryResult.data },
)
return [ideaViewQueryResult, ideaListQueryResult]
})
// a disabled query still has a real status of pending,
// so we won't get here until ideaViewQueryResult has loaded
.page(({ queries: [ideaViewQueryResult, ideaListQueryResult] }) => (
{ideaViewQueryResult.data.idea.title}
Similar ideas
{ideaListQueryResult.data.ideas.map((idea) => (
{idea.title}
))}
))
```
The `enabled` trick is clunky; the `resolve` sections below cover a way I like
more.
## `.with` as a state manager
Inside a `.with` function you can short-circuit the chain. Returning a React
element renders it; returning `'loading'` or an `Error` renders the loading or
error component declared up the chain:
```tsx
export const strangePage = root.lets
.page('/strange')
.with(({ LoadingComponent, ErrorComponent }) => {
// LoadingComponent holds what we declared earlier in .loading()
// ErrorComponent holds what we declared earlier in .error()
const [isLoading, setIsLoading] = useState(true)
const [error] = useState(() =>
Math.random() > 0.5 ? new Error('Oh, how wrong I was') : undefined,
)
useEffect(() => {
setTimeout(() => setIsLoading(false), 1000)
}, [])
if (isLoading) return
if (error) return
// return undefined — or just nothing — to render the following methods
})
// we won't reach the render until all .with() calls have resolved
.page(() => (
I loaded something, I don't know what, but I pulled it off
))
```
There's a shorter notation I like more — the reserved word `'loading'` is the
same as ``, and any `instanceof Error` is the same as
``:
```tsx
export const strangePage = root.lets
.page('/strange')
.with(() => {
const [isLoading, setIsLoading] = useState(true)
const [error] = useState(() =>
Math.random() > 0.5 ? new Error('Oh, how wrong I was') : undefined,
)
useEffect(() => {
setTimeout(() => setIsLoading(false), 1000)
}, [])
// same as return
if (isLoading) return 'loading'
// same as return
if (error) return error
// return nothing to render the following methods
})
.page(() => (
I loaded something, I don't know what, but I pulled it off
))
```
An error returned this way can carry an HTTP status, honored during the SSR
render — `return new ErrorPoint0('Failed', { status: 500 })`. Returning any
string other than `'loading'` is a type error, and so is an array of anything
other than query results — a frequent mistake is returning a query's data array
instead of the query itself.
## `.with` as a props injector
To pass a computed value from one `.with` hook to another — or to the page
itself — return a plain object: it becomes props, merged into `props` for every
later method. The merge is a shallow spread (`{ ...prev, ...next }`), so a later
`.with` can overwrite an earlier key — even change its type:
```tsx
export const strangePage = root.lets
.page('/')
.with(() => {
// anything that isn't a react element, an Error, the word 'loading',
// or a query result, is treated as props for the following methods
return { x: 1, y: 2 }
})
.with(({ props: { x, y } }) => {
return {
a: x * 10,
b: y * 100,
// props can be overwritten even with a different type, because in essence
// this is nextProps = { ...prevProps, ...newProps }
x: 'I decided I will be a string',
}
})
.page(({ props: { a, b, x, y } }) => (
{a} {b} {x} {y}
))
```
## `.with` as a wrapper
The function's argument also carries `children` — the rendered rest of the
chain. Return them wrapped and you get a boundary around everything downstream:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(({ children }) => {
return
{children}
})
.page(() =>
Hello!
)
```
This differs from returning a _self-contained_ element: an element that doesn't
include `children` **blocks** the chain — the rest never renders, and any
queries it would have injected never run. Wrap with `children` to keep the
chain; return a standalone element to stop it.
## `.with` as an idea
Combining these shapes gives another way to feed one query's data into another's
input — the pattern I settled on while building a real project on the framework:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.with(({ queries: [ideaViewQueryResult] }) => {
// unlike .page(), every .with() receives queries in an indeterminate state —
// here it could be an error, loading, or the loaded result, and we handle it
// covered in ".with as a state manager"
if (ideaViewQueryResult.isError) return ideaViewQueryResult.error
if (ideaViewQueryResult.isLoading) return 'loading'
// covered in ".with as a props injector"
return { similarIds: ideaViewQueryResult.data.idea.similarIds }
})
// we won't reach this .with() while the previous one is intercepting control
.with(ideaListQuery, ({ props: { similarIds } }) => ({ ids: similarIds }))
.page(({ queries: [ideaViewQueryResult, ideaListQueryResult] }) => (
{ideaViewQueryResult.data.idea.title}
Similar ideas
{ideaListQueryResult.data.ideas.map((idea) => (
{idea.title}
))}
))
```
All this manual handling of query states is verbose, so there's a `resolve`
helper. It takes a query result; while the query is loading or erroring it
returns the loading or error state, and on success it maps the data to props
passed further down. The same example, shorter:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.with(({ queries: [ideaViewQueryResult], resolve }) => {
// the same as above, but short
return resolve(ideaViewQueryResult, ({ data }) => ({
similarIds: data.idea.similarIds,
}))
})
.with(ideaListQuery, ({ props: { similarIds } }) => ({ ids: similarIds }))
.page(({ queries: [ideaViewQueryResult, ideaListQueryResult] }) => (
{ideaViewQueryResult.data.idea.title}
{/* ... */}
))
```
Resolving before continuing is needed often enough that an even shorter notation
bakes `resolve` right into the query injection — as the **fourth** argument:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(
ideaViewQuery,
({ params }) => ({ id: params.id }),
undefined, // useQuery options could go here, not needed now
({ data }) => ({ similarIds: data.idea.similarIds }),
)
.with(ideaListQuery, ({ props: { similarIds } }) => ({ ids: similarIds }))
.page(/* ... */)
```
`resolve` also helps when you want to call a query but **don't** want it to land
in the `queries` array or in `data`. That's handy for the current user — you
want it in `props`, while `data` stays free for the page's own data:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(({ resolve }) => {
// since we don't return the useQuery result from this .with(),
// it won't land in queries or data — only the props from resolve() survive
return resolve(getMeQuery.useQuery(), ({ data }) => ({ me: data.me }))
})
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.page(({ data: { idea }, props: { me } }) => (
{idea.title}
Hello, {me.name}!
))
```
## The argument that `.with(fn)` receives
The function form receives one object. Here's everything on it:
| Key | What |
| --------------------------------------- | ----------------------------------------------------------------------------------- |
| `status` / `loading` / `error` / `data` | the accumulated state so far |
| `props` | props contributed by earlier `.with` calls |
| `queries` | injected queries — **in an indeterminate state** here (may be loading or errored) |
| `params` / `search` / `input` | present when the matching schema exists |
| `location` | present on pages and layouts |
| `resolve` | the [resolve helper](#resolve-forms) |
| `children` | the rendered remainder of the chain |
| `LoadingComponent` / `ErrorComponent` | the components you set with [`.loading`](loading-error) / [`.error`](loading-error) |
There is **no `ctx`** here — `ctx` is server-only and lives in `.ctx` and
loaders (`.ctx` is cut from the client bundle, body and imports removed — the
opposite strip category from `.with`). If you need a ctx value at render, merge
it into props in a [plugin](plugin) first.
### What you can return
| Return | Effect |
| ------------------------------------------------ | -------------------------------------------------- |
| a query result, or an array of them | appended to `queries` (`data` = the first) |
| `'loading'` | render the loading component |
| an `Error` | render the error component (status honored in SSR) |
| a `RedirectTask`, or an `Error` with `.redirect` | redirect |
| a React element (with `children`) | wrap the rest of the chain |
| a React element (standalone) | render it and **stop** the chain |
| a plain object | shallow-merge into `props` |
| `undefined` / nothing | proceed to the next method |
| a non-`'loading'` string, or a non-query array | **type error** — those shapes are reserved |
Throwing works too. Every mountable render is wrapped in an error boundary, so a
`throw` inside a `.with` callback (or anywhere else in the render) is caught and
renders the nearest `.error` declared above it in the chain — the rest of the
page stays alive. A thrown error carries the same SSR effects as a returned one:
its HTTP `status` reaches the response, and a thrown redirect becomes the real
HTTP redirect. Returning is still nicer: it's typed, it short-circuits the chain
as data (no boundary involved), and it puts `.error()` into the server-rendered
HTML itself — a throw ships the loading fallback there and the client renders
`.error()` after hydration.
### `resolve` forms
```tsx
resolve(query) // wait, contribute nothing to props
resolve(query, true) // wait, then spread the query's data into props
resolve(query, ({ data }) => props) // wait, then map success to props
```
The key difference from injecting a query with `.with(query, …)`: a query you
`resolve` does **not** land in `queries` or `data` — only the props you derive
from it survive. The fourth-argument form (above) is the opposite: it injects
the query **and** maps extra props alongside it.
## When the `.with` body is stripped
`.with` is **server-ssr-and-client** render code, so the rule is symmetric to
other render methods (`.page`, `.layout`, `.component`, `.provider`, `.loading`,
`.error`, `.wrapper`, `.mapper`, `.head`):
- From the **server** build, the `.with` body — and the imports it pulls in —
are **cut** when `ssr: false`, or after a [`.clientOnly()`](ssr) earlier in
the chain (which makes the rest of the point client-only). It stays in the
server build only when SSR is on, so the point can render server-side.
- From the **client** build, nothing is cut — `.with` always ships and runs at
render in the browser. (Server-only methods around it, `.ctx`, server
`.loader`, …, are the ones cut from the client bundle; `.with` itself is not.)
That's also why `.with` runs "at render, on the client and — under SSR — on the
server too": under SSR the first paint is server-rendered, and after that, page
navigations are client-side (SPA-style), running `.with` in the browser.
## Security: gate access in `.with`, not `.ctx`
A `.ctx` gate runs **only when the point has a loader**, so a loader-less page
wouldn't run it at all. `.with`, by contrast, runs at render on every point. For
an authorization gate that always fires, return an `Error` from `.with`:
```tsx
export const authorizedPlugin = Point0.lets
.plugin()
.with(({ props: { me } }) => {
if (!me) {
return new ErrorPoint0('Only for authorized users', {
code: 'UNAUTHORIZED',
})
}
return { me }
})
.plugin()
```
`ErrorPoint0` is the framework's default error class; you can swap it for your
own class of the same-or-wider shape via `.errorClass(...)` — see
[error handling](error-handling). A common production shape combines both gates:
a `.ctx` that resolves the user for server loaders and a `.with` that resolves
it for the render — see [Plugin](plugin).
An `Error` you return from `.with` is normalized through that error class and
rendered by the error component. A thrown one lands in the same `.error` — the
mountable's error boundary catches it — and carries the same HTTP `status` into
the SSR response; returning stays the better gate simply because it is typed.
See
[How a thrown error reaches the error component](error-handling#how-a-thrown-error-reaches-the-error-component).
---
# Point0 — Mapper
Source: https://1gr14.dev/point0/v0/mapper.md
`.mapper` turns the raw loader and query results into the final `data` your
render reads. By default `data` is just the first query's data; once you add a
`.mapper`, `data` is whatever the mapper returns. Reach for it when one point
pulls several queries together, or when a query's shape (paginated, nested)
isn't the shape your component wants.
```tsx
export const ideaListPage = generalLayout.lets
.page('/ideas')
.search(
z.object({
page: z.coerce.number().default(0),
limit: z.coerce.number().default(2),
}),
)
.loader(async ({ search: { page, limit } }) => {
const ideasCount = await prisma.idea.count()
const ideas = await prisma.idea.findMany({
take: limit,
skip: page * limit,
})
const nextCursor = ideasCount > (page + 1) * limit ? page + 1 : undefined
return { ideas, ideasCount, nextCursor }
})
.infiniteQuery({
getNextPageParam: (last) => last.nextCursor,
initialPageParam: 0,
pageParamFromInput: '?.page',
})
.mapper(({ data }) => ({
ideas: data.pages.flatMap((page) => page.ideas), // flatten the paginated shape
total: data.pages[0].ideasCount,
}))
.head(({ data: { total } }) => `${total} ideas`)
.page(({ data: { ideas, total } }) => (
))
```
An infinite query hands you `data.pages` — an array of page objects. The mapper
flattens that into `{ ideas, total }`, and from there `.head` and `.page` read
the clean shape, not the pagination machinery.
> **Stripping:** server-ssr-and-client — cut from the SERVER bundle when
> `ssr:false` (or after an earlier `.clientOnly()` in the chain): the mapper's
> body and the imports it uses are removed from the server build, where the
> framework substitutes an identity passthrough. Kept in the client build
> always, and in the server build only when SSR is on (it runs on the client
> always, and on the server only when SSR is on).
## What the mapper receives
The mapper is one synchronous function. It gets a single object and returns a
plain object — the new `data`:
```tsx
export const ideaListPage = root.lets
.page('/ideas')
.with(ideasQuery)
.mapper(({ data, queries, props, location }) => {
return {/* the new data */}
})
.page(({ data }) => )
```
| Key | What |
| ---------- | ------------------------------------------------------------------------------------------------------- |
| `data` | the current `data` — the first query's data, or the previous mapper's output |
| `queries` | every query, in `.with` order, **all loaded** (success state) |
| `props` | props contributed by [`.with`](with) wrappers and plugins |
| `location` | present whenever the point has a route location (pages and layouts); components and providers have none |
There is no separate `params`, `search`, or `input` key on the mapper argument
at runtime — reach for `location.params` / `location.search` instead, as the
self-query example below does.
The mapper runs **only on success** — by the time it runs, every query has
loaded, so `queries[n].data` is always there. There is no `loading`, `error`,
`status`, or `ctx` key here: a mapper transforms data that is already resolved,
it doesn't decide loading or error (that's [`.with`](with) and
[Loading & error](loading-error)).
A mapper that throws IS caught: the mapper runs inside the render (in a
`useMemo`), and every mountable render is wrapped in an error boundary — the
throw renders the nearest [`.error`](loading-error) above it while the rest of
the page stays alive, and during SSR its HTTP `status` (or a thrown redirect)
still reaches the response. Still, prefer keeping the mapper total: signal
errors from the data phase by returning one from a [`.with`](with) — that path
is typed and puts `.error` into the SSR HTML itself; the render boundary is the
safety net.
## `data` shadows `queries[0].data`
Without a mapper, `data` is a shorthand for the first query's data:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.page(({ data, queries }) => {
// data === queries[0].data
return
})
```
A mapper replaces it. After `.mapper`, `data` is the mapper's return value,
while `queries` still hold the raw per-query results:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) })) // queries[0]
.with(commentsQuery, ({ params }) => ({ ideaId: Number(params.id) })) // queries[1]
.mapper(({ queries: [idea, comments] }) => ({
idea: idea.data.idea,
comments: comments.data.comments,
}))
.page(({ data }) => )
// data is the mapper's shape; queries[0]/queries[1] still hold the raw results
```
This is why the mapper exists for **multiple `.with` queries**: each query lands
at its own index, but a render usually wants one merged object, not an array of
results to thread through by hand.
With no loader and no queries, `data` is the empty object `{}` — the mapper
still runs and its return becomes `data`.
## Combine queries with props
The mapper sees `props` too, so you can fold a value from an upstream
[`.with`](with) (or a [plugin](plugin)) into the same object as your query data:
```tsx
export const ideaNewsPage = ideaLayout.lets
.page('/news')
.loader(async ({ params, search }) => {
/* ... */ return { newsPosts, newsCount, nextCursor }
})
.infiniteQuery({
getNextPageParam: (last) => last.nextCursor,
initialPageParam: 0,
pageParamFromInput: '?.page',
})
.with(() => ({ idea: ideaLayout.useValue('idea') })) // idea comes from the layout
.mapper(({ data, props }) => ({
newsPosts: data.pages.flatMap((page) => page.newsPosts),
total: data.pages[0]?.newsCount ?? 0, // first page may be empty — guard it
idea: props.idea, // merge the prop in
}))
.head(({ data: { total, idea } }) => `${total} news for idea "${idea.title}"`)
.page(({ data: { newsPosts, total, idea } }) => (
))
```
The render now reads one object — query data and the layout's `idea` side by
side — and so does `.head`.
## Where `data` comes from (the self query)
Calling `.mapper` (like `.with` and `.head`) finalizes a page's or component's
own loader into its **self query**. After a `.loader`, that loader's output
becomes `queries[0]` and is what `data` shadows by default:
```tsx
export const xyzPage = root.lets
.page('/xyz/:y')
.loader(() => ({ z: 3 })) // becomes the self query → queries[0]
.with(query, ({ location }) => ({ y: +location.params.y })) // → queries[1]
.mapper(({ data, queries, location }) => ({
x: queries[1].data.x, // the injected query
y: location.params.y, // route param
z: data.z, // the page's own loader output, via shadowed data
}))
.page(({ data: { x, y, z } }) => )
```
So a page with a loader and extra `.with` queries reads its own data through
`data` (the self query) and the injected ones through `queries[1…]`. See
[Page](page) for the self-query model in full.
## Which points have `.mapper`
`.mapper` is a method on the [**mountables**](mountable) — the points that
produce UI:
- [page](page), [layout](layout), [component](component), and
[provider](provider).
It is **not** on a [query](query), [infinite-query](infinite-query),
[mutation](mutation), or [action](action) — those return data, they don't map it
for a render. It's also off on [root](root) and [plugin](plugin).
On a [provider](provider), the final `.provider(mapperFn)` argument is the same
as a trailing `.mapper` — a shorthand for the common case:
```tsx
export const meProvider = Point0.lets
.provider()
.with(({ resolve }) =>
resolve(getMeQuery.useQuery(), ({ data }) => ({ me: data.me })),
)
.provider(({ data: { me }, props: { x } }) => ({ me, x })) // === a trailing .mapper
```
The trailing `.provider(mapperFn)` is also server-ssr-and-client, stripped the
same way as a `.mapper` (see the strip note at the top).
## Edge cases and gotchas
- **Return an object.** The mapper's return is the new `data`, which must be a
plain object — not an array, primitive, or `Response`.
- **Synchronous only.** No `async`/`await` — the mapper runs inside `useMemo` at
render. It transforms already-loaded data; it never fetches.
- **Success only.** The mapper doesn't run during loading or on error, so its
inputs are always resolved.
- **Keep it pure.** The result is memoized on its inputs (`location`, `props`,
the previous data, and each `queries[n].data`). Closing over a value outside
those and the memo can go stale — derive everything from the argument.
- **Guard empty pages.** With an infinite query, `data.pages[0]` can be missing
on an empty first page — read it as `data.pages[0]?.field ?? fallback`, as the
news example does.
- **`.mapper` chains.** Declare it more than once and each mapper feeds the
previous one's output as its `data`, so the last `.mapper` produces the final
shape. `queries` stays the raw per-query results throughout — only `data`
advances from one mapper to the next.
## Security
The mapper is server-ssr-and-client (see the strip note at the top): it isn't
guaranteed to run on the server, and it always ships to the browser. Don't put a
server-only secret or gate in a mapper — gate access in a [`.with`](with)
wrapper instead.
---
# Point0 — Transformer
Source: https://1gr14.dev/point0/v0/transformer.md
`.transformer` sets how Point0 serializes data crossing the wire — query inputs
and outputs, request bodies, the SSR dehydrated state. By default that's plain
JSON, which can't carry a `Date`, `Map`, `Set`, or `BigInt`. Pass `superjson`
and those types survive the round trip, on both ends and inside the query key.
```tsx
import superjson from 'superjson'
export const root = Point0.lets
.root()
.serverUrl(sharedEnv.SERVER_URL)
.clientUrl(sharedEnv.CLIENT_URL)
.transformer(superjson) // Date / Map / Set / BigInt now round-trip
.schemaHelper(zodSchemaHelper())
.errorClass(AppError) // optional — your own error class; default is ErrorPoint0
.root()
```
This is the canonical production wiring: one transformer on the root, applied to
every point beneath it.
Stripping: `.transformer` is **server-and-client** — it's a root setter, kept on
both bundles and never stripped, because serialization has to run identically on
the server and in the browser.
## Root only
`.transformer` is a **root method** — you call it on `Point0.lets.root()…` and
nowhere else. It's not available on a [page](page), [query](query),
[mutation](mutation), [action](action), or [layout](layout); writing it there is
a type error. One root, one transformer, shared by every point in that root's
[scope](query#mode-tags-scope).
The one opt-out is a channel option, not a second transformer knob: a
[channel](socket) declared with the `preventTransformer: true` channel option
serializes its whole socket subtree (spaces and handlers included) as plain JSON
— for raw external consumers that must not learn the transformer's envelope. See
[Channel options](socket#reference).
`superjson` is not a Point0 dependency — install it yourself:
```sh
bun add superjson
```
`.transformer` takes any `{ serialize, deserialize }` pair (see
[Reference](#reference)); `superjson` satisfies that out of the box.
## Why you'd set it
Without a transformer, the wire is plain JSON, and the type lies. A loader can
return a `Date`, the type says `Date`, but what arrives on the client is a
string:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.loader(() => ({ createdAt: new Date('2026-01-01') }))
.page(({ data }) => {
// type says data.createdAt: Date
// runtime (no transformer): data.createdAt === '2026-01-01T00:00:00.000Z' — a string
return
})
```
This is the most common surprise. `JSON.stringify` turns a `Date` into an ISO
string, drops a `Map`/`Set` to `{}`, and throws on a `BigInt`. The data degrades
on the way out and never reconstructs on the way in.
Set `.transformer(superjson)` on the root and the same loader gives the page a
real `Date`:
```tsx
export const ideaPage = root.lets
.page('/ideas/:id')
.loader(() => ({ createdAt: new Date('2026-01-01') }))
.page(({ data }) => {
// runtime (with superjson): data.createdAt instanceof Date === true
return
})
```
The same holds for request bodies. A `BigInt` in a mutation or [action](action)
body survives both directions only when the root has a transformer:
```tsx
// action body schema: z.object({ amount: z.bigint() })
await transferAction.fetch({ body: { amount: 100n } })
// server receives amount === 100n (with superjson) — not a string, not a throw
```
On the server, validation runs **after** the transformer deserializes, so a
`z.bigint()` schema sees a real `bigint` and accepts it. With plain JSON the
same schema would reject the value.
Point0 hands serialization to your transformer, so any type `superjson` supports
— `Date`, `BigInt`, `Map`, `Set`, and more — round-trips. See
[superjson's supported types](https://github.com/flightcontrolhq/superjson#parse).
## The default transformer
When you never call `.transformer`, Point0 uses a blank transformer:
`serialize`/`deserialize` are identity (pass-through), and the wire format is
plain JSON with **stable key order** (it stringifies via
`safe-stable-stringify`, a Point0 runtime dependency):
```tsx
// default transformer, serializing { date, string }:
'{"date":"2017-01-01T00:00:00.000Z","string":"value"}'
// keys sorted; the Date is already a string — its type is gone
```
`superjson` instead wraps the value in a `{ json, meta }` envelope, where `meta`
records which fields need reconstructing:
```tsx
// superjson, same input:
'{"json":{"date":"2017-01-01T00:00:00.000Z","string":"value"},' +
'"meta":{"v":1,"values":{"date":["Date"]}}}'
// meta.values.date = ["Date"] tells deserialize to rebuild a Date
```
That `meta` is what carries the type across the wire — and it's why the
serialized form is larger than plain JSON.
## How it bakes into the query key
The transformer is part of the cache key, not only the wire. A query key's
`input` field is the transformer's stringified input:
```tsx
ideaQuery.getQueryKey({ id: 123 })
// [
// 'point0',
// { scope, type, name, mode, finiteness, tags, output,
// input: '{"id":123}' }, // = transformer.stringify(routedInput)
// ]
```
`input` is `safe-stable-stringify(transformer.serialize(routedInput))`. With the
default transformer that's plain stable JSON. With `superjson`, the superjson
serialization (its `{ json, meta }` shape, then stable-stringified) is what keys
the cache — so an `input` containing a `Date` produces a distinct,
reconstructable key instead of a lossy string.
Stable stringification means key order doesn't matter: `{ a, b }` and `{ b, a }`
hit the same cache entry. See [Query](query#the-query-key) for the full key
shape; for what counts as `routedInput` (page/layout search filtering, action
sections), see [Validation](validation).
Because the transformer is in the key, **changing it changes cache keys
app-wide**. If you add or swap `.transformer` in an existing app, keys built
with the old transformer won't match the new ones, and Point0 does nothing to
reconcile them. In-flight keys sort themselves out on the next fetch; persisted
keys are your concern — if you persist the query cache through TanStack's
`persistQueryClient`, change its `buster` string when you change the transformer
so the stale cache is discarded.
## Not the same as the store transformers
Two other surfaces take a transformer with the same `{ serialize, deserialize }`
shape, but they're **separate knobs** from the root `.transformer`:
- [`CookieStore`](cookie-store) accepts a per-store `transformer` option.
- [`SsrStore`](ssr-store) / `SuperStore` expose `setTransformer(transformer)`.
Both default to the blank transformer independently. Setting `.transformer` on
the root does not configure them, and vice versa.
## Reference
### Signature
```tsx
.transformer(transformer: DataTransformer): this // root only, chainable
```
`DataTransformer` is the minimal pair both `serialize` and `deserialize` round
through:
```tsx
type DataTransformer = {
serialize: (data: any) => any
deserialize: (data: any) => any
}
```
Any object of that shape works — `superjson` is the recommended default, but a
hand-rolled transformer is valid too. Point0 wraps whatever you pass to also
derive a stable `stringify`/`parse` pair for query keys and the wire
(`stringify` = `safe-stable-stringify(serialize(data))`, `parse` =
`deserialize(JSON.parse(str))`).
### Where it runs
| Boundary | Direction | What it does |
| --------------------------------- | --------------- | -------------------------------- |
| query / mutation / action request | client → server | `serialize` the body |
| request parse | server reads | `deserialize` the body |
| query / loader output | server → client | `stringify` the result |
| page dehydrated state (SSR) | server → client | `stringify` the dehydrated state |
| response read | client reads | `deserialize` the JSON |
| query key `input` | both | `stringify` the routed input |
### The `x-point0-transform` header
The HTTP transform is **opt-in per request**, gated on the
`x-point0-transform: true` request header. The generated client always sends it,
so every request the app makes is transformed on both ends: the server
`deserialize`s the input and `stringify`s the output through the transformer. A
request without the header — a hand-driven `curl`, an external HTTP client —
gets **raw JSON** semantics instead: the server reads the input as plain JSON
(no `deserialize`) and returns the output as plain JSON (no transformer
`stringify`), so a foreign caller sees ordinary JSON and never has to speak
superjson.
The [socket](socket) wire is not gated — every frame's payload always goes
through the transformer, in both directions. Which transformer that is belongs
to the channel: the root's by inheritance, or plain JSON when the channel
declared `preventTransformer: true` ([above](#root-only)).
### Edge cases
- **`serialize` returning `undefined` is an error.** Every value that must cross
a boundary — a query or mutation key, a request body, a socket frame, a room
or identity, the dehydrated state, a streamed query/RSC push — is serialized
through a wrapper that refuses `undefined`. If a custom transformer answers
`undefined` for one of them, Point0 throws `POINT0_SERIALIZE_FAILED`
(`Transformer returned undefined serializing a value on point …`, with the
point id in `meta`) rather than merging distinct cache entries under one key,
writing the literal `"undefined"` into a frame, or sending an empty body.
Match it with `POINT0_ERROR_CODES_MAP.SERIALIZE_FAILED`.
- **An absent input is the `{}` input.** Point inputs are always objects, so
everything that serializes one coerces `undefined` to `{}` first — the same
bytes a mutation run with no input and one run with `{}` produce, on both
sides of every cache comparison.
- **Calling `.transformer` twice is last-wins.** Each call overwrites the root's
transformer with the new one — they don't compose — so the last `.transformer`
in the chain is the one that runs.
---
# Point0 — Stage Methods
Source: https://1gr14.dev/point0/v0/stage-methods.md
These are the methods you use to **declare** a point before it is finalized. A
point is a chain: it opens with `.lets`, runs through a sequence of builder
methods, and closes with the method that matches its type (`.page()`,
`.query()`, `.mutation()`, …). Everything **between** the opening and the
closing call is a **stage-method** — the code types call this a `StagePoint`.
After the closing call you hold a `ReadyPoint`, with a different, smaller
surface (`.useQuery`, `.route`, `.id`, …) covered on each point's own page.
This page is the reference for stage-methods (and the **closing** methods that
finalize the chain — `.page`/`.layout`/`.component`/`.provider`/`.query`/
`.infiniteQuery`/`.mutation`/`.action`/`.subscription`/`.root`/`.base`/`.plugin`).
Most have their own page — this one links there and gives the one-line gist. A
few small setters live only here and are described in full. Each entry says
**which point types accept it** and **what the compiler does with it** — which
of the four strip categories below it falls in, and how `ssr` and
`.clientOnly()` change that.
```tsx
export const ideaPage = root.lets
.page('/ideas/:id') // .lets — opens the chain
.use(authorizedPlugin) // ↑ stage-methods
.ctx(({ request }) => ({ me: getMe(request) })) //
.loader(({ params }) => ({ idea: getIdea(params.id) })) //
.head(({ data: { idea } }) => idea.title) //
.page(({ data: { idea } }) =>
{idea.title}
) // .page — closes the chain
```
## How the strip notes read
Point0 server-renders the first page load (when SSR is enabled) and behaves like
an SPA only for client-side navigation after that. So most points exist in
**two** bundles at once — a server build and a browser build — and the compiler
decides, per method, what goes where. Stripping removes the call's body **and
prunes the imports that only that code used**, so the dependency never lands in
that bundle; the chained call itself stays (so the types still resolve). Every
method falls into one of **four** categories:
- **Server-only** — **cut from the client bundle**: its body and the imports it
uses are removed, so it never ships to the browser. Safe for secrets, DB
access, request headers (it runs only on the server).
- **Client-only** — **cut from the server bundle**: body and its imports
removed, so it never bloats the server build, regardless of SSR (it runs only
in the browser).
- **Server-and-client** — **not cut from either bundle**: kept in both
(isomorphic), nothing pruned. Config and closers that carry no side-specific
code.
- **Server-SSR-and-client** — **cut from the server bundle when `ssr: false`**
(or after an earlier `.clientOnly()` 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. This is the render-side family.
`.clientOnly()` is the switch: it sets the rest of the chain to behave as if
`ssr: false`, so every **server-SSR-and-client** method after it
(`.page`/`.layout`/`.with`/`.head`/`.loading`/`.error`/…) is cut from the
**server** build and runs in the browser only. A point built under `ssr: false`
behaves the same way from the start.
> The short `.lets.page('/x')` form, the variable-name rule, and why some
> closing methods take no argument are all on the [points](points) page — not
> repeated here.
## Setup methods (before the loader)
These shape the point's data and request. They must come **before** the single
`.loader()`; once the loader is declared, the setup is locked.
### .ctx
Per-request server values, merged and handed to every `.loader()` and later
`.ctx()` that follows. **Server-only** — cut from the client bundle. It only
runs when the point actually has a loader — otherwise there's no request. On
**every point type**. Full page: [ctx](ctx).
### .loader / .clientLoader
The one callback that produces the point's data. `.loader` is **server-only** —
cut from the client bundle (it runs on the server); `.clientLoader` is
**client-only** — cut from the server bundle (it runs in the browser). A
mountable (page/layout/component/provider) that declares a loader **also becomes
a query** — it gains `.useQuery`, `.fetchQuery`, and the rest. On a
[subscription](subscription) the loader is an async **generator** — each `yield`
is one streamed value. Available on **every point type** except the structural
ones — and except the channel and the space, whose callbacks are their own
methods: `.connector` (the channel's connect callback, returning the connection
identity) and `.joiner` (the space's join callback, returning the rooms a client
enters — see [channel & handlers](socket)). Full page: [loader](loader).
### .input / .clientInput / .sharedInput
The validation schema for a point's input. `.input` is **server-only** — cut
from the client bundle (it validates on the server); `.clientInput` is
**client-only** — cut from the server bundle (it validates in the browser);
`.sharedInput` is **server-and-client** — not cut from either bundle (it runs on
both). On **query, infinite-query, mutation, component, provider** (and the
chain heads — root, base, plugin); a [subscription](subscription) takes `.input`
too — its stream is server-generated, so the server schema is the one that
matters. Full page: [validation](validation).
### .params / .search / .body
Route-shaped input schemas. `.params` and `.search` validate the URL params and
search string of a routed point; `.body` is the request body of an
[action](action). On a non-action routed point `.params`/`.search` are
**server-and-client** — not cut from either bundle, because the client needs
them to build hrefs. Inside an **action** they are **server-only** — cut from
the client bundle (the action runs on the server) — as is `.body` everywhere. On
**page, layout, action** (`.params`/`.search`) and **action** (`.body`), plus
the chain heads. Full page: [validation](validation).
### .headers / .cookies
Schemas for request headers and cookies. **Server-only** — cut from the client
bundle. On **every point type**. Full page: [validation](validation).
## Render methods (mountables)
These describe how a mountable — [page](page), [layout](layout),
[component](component), [provider](provider) — turns its data into UI. All of
them are **server-SSR-and-client**: cut from the **server** bundle when
`ssr: false` (or after a `.clientOnly()`) — body and imports removed from the
server build. Kept in the client build always, and in the server build only when
SSR is on.
### .with
The builder's swiss-army knife: inject a query, intercept loading/error, pass
props, or wrap the render. On every **mountable** (and the chain heads). Full
page: [with](with).
### .mapper
Reshape the loaded queries/props into the `data` the render receives — the same
thing the final `.provider(fn)` does inline. On every **mountable** (and the
chain heads). Full page: [mapper](mapper).
### .wrapper
Wrap everything the rest of the chain renders in a component, without the
prop-threading `.with(({ children }) => …)` gives you. On every **mountable**
(and the chain heads).
```tsx
export const IdeaPage = root.lets
.page('/ideas/:id')
.wrapper(({ children }) => {children})
.page(() =>