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.

Actions live in src/actions/index.ts, exported as a server object. Each is a defineAction with an input schema (Zod) and a handler:

src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';
export const server = {
createComment: defineAction({
input: z.object({
postId: z.string(),
body: z.string().min(1).max(2000),
}),
handler: async (input, context) => {
// 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, // per-request data from middleware
});
return comment; // the return type flows to the client
},
}),
};

Two things are automatic: 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.

Import actions from astro:actions and call it. Every call returns { data, error } — Astro never throws across the boundary:

import { actions, isInputError } from 'astro:actions';
const { data, error } = await actions.createComment({
postId: '42',
body: 'Nice post!',
});
if (isInputError(error)) {
// Validation failed — error.fields has per-field messages.
console.log(error.fields.body);
} else if (data) {
console.log('Created comment', data.id); // data is typed
}
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-->>A: return value (typed)
  A-->>C: { data, error }
A client call runs the validated handler

An action can be wired directly to an HTML <form> by passing action.name — 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.

---
import { actions } from 'astro:actions';
---
<form method="POST" action={actions.createComment}>
<input type="hidden" name="postId" value="42" />
<textarea name="body"></textarea>
<button type="submit">Comment</button>
</form>

You can also invoke an action from a component script with Astro.callAction(actions.createComment, input) when you need its result during server render. The same validated handler runs in every case.

Where are actions defined and what does each need?
What happens to an action’s input before the handler runs?
What does a client action call return?
What do you gain by wiring an action to a `<form action={actions.name}>`?