Astro Actions
Astro Actions let you write backend functions once and call them from anywhere — a client island, a server component, or a form — with full end-to-end type safety and no hand-written API routes. Instead of building a fetch endpoint, validating the body, and re-declaring the response shape on the client, you define a single function whose input schema and return type flow automatically to the call site. This keeps Astro’s zero-JS-by-default philosophy intact: nothing ships to the browser unless you opt in with a client:* directive, and even then the client only receives a thin, typed RPC stub.
Why actions exist
Traditional Astro endpoints (src/pages/api/*.ts) are flexible but verbose. You manually parse request.json(), validate it, branch on the HTTP method, set status codes, and then keep a parallel TypeScript interface on the front end so the consumer knows what came back. Any drift between the two becomes a runtime bug.
Actions collapse that into one declaration. The framework generates the route, parses input, runs validation, serializes the result, and exposes a typed actions object you import directly. You get the ergonomics of a typed function call (await actions.like(post)) over what is really a network request.
| Concern | Plain endpoint | Action |
|---|---|---|
| Input validation | Manual | Zod schema, automatic |
| Return type on client | Re-declared by hand | Inferred |
| Route wiring | You create the file/path | Generated |
| Error handling | Custom status logic | Structured ActionError |
| Form support | Manual FormData parsing | Native accept: 'form' |
A first action
Every action lives in src/actions/index.ts, exported from a single server object. You build each one with defineAction, declaring an input schema and a handler that returns plain data.
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
greet: defineAction({
input: z.object({ name: z.string().min(1) }),
handler: async ({ name }) => {
return { message: `Hello, ${name}!` };
},
}),
};
The astro:schema module re-exports Zod, so you do not need a separate import. The handler receives the already-validated, fully typed input — name is a string, guaranteed.
Calling it with type safety
On the client or server you import the generated actions object from astro:actions. The return value is a discriminated { data, error } result, so you never have to wrap the call in your own try/catch for expected failures.
---
// src/pages/index.astro
import { actions } from 'astro:actions';
const { data, error } = await actions.greet({ name: 'Ada' });
---
<p>{error ? 'Something went wrong' : data.message}</p>
Hover over data in your editor and you will see { message: string } inferred straight from the handler — no manual typing, no codegen step you run yourself.
Output:
<p>Hello, Ada!</p>
Where actions run
Actions always execute on the server, even when invoked from a browser island. When called from client code, Astro transparently issues a POST request to a generated endpoint and deserializes the response. This means you can safely use secrets, database clients, and Node APIs inside a handler.
---
// src/components/GreetButton.astro
---
<button id="go">Greet</button>
<script>
import { actions } from 'astro:actions';
document.querySelector('#go')!.addEventListener('click', async () => {
const { data, error } = await actions.greet({ name: 'World' });
if (!error) alert(data.message);
});
</script>
Actions require an SSR-capable setup. Add an adapter (
@astrojs/node,@astrojs/vercel, etc.) and ensure the relevant routes are rendered on demand, otherwise the generated endpoint has nowhere to run.
Handling expected errors
For validation failures and business-rule errors, return an ActionError rather than throwing a raw exception. It carries a typed code and surfaces cleanly through the error field at the call site.
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
deletePost: defineAction({
input: z.object({ id: z.string() }),
handler: async ({ id }, ctx) => {
const user = ctx.locals.user;
if (!user) {
throw new ActionError({
code: 'UNAUTHORIZED',
message: 'You must be logged in.',
});
}
return { deleted: id };
},
}),
};
The second ctx argument exposes the request context, including locals, cookies, and headers — the same APIContext you know from endpoints and middleware.
Best practices
- Keep handlers thin: validate with Zod, delegate real work to plain service functions, and return serializable data only.
- Throw
ActionErrorfor expected failures (auth, not-found, validation) so the client receives a typederrorinstead of an opaque 500. - Use
astro:schema’s re-exportedzto avoid version mismatches between your Zod and Astro’s. - Treat actions as the default for mutations and the typed read path; reserve raw endpoints for webhooks, file streaming, or non-JSON responses.
- Never trust client input — the
inputschema is your only enforced boundary, so make it strict (.min,.email, enums). - Confirm an SSR adapter is configured before relying on actions in production.