Defining Actions
Astro Actions let you write backend functions that your frontend can call with full type safety, automatic input validation, and structured error handling. You define each action with the defineAction helper, give it an input schema, and write a server handler that runs only on the server. Because the validation, serialization, and typing are wired up for you, an action feels like calling a local async function while actually executing securely on the server.
The actions entrypoint
Every action lives under src/actions/, and Astro looks for an exported server object from src/actions/index.ts. This file is the single source of truth for the actions your app exposes. Each key on the server object becomes a callable action, and you can nest objects to group related actions into namespaces.
// src/actions/index.ts
import { defineAction } from "astro:actions";
import { z } from "astro:schema";
export const server = {
newsletter: {
subscribe: defineAction({
input: z.object({
email: z.string().email(),
consent: z.boolean(),
}),
handler: async ({ email, consent }) => {
if (!consent) {
throw new Error("Consent is required");
}
await saveSubscriber(email);
return { subscribed: true, email };
},
}),
},
};
This registers an action callable as actions.newsletter.subscribe(...) on the client. The astro:schema module re-exports Zod, so you do not need to install or import it separately.
Anatomy of defineAction
defineAction accepts a single options object. The two most important keys are input (the validation schema) and handler (the server logic).
| Option | Type | Purpose |
|---|---|---|
input | Zod schema | Validates and types the action’s arguments. Optional. |
handler | async (input, context) => result | Server-only logic; returns serializable data. |
accept | "json" or "form" | Controls how input is parsed. Defaults to "json". |
The handler receives the parsed, validated input as its first argument and an ActionAPIContext as its second. The context exposes the same essentials as an endpoint context, including cookies, request, locals, and url.
import { defineAction } from "astro:actions";
import { z } from "astro:schema";
export const server = {
getProfile: defineAction({
input: z.object({ userId: z.string().uuid() }),
handler: async ({ userId }, context) => {
const session = context.cookies.get("session")?.value;
if (!session) {
throw new Error("Not authenticated");
}
const user = await db.users.findById(userId);
return { name: user.name, joined: user.createdAt };
},
}),
};
Input validation
The input schema is enforced before your handler ever runs. If the caller passes data that fails validation, Astro short-circuits and returns a structured validation error — your handler is never invoked with bad data. This keeps validation logic out of the handler body.
import { defineAction } from "astro:actions";
import { z } from "astro:schema";
export const server = {
createPost: defineAction({
input: z.object({
title: z.string().min(3).max(120),
body: z.string().min(1),
tags: z.array(z.string()).max(5).default([]),
publishAt: z.coerce.date().optional(),
}),
handler: async (input) => {
const post = await db.posts.create(input);
return { id: post.id, slug: post.slug };
},
}),
};
Because the schema drives the types, input.title is string, input.tags is string[], and input.publishAt is Date | undefined — all inferred automatically and surfaced to the client caller.
Tip: An action with no
inputschema accepts no arguments and is called asactions.myAction(). Add aninputonly when the action needs data.
Accepting form submissions
By default an action expects JSON. To consume an HTML form’s FormData directly, set accept: "form". The input schema then validates the form fields, and z.coerce is invaluable for converting the always-string form values into the right types.
import { defineAction } from "astro:actions";
import { z } from "astro:schema";
export const server = {
rsvp: defineAction({
accept: "form",
input: z.object({
name: z.string().min(1),
guests: z.coerce.number().int().min(0).max(10),
vegetarian: z.boolean().optional(),
}),
handler: async ({ name, guests, vegetarian }) => {
await db.rsvps.add({ name, guests, vegetarian: !!vegetarian });
return { confirmed: true };
},
}),
};
A checkbox named vegetarian is treated as a boolean automatically when present, and z.coerce.number() turns the "3" string into the number 3.
Returning data and throwing errors
Whatever your handler returns is serialized and sent back to the caller. Return plain serializable values — objects, arrays, strings, numbers, Date, Map, and Set are all supported via Astro’s devalue-based serialization. For expected, recoverable failures, throw an ActionError so callers receive a typed code instead of a generic 500.
import { defineAction, ActionError } from "astro:actions";
import { z } from "astro:schema";
export const server = {
redeem: defineAction({
input: z.object({ code: z.string() }),
handler: async ({ code }) => {
const coupon = await db.coupons.find(code);
if (!coupon) {
throw new ActionError({
code: "NOT_FOUND",
message: "That coupon code does not exist.",
});
}
return { discount: coupon.discount };
},
}),
};
When you run the dev server, the action registry is reported on startup:
npm run dev
Output:
astro v5.x.x ready in 412 ms
┃ Local http://localhost:4321/
┃ actions newsletter.subscribe, getProfile, createPost, rsvp, redeem
Best practices
- Keep
src/actions/index.tslean: define schemas and import the real work from service modules rather than inlining business logic. - Always provide an
inputschema for actions that take arguments — let Zod validate at the boundary instead of hand-checking inside the handler. - Use
z.coerceforaccept: "form"actions so numeric and date fields arrive correctly typed. - Throw
ActionErrorwith a meaningfulcodefor expected failures, and reserve thrown plainErrors for genuine bugs. - Group related actions into nested namespaces (for example
newsletter.subscribe) to keep the public surface organized. - Return only serializable data from handlers, and never leak secrets or internal fields in the response shape.