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 😎

Validation

  • Раздел: Methods

On this page

  1. Which method for which point
  2. Any Standard Schema library
  3. Schema helpers — register your library on the root
  4. .input — queries, mutations, components
  5. .params and .search — pages and layouts
  6. .params — route segments
  7. .search — the query string
  8. .body, .headers, .cookies — actions and request data
  9. Schemas merge down the chain
  10. Narrowing is allowed, widening is a type error
  11. Security: validation runs on the server
  12. Reference
  13. Method → point types → request location
  14. Overloads
  15. How validation runs
  16. .input vs component props

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 library works (zod, valibot, arktype, typebox, yup, superstruct), or a plain validate function.

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:

// query / infiniteQuery / mutation / component / provider → .input
.query().input(z.object({ id: z.number() }))

// page / layout → .params (route segments) and .search (query string)
.page('/ideas/:id').params(z.object({ id: z.coerce.number() }))
.page('/ideas').search(z.object({ page: z.coerce.number().default(0) }))

// action → .params, .search, and .body
.action('POST', '/api/ideas/:id')
  .params(z.object({ id: z.string() }))
  .body(z.object({ title: z.string() }))

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:

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

Any Standard Schema library

.input(schema) and its siblings accept any object that implements the Standard Schema interface. zod is the default in our examples, but the validation path only touches the ~standard contract, so the library is interchangeable:

import * as v from 'valibot'
.input(v.object({ id: v.number() })) // valibot — same as zod here
import { type } from 'arktype'
.input(type({ id: 'number' })) // arktype

Instead of a schema you can pass a validate function — it receives the raw input and returns the parsed value, throwing to reject:

.input((raw) => {
  if (typeof raw.id !== 'number') throw new Error('id must be a number')
  return { id: raw.id }
})

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

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:

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

.input — queries, mutations, components

.input is the schema for a query, infiniteQuery, mutation, component, or provider. It forms the cache key, is validated on the server, and is sent to the loader:

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:

.clientInput(schema)  // validate at render / on the client — not on the server
.sharedInput(schema)  // validate on BOTH server and client

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 or 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:

export const ideaViewPage = generalLayout.lets
  .page('/ideas/:sn')
  .params(z.object({ sn: z.coerce.number() })) // "123" → 123
  .page(({ params }) => <h1>Idea #{params.sn}</h1>) // params.sn is a number

A validate function passed to .params receives Record<string, string> — 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 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:

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 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, where it's cut from the client bundle.

setSearch and the rest of the search/routing surface live on Navigation.

.body, .headers, .cookies — actions and request data

An 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:

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.

.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 or plugin can declare part of an input, and the point fills in the rest:

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:

.base().input(z.object({ id: z.number() }))
  .query().input(z.object({ id: z.literal(1) })) // ✓ narrows number → 1

.base().input(z.object({ id: z.number() }))
  .query().input(z.object({ id: z.string() })) // type error: not assignable to the base schema

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 wrapper that throws your error class (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

MethodAvailable onValidated onOpenAPI location
.inputquery, infiniteQuery, mutation, component, provider (+ root/base/plugin)serverrequest body
.clientInputsame as .inputclient / mount—
.sharedInputsame as .inputserver + clientrequest body
.paramspage, layout, actionserver + clientpath (required)
.searchpage, layout, action (+ root/base/plugin)server + clientquery
.bodyaction (+ root/base/plugin)serverrequest body
.headersevery point typeserverheader
.cookiesevery point typeservercookie
.schemaHelperroot 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):

.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 (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 (<Greet name="…" />), spread directly and not schema-validated. See component for both side by side.

НазадPluginДалееLoader

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