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
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
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.
// 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.
// 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
// 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!')
}
on: dict on page(). Auto-CSRF, auto-typed callers, and the path is derived from page + handler name — zero boilerplate.// 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.
// 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.
// 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/prototyperejected
See Security concept for the full picture.