Skip to content

Actions

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.

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:

src/actions/index.ts
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 401

Two 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 }
A client call runs the validated, authorized handler

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:

src/pages/index.astro
---
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>

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 input schema, or does the handler reach into context.request and parse the body by hand, bypassing validation?
  • Does a raw fetch() to /_actions/<name> get treated as if it returns plain JSON? It returns devalue-encoded data — only actions.name(...) (or .orThrow()) decodes it, as proven above.
  • Is ActionError thrown with a real code from the table above, or a made-up string that won’t map to a sensible HTTP status?

🐛 Spot the bug

src/actions/index.ts
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).

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.

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
  • “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 deletePost bug above.
  • “isInputError and a thrown ActionError look the same to the client.” They’re two different shapes (AstroActionInputError vs AstroActionError, proven above) — isInputError narrows specifically to the validation-failure case, with per-field fields.
  • “I can fetch() the action path and read normal JSON.” Proven above: the raw endpoint returns devalue-encoded data; use actions.name(...) or .orThrow(), which decode it for you.
Where are actions defined and what does each need?
What happens to an action’s input before the handler runs?
Proven with a real curl POST to /_actions/createComment with no session cookie — what is the actual response?
handler: async (input, context) => {
  const comment = await db.comments.create({ body: input.body });
  return comment;
}
What does calling the raw /_actions/<name> endpoint directly with curl return, compared to calling actions.name(...) from client code?
What distinguishes isInputError(error) from a thrown ActionError({ code: 'UNAUTHORIZED' })?
What does wiring an action to <form action={actions.name}> give you, and how do you read the result after the POST?