Overview
Astro Actions (since Astro 4.15) are type-safe server functions. You export them from src/actions/index.ts; Astro validates input with Zod, serializes results with devalue (so Date, Map, Set, and URL survive), and exposes them as functions on actions from astro:actions. Use an action instead of a hand-written API endpoint for mutations your own pages call. Every action is still a public endpoint at /_actions/<name>.
Define actions in the server export
All actions must be exported from a server object in src/actions/index.ts. Nest related actions in objects to organize them (actions.user.getUser()).
// src/actions/index.ts
import { defineAction, ActionError } from "astro:actions";
import { z } from "astro/zod";
export const server = {
likePost: defineAction({
input: z.object({ postId: z.string() }),
handler: async ({ postId }, context) => {
if (!context.locals.user) throw new ActionError({ code: "UNAUTHORIZED" });
return db.like(postId, context.locals.user.id);
},
}),
};Check error before data
A call returns { data, error }. Check error first so data is defined without a separate undefined check. Throw an ActionError with a code such as NOT_FOUND, UNAUTHORIZED, or BAD_REQUEST from the handler instead of returning undefined, so the client gets a status code and a typed error. Use .orThrow() only when something else catches errors.
const { data, error } = await actions.likePost({ postId });
if (error?.code === "UNAUTHORIZED") showLogin();
else if (!error) updateLikes(data);Accept forms with accept: "form"
Set accept: "form" to parse FormData into an object keyed by each input’s name. Number inputs validate with z.number(), checkboxes with z.coerce.boolean(), files with z.instanceof(File), repeated names with z.array(), and everything else with z.string(). Empty inputs arrive as null, except arrays and booleans. Omit input to receive the raw FormData.
Submit HTML forms with zero JavaScript
Pass the action as the form’s action with method="POST". The page must render on demand (export const prerender = false). Read the outcome on the server with Astro.getActionResult() and use isInputError() to show per-field messages. Inputs clear on submit; add transition:persist to keep their values when <ClientRouter /> is on. Add enctype="multipart/form-data" for file uploads.
---
import { actions, isInputError } from "astro:actions";
const result = Astro.getActionResult(actions.newsletter);
const fieldErrors = isInputError(result?.error) ? result.error.fields : {};
---
<form method="POST" action={actions.newsletter}>
<input name="email" type="email" required aria-describedby="email-error" />
{fieldErrors.email && <p id="email-error">{fieldErrors.email.join(", ")}</p>}
<button>Sign up</button>
</form>Authorize inside every handler
Treat actions as public endpoints and apply the same authorization you would to an API route. Check the session in the handler through context.locals and throw ActionError({ code: "UNAUTHORIZED" }). Middleware can gate actions with getActionContext(), but that is a coarse check for session presence, not a substitute for per-action authorization and rate limiting. Action request bodies are capped at 1 MB by default; change it with security.actionBodySizeLimit.
Call actions from server code
Use Astro.callAction(actions.findProduct, { query }), or context.callAction() in an endpoint, to reuse an action’s validation and logic on the server. It returns the same { data, error } shape. For the server-rendering setup, see astro-ssr; for the Next.js equivalent, see nextjs-server-actions.