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 😎

Action

  • Раздел: Points

On this page

  1. When to use an action
  2. Declaring an action
  3. The route prefix is inherited
  4. Input: params, search, body — not input
  5. The body, and webhooks
  6. The loader and the return value
  7. Setting status, headers, cookies
  8. Consuming an action as a query, mutation, or infinite query
  9. OpenAPI
  10. Gating an action
  11. Edge cases
  12. Reference
  13. Methods on an action
  14. The action ready point
  15. Input shape for the fetch helpers

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.

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

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

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 helper) — the framework doesn't add CORS headers for you.

When the name is inferred, the action is named after its method and route:

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"

A route token can't be a wildcard — root.lets('GET', '/files/*') throws (Wildcard is not allowed in action point. Use middleware instead).

The route prefix is inherited

Like a page, an action extends a base's basePath:

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:

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

// POST /api/raw-body/store  with  {"b":777}
.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)
})

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:

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:

root
  .lets('POST', '/api/echo')
  .body(z.object({ msg: z.string() }))
  .action(({ body }) => body)

…or declare .loader(fn) and close with a bare .action():

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:

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

.action(({ set, body }) => {
  set.status(201)
  set.cookies('session', token, { httpOnly: true })
  return { id }
})

set is the same response-effects helper documented for mutations and on the 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.

// 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(/* ... */)
// 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.

OpenAPI

An action shows up in the generated 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:

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.

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 (or a server-side .middleware / 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 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.)

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 you can call while building an action.

Input: .params, .search, .body, .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, .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). Both server-only — cut from the client bundle (they only feed the server-generated spec).

Shared: .use, .middleware, .on / .serverOn / .clientOn (events), .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 / .mutation / .infiniteQuery. 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, plus fetch helpers fetch, fetchServer, fetchServerDetailed, getFetchServerOptions. The rest of the surface depends on how you closed it:

Closed withYou get
.action() / .mutation()useMutation, fetchMutation, getMutationKey, … (full mutation surface)
.query()useQuery, fetchQuery, getQueryKey, … (full query surface)
.infiniteQuery(...)the *InfiniteQuery* surface (infinite-query)

action.route is a callable Route0 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:

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:

await action.fetchMutation({ params: { id }, body: { title, content } })
НазадMutationДалееRoot

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