Point0by 1gr14
  • Меню
    • GitHub
    • Блог
  • Introduction
    • Overview
    • Getting Started
    • Full Overview
    • Benchmarks
    • Points
  • Points
    • Page
    • Layout
    • Component
    • Provider
    • Mountable
    • Query
    • Infinite Query
    • Mutation
    • Action
    • Root
    • Base
    • Plugin
  • Methods
    • Validation
    • Loader
    • Context
    • Middleware
    • Loading & error
    • .with
    • Mapper
    • Transformer
    • Stage Methods
  • Core
    • Navigation
    • SSR
    • RSC
    • Request
    • Response
    • Error handling
    • Env
    • Head
    • MDX
    • Assets
    • File upload
    • OpenAPI
    • Query client
    • Events
    • Infer
  • Engine
    • Engine Config
    • Engine Runtime
    • CLI
    • Dev
    • Build
    • Compiler
    • Generator
    • Project MCP
    • Docs MCP
    • Importer
    • Public dir
    • Testing
    • Deploy
    • Bun or Vite
  • Extra
    • SsrStore
    • CookieStore
    • Basic Auth
    • CORS
    • Cache-Control
    • Compress
  • Examples
    • Basic
    • Vite
    • Better Auth
    • Capacitor
    • Expo
  • 1gr14
    • Главная
    • Start0
    • Поддержать
    • Обучение
    • Группа
    • Блог
    • Автор
  • Сообщество
    • Discord
    • Telegram
  • Опенсорс
    • Point0
    • Route0
    • Error0
    • Flat
  • Аккаунт
    • Войти
    • Регистрация
Point0by 1gr14
Создаю опенсорс во славу Господа Иисуса Христа ☦️
С любовью ко всем разработчикам на свете ❤️
Условия использованияПолитика конфиденциальностиСергей Дмитриев 2026 😎

Loader

  • Раздел: Methods

On this page

  1. Which points have loaders
  2. What the loader receives
  3. What the loader returns
  4. Returning a Response
  5. Client loaders
  6. One loader per point
  7. What runs where, and what stays secret
  8. Reference
  9. Server loader argument
  10. Return values

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).

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.

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

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 .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.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.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 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).
.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. Six 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
  return <Hello />                       // 6. a React element → element as data (RSC)
})
  • Plain data — a plain object (Record<string, unknown>). 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(...) 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, 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 page.
// 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\""

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:

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 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 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.

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 (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

KeyTypeWhen
inputparsed .input valuewhen .input is set
paramsparsed route paramswhen the route has params / .params
searchparsed query stringwhen .search is set
bodyparsed request bodywhen .body is set
headersparsed request headerswhen .headers is set
cookiesparsed request cookieswhen .cookies is set
ctxserver contextalways ({} if no .ctx/plugin)
exposed ctx keyswhatever .ctx(expose) exposedspread at top level alongside ctx
dataaccumulated data up the chainalways (clone)
requestthe requestalways
setresponse helper (headers/cookies/status)always
pointsserver points registryalways

Return values

ReturnEffect
plain objectbecomes data
undefined / voidempty data {}
[status, data]sets the HTTP status, then handles data (server only)
Responsesets the response, data becomes undefined (mutation / action only)
RedirectTaskredirects (from the redirect(...) helper)
Error (thrown or returned)error state; status applied; normalized via your error class
React elementelement as data — see RSC; nested elements need .rsc({ depth: n })
array / string / primitivetype 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.

НазадValidationДалееContext

Enjoying Point0?

Give it a star on GitHub, ask questions, and follow what's new

Star Point0

A star on GitHub helps more developers find Point0
Star on GitHub

Start0

Point0 integrated into the Start0 SaaS boilerplate — the best way to use it
Explore Start0

YouTube

Tutorials, walkthroughs, and updates
YouTube

Discord

Questions and chat in English
Join Discord

Telegram

News channel and chat in Russian
ChannelChat

Twitter

Posts and updates
Twitter