Loader
- Category: Methods
A loader is the callback that produces a point's data. Each point has one
loader. The server .loader is 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.
The two loaders are strict opposites in stripping. 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 (it runs on the server). The
.clientLoader is client-only: cut from the server bundle — body and its
imports removed, regardless of SSR (it runs in the browser).
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 — 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. The rest of this
page shows the argument the loader receives, what it can return, and which
bundle each loader is cut from.
Which points have loaders
.loader and .clientLoader are available on every concrete point:
page, layout, component, provider,
query, infinite-query, mutation, and
action.
// 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, base, 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 or component without any loader is a pure mountable: it renders, issues no server request, and exposes no useful query.
What the loader receives
Cut from the client bundle — the server .loader's body and the imports it uses
are removed, so it never ships to the browser (it runs on the server).
The server loader gets one object. Every key below is always present except the parsed inputs, which appear only when you declared their schema:
.loader(async ({ input, params, search, body, ctx, request, set, data, points }) => {
// ...
})export const ideaNewsQuery = root.lets
.query()
.params(z.object({ id: z.string() }))
.search(z.object({ page: z.coerce.number().default(0) }))
.loader(async ({ params, search }) => {
// params and search are parsed and typed — present because their schemas exist
return { news: await loadNews(params.id, search.page) }
})
.query()input/params/search/body/headers/cookies— the parsed, validated input. Each key exists only when you declared the matching schema (.input,.params, …). See Validation.ctx— the server context built by.ctxand plugins up the chain.ctxis 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 nestedctx.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.original(the rawRequest),request.method,request.location, the lazy gettersrequest.headersandrequest.cookies(direct on the request), and the caller details underrequest.from(request.from.ips,request.from.ip,request.from.userAgent).set— a response helper:set.headers(name, value),set.cookies(name, value, options?),set.status(code). It also carriesset.inspect— a read-only snapshot of the headers, cookies, and status set so far (set.inspect.headers.x) — andset.apply(response), which returns a newResponsewith the accumulated headers, cookies, and status applied.points— the server points registry (points.findPoint,points.findEndpoint,points.collection).
.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
})What the loader returns
The return value becomes data. Five shapes are accepted:
.loader(async ({ input }) => {
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
})- Plain data — a plain object (
Record<string, unknown>). Arrays, strings, and other primitives are a type error; data must be an object. undefined/void— treated as empty data{}.[status, data]— a tuple appliesset.status(...)then uses the second element as the data (or aResponse); 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
RedirectTaskfrom theredirect(...)helper; Point0 turns it into an HTTP redirect. - An
Error— returning anErroris the same as throwing one: it short-circuits to the error state. A thrown error is converted through your error class, and itsstatusis applied to the response.
// 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 or an 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:
.loader(() => new Response('ok'))
// ✓ on a mutation / action
// ✗ on a page or query — "Output can not be type of \"Response\" for point of type \"page\""A query loader specifically must return plain object data, never a
Response.
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:
Cut from the server bundle — .clientLoader's body and its imports are removed
(it runs in the browser).
export const settingsQuery = root.lets
.query()
.clientLoader(() => ({ theme: localStorage.getItem('theme') ?? 'light' }))
.query()The client loader's argument has data, serverData (the server loader's
output, if any), 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 entry). A .loader makes
the query a real endpoint; a .clientLoader does not. See Query.
Data returned by a loader round-trips through the configured transformer, so non-JSON values survive the server-to-client hop:
.clientLoader(() => ({ date: new Date('2026-01-01') }))
// in the component, `data.date` is a real Date instance, not a stringOne 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.
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 loaderBoth 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 server .loader is server-only: cut from the client bundle — at compile
time the compiler empties the .loader callback for the client build and drops
the imports only it used (your Prisma client, secrets, server SDKs all vanish),
so none of it ever ships to the browser. The .clientLoader is the inverse —
client-only: cut from the server bundle, body and its imports removed; kept
on the client. (This is purely about which bundle the code is removed from —
when SSR is enabled, the first page load is still server-rendered; it's only
navigation between pages afterward that runs client-side, SPA-style.)
This means a server .loader is a safe place for secrets and database access —
none of it ever reaches the browser. 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 (or a plugin
that combines .ctx and .with), not in the loader alone:
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 for how to build one (with
error0 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 | always |
set | 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(...) helper) |
Error (thrown or returned) | error state; status applied; normalized via your error class |
| array / string / primitive | type error — data must be a plain object |
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, serverData, response } plus the parsed client inputs (input,
params, search) — no ctx, request, set, or points.
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️