Actions
The problem Actions solve
Section titled “The problem Actions solve”To call server logic from the client the old way, you write an endpoint, invent a URL, fetch() it, stringify the body, guess at the response type, and validate the input by hand on the server. Actions collapse all of that into a single typed function: define it on the server, call it from the client with full type-safety and automatic validation.
An Action is a public endpoint
Section titled “An Action is a public endpoint”Before any syntax: calling actions.createComment(...) from your own client code and curling the action’s URL directly are the same request as far as the server is concerned. Astro compiles every action into a real HTTP endpoint under /_actions/<name> — reachable by anyone who knows (or guesses) the name, whether or not they ever loaded your page. An action is not a private channel between your UI and your server; it’s a public POST endpoint that happens to have generated TypeScript bindings.
That means authorization is not optional polish, it’s the first line of the handler — check who’s calling before you touch the database, on every single action, starting with the first one you write:
import { defineAction, ActionError } from 'astro:actions';import { z } from 'astro/zod';
// Stand-in for your real database client — replace with your ORM/query layer.const db = { comments: { create: async (data: { postId: string; body: string; userId: string }) => ({ id: crypto.randomUUID(), ...data }) } };
export const server = { createComment: defineAction({ input: z.object({ postId: z.string(), body: z.string().min(1).max(2000), }), handler: async (input, context) => { // Reject first — before anything else runs. `locals.user` is set by // middleware (see the middleware & sessions lesson); it is `null` for // anyone without a valid session, curl included. if (!context.locals.user) { throw new ActionError({ code: 'UNAUTHORIZED', message: 'You must be signed in to comment.', }); }
// input is fully typed and already validated against the schema. const comment = await db.comments.create({ postId: input.postId, body: input.body, userId: context.locals.user.id, }); return comment; // the return type flows to the client }, }),};Proven against a running build of this exact handler — a POST with no session cookie at all:
$ curl -s -X POST http://127.0.0.1:4351/_actions/createComment \ -H "Content-Type: application/json" -d '{"postId":"42","body":"hi"}' \ -w "\nHTTP %{http_code}\n"{"type":"AstroActionError","code":"UNAUTHORIZED","status":401,"message":"You must be signed in to comment."}HTTP 401Two things are still automatic even with the auth check added: the input is validated against the Zod schema before your handler runs, and the handler’s return type is inferred all the way to the caller — no manual typing of the response.
ActionError: codes, not raw status numbers
Section titled “ActionError: codes, not raw status numbers”ActionError takes a human-readable code (from a fixed set mapped to real HTTP status codes) instead of a bare number — the same list actions and endpoints agree on:
| Code | Status | Typical use |
|---|---|---|
BAD_REQUEST |
400 | Malformed request outside what the input schema checks |
UNAUTHORIZED |
401 | No valid session — proven above |
FORBIDDEN |
403 | A valid session, but not allowed to do this |
NOT_FOUND |
404 | The row being mutated doesn’t exist (or isn’t this user’s) |
CONFLICT |
409 | A duplicate or out-of-date write |
TOO_MANY_REQUESTS |
429 | Rate limiting |
INTERNAL_SERVER_ERROR |
500 | The default if you throw a plain Error |
Input validation failures are a different, automatic error shape — isInputError narrows to it:
import { actions, isInputError } from 'astro:actions';
const { data, error } = await actions.createComment({ postId: '42', body: '' });
if (isInputError(error)) { // error.fields is keyed by input field name, each an array of messages. console.log(error.fields.body); // ["Too small: expected string to have >=1 characters"]} else if (error) { // Any other ActionError — check error.code. console.log(error.code);}Proven with the same handler, this time with an empty body (fails the .min(1) rule) — the real shape returned when calling the /_actions/createComment endpoint directly:
$ curl -s -X POST http://127.0.0.1:4351/_actions/createComment \ -H "Content-Type: application/json" -d '{"postId":"42","body":""}'{"type":"AstroActionInputError","issues":[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["body"],"message":"Too small: expected string to have >=1 characters"}],"fields":{"body":["Too small: expected string to have >=1 characters"]}}When you don’t want to branch on { data, error } at all — for example inside code that already has its own try/catch — call .orThrow() instead. It returns data directly and throws the ActionError (or a generic error) on failure:
try { const comment = await actions.createComment.orThrow({ postId: '42', body: 'Nice post!' }); console.log('Created comment', comment.id);} catch (e) { // e is the ActionError instance when the server threw one.}sequenceDiagram
participant C as Client
participant A as Astro Action
participant H as handler
C->>A: actions.createComment(input)
A->>A: validate input against Zod schema
A->>H: run handler(input, context)
H->>H: reject if !locals.user (ActionError UNAUTHORIZED)
H-->>A: return value (typed)
A-->>C: { data, error }Forms, progressive enhancement, and Astro.getActionResult
Section titled “Forms, progressive enhancement, and Astro.getActionResult”An action can be wired directly to an HTML <form> by passing it as the action prop — it works as a normal form POST even before JavaScript loads, then upgrades to a client call once hydrated. This is progressive enhancement: the form submits either way. Read the outcome of that POST-and-redirect-back cycle with Astro.getActionResult:
---import { actions } from 'astro:actions';
const result = Astro.getActionResult(actions.createComment);---<form method="POST" action={actions.createComment}> <input type="hidden" name="postId" value="42" /> <textarea name="body"></textarea> <button type="submit">Comment</button></form>{result?.error && <p class="error">{result.error.code}: {result.error.message}</p>}getActionResult reads the result of whichever action just submitted to this page (Astro parcels it up after the redirect) — undefined on a normal page load, populated right after a POST.
Calling from a client island, and getActionPath
Section titled “Calling from a client island, and getActionPath”The exact same actions import works from a hydrated framework component — Astro turns the call into a fetch() for you:
// src/components/LikeButton.tsx (a Preact island, client:visible)import { actions } from 'astro:actions';
export default function LikeButton({ postId }: { postId: string }) { return ( <button onClick={() => actions.likePost({ postId })}> Like </button> );}For cases where you can’t await a normal fetch — dispatching on unload or a background “mark as read” ping — getActionPath gives you the raw URL so you can use navigator.sendBeacon, which fires the request without waiting for (or blocking on) a response:
<script> import { actions, getActionPath } from 'astro:actions';
navigator.sendBeacon( getActionPath(actions.likePost), new Blob([JSON.stringify({ postId: '42' })], { type: 'application/json' }), );</script>Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet defines an action, check for these before trusting it:
- Does the handler check
context.locals.user(or an equivalent auth check) as the first line, or does it assume the caller is authenticated because a form only renders behind a login? Rendering the form for signed-in users only gates the UI, not the endpoint — the action itself is still reachable by direct POST. - If the action updates or deletes a row, does the query filter by the current user’s id as well as the row’s id — or could any signed-in user act on someone else’s data?
- Is every field read through the Zod
inputschema, or does the handler reach intocontext.requestand parse the body by hand, bypassing validation? - Does a raw
fetch()to/_actions/<name>get treated as if it returns plain JSON? It returnsdevalue-encoded data — onlyactions.name(...)(or.orThrow()) decodes it, as proven above. - Is
ActionErrorthrown with a realcodefrom the table above, or a made-up string that won’t map to a sensible HTTP status?
🐛 Spot the bug
import { defineAction } from 'astro:actions';import { z } from 'astro/zod';
export const server = {deletePost: defineAction({ input: z.object({ postId: z.string() }), handler: async (input, context) => { // "the delete button only shows for the post's author, so this is safe" await db.posts.delete({ where: { id: input.postId } }); return { ok: true }; },}),};Show the answer
The comment explains the actual bug: hiding the delete button in the UI controls what a signed-in author sees, not what the /_actions/deletePost endpoint will accept. Nothing here checks context.locals.user at all, so any authenticated caller — or an anonymous curl request, since there isn’t even an UNAUTHORIZED check — can delete any post by guessing or enumerating postId. The fix mirrors createComment above: reject when !context.locals.user with ActionError({ code: 'UNAUTHORIZED' }), and filter the delete by userId: context.locals.user.id too, so a signed-in user can only delete their own posts (an ActionError({ code: 'FORBIDDEN' }) or a where clause that simply matches nothing works — a NOT_FOUND for rows you don’t own also avoids leaking that the id exists at all).
Mental Model
Section titled “Mental Model”An action is a button that happens to compile into an HTTP endpoint, not an HTTP endpoint that happens to have a button. The function signature you write is the same function signature a stranger’s curl command calls — proven above with the real 401. If you would not accept a field from an anonymous POST body, do not accept it from the validated input object either; validation proves the shape is right, not that the caller is allowed to do this.
Trade-offs
Section titled “Trade-offs”| Action | API endpoint | |
|---|---|---|
| You write | A function, called directly from a form or client code | A URL, wired up and fetch-ed by hand |
| Validation | Automatic, from the Zod input schema |
Manual, in the handler body |
| Response shape | Inferred { data, error }, devalue-encoded over the wire |
Whatever Response you construct |
| Progressive enhancement | Built in via <form action={actions.x}> |
None — requires JavaScript to fetch |
| Best for | Mutations and forms inside your own app | A public API, webhook, or non-form client |
| Public reachability | Yes, via /_actions/<name> — proven above |
Yes, via its path |
Common Misconceptions
Section titled “Common Misconceptions”- “Only my form calls this action, so it’s safe.” Proven above: the exact same request reaches the handler from
curl, with no form and no session — the action ID is the whole contract. - “Hiding the button for non-owners is enough.” Render-time gating controls what UI shows up, not what requests the server accepts — see the
deletePostbug above. - “
isInputErrorand a thrownActionErrorlook the same to the client.” They’re two different shapes (AstroActionInputErrorvsAstroActionError, proven above) —isInputErrornarrows specifically to the validation-failure case, with per-fieldfields. - “I can
fetch()the action path and read normal JSON.” Proven above: the raw endpoint returnsdevalue-encoded data; useactions.name(...)or.orThrow(), which decode it for you.