place

action()

action() declares a server-only function that the client can call by name with an inferred input shape and an inferred return type. No Babel pass, no encrypted action IDs, no 'use server' directive — the function lives in a file with a server-only type and the framework strips it from the client bundle.

Signature

ts
action<I, O>(def: { path: string input: (raw: unknown) => I // validator (schema-agnostic) fn: (input: I, ctx: ActionCtx) => Promise<O> | O }): { handler, call, path }

I is the validated-input type (output of input); O is the return type of fn. The handler validates the body, enforces same-origin + body-size + prototype-pollution defaults, then runs fn.

Defining an action

ts
import { action, shape } from '@place/component' export const updateProfile = action({ path: '/profile/update', input: shape({ name: 'string', age: 'number?', }), fn: async (input, { req }) => { await db.users.update(currentUser(req).id, input) return { ok: true } }, })

shape() is the schema-agnostic validator the framework ships; input accepts any (raw: unknown) => T function, so plug in Zod, Valibot, or yours.

Registering

There is no actions field on app(). An action() result exposes a .handler — a { 'METHOD /path': fn } fragment of the route table. Spread it into serve()'s routes alongside your page routes; path uniqueness is checked when the route table is built.

ts
// An action() result carries a `.handler` — a { 'METHOD /path': // fn } route-table fragment. Spread it into serve()'s routes: import { serve } from '@place/component' import { updateProfile, deleteAccount, createPost } from './actions' serve({ routes: { ...updateProfile.handler, ...deleteAccount.handler, ...createPost.handler, // page routes alongside: '/': home, }, }) // Co-located on a page instead? Use page({ on: { … } }) — the // handler registers automatically under {pagePath}/_action/{key}. // See "Page-co-located actions" below.

Schema interop — fromStandard()

input accepts any (raw: unknown) => T function. fromStandard() adapts any Standard Schema v1 validator — Zod 3.24+, Valibot 0.36+, ArkType, Effect Schema — into that shape, with structured field-level errors. The framework ships no validation dependency.

ts
// Schema interop — fromStandard() adapts any Standard Schema v1 // validator (Zod 3.24+, Valibot 0.36+, ArkType, Effect Schema) into // the (raw: unknown) => T shape `input` expects. No validation dep. import { z } from 'zod' import { action, fromStandard, isValidationFailure } from '@place/component' export const signup = action({ path: 'POST /api/signup', input: fromStandard(z.object({ email: z.string().email(), age: z.number().int().min(18), })), fn: async ({ email, age }) => ({ ok: true }), }) // On validation failure fromStandard throws // ActionError(400, 'Validation failed', { fields }) — narrow the // payload with isValidationFailure to route per-field messages.

Calling from the client

ts
// Client-side: call() is fully type-inferred — input matches the // declared shape; the return type is inferred from fn()'s return. import { updateProfile } from './actions' const onSubmit = async () => { const result = await updateProfile.call({ name: 'Ada' }) // ^? { ok: boolean } if (result.ok) toast.success('Saved!') }
Page-co-located actions
For mutations that belong to a single page, use the on: dict on page(). Auto-CSRF, auto-typed callers, and the path is derived from page + handler name — zero boilerplate.
ts
// For page-co-located actions, use the on: dict on page(). The // framework auto-CSRFs and auto-types the caller; the path is derived // from the page's path + handler name. export default page('/posts/:id/edit', { meta: { title: 'Edit post' }, load: async ({ params }) => ({ post: await db.posts.find(params.id) }), on: { save: async (input: { title: string }, { params }) => { await db.posts.update(params.id, input) return { ok: true } }, }, view: ({ post, on }) => ( <Form action={on.save} // typed caller — input shape inferred defaults={{ title: post.title }} > <Input name="title" /> <Button type="submit">Save</Button> </Form> ), })

Structured errors

Thrown errors don't get stringified across the wire. action.call() resolves with the typed return on 2xx, or throws an ActionError with .status, .message, and a structured .payload on non-2xx.

ts
// action errors are STRUCTURED, not stringified. The client sees a // typed ActionError with .status and .payload. try { await updateProfile.call({ name: '' }) } catch (e) { if (e instanceof ActionError && e.status === 400) { // .message is the server's error string; .payload carries any // structured data the handler attached (e.g. fromStandard's // { fields } map on validation failure). toast.error(e.message) } }

Request-scoped caches

Caps installed during a request are isolated via runWithCapabilityScope — concurrent requests can't see each other's caps. That means cache(fn) is auth-bleed-proof by construction; the per-request cap stack is part of the cache key.

ts
// Per-request capability scopes guarantee actions can't accidentally // share cache entries across users. cache(fn) uses the request scope // as part of the cache key; auth-bleed bugs (Next #86538) don't reach // production here. const getUser = cache(async (id: string) => db.users.find(id)) export const reactToPost = action({ path: '/posts/react', input: shape({ postId: 'string' }), fn: async (input, { req }) => { const user = await getUser(currentUserId(req)) // ^ scoped to THIS request — no bleed across concurrent requests return reactToPost(input.postId, user.id) }, })

This closes the class of footguns documented in Next.js issue #86538 (auth context bleeding between concurrent cached requests) by structure, not by linting.

Security defaults that apply

  • Same-origin enforcement — cross-origin requests rejected by default
  • Auto-CSRF — token validated transparently when load() returns one
  • Body size limit — 1 MB on 'standard', 256 KB on 'strict'
  • Prototype-pollution guard — JSON keys __proto__/constructor/prototype rejected

See Security concept for the full picture.