
What are Astro Actions and how do they work?
Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async functions. You declare them with defineAction() in src/actions/index.ts, give each one an optional Zod schema for its input, and Astro does the rest: it creates an endpoint for every action, parses JSON or form data, validates the input before your code runs, serialises the result and generates a typed client you import from astro:actions. Every call returns a { data, error } object, so success and failure are handled in the same predictable way whether the action was triggered by a button click, a <script> or a plain HTML form.
Before actions, the usual way to run server code from a page was to write an API endpoint, call it with fetch(), parse the JSON by hand and keep the request and response types in sync yourself. Actions remove that boilerplate without hiding what is really happening, which is still an HTTP request to your server. In this article you'll learn how to define and organise actions, call them from client scripts and from forms that work without JavaScript, validate input and display field errors, throw and handle typed errors, call actions on the server, and protect them properly.
What You Need Before You Start
Actions run on the server at request time, so your project needs an adapter such as @astrojs/node, @astrojs/vercel, @astrojs/netlify or @astrojs/cloudflare. The adapter post on choosing an Astro adapter explains the options.
You don't have to switch the whole site to on-demand rendering. Actions are endpoints, so they work in a mostly static site. The one exception is pages that submit an HTML form directly to an action: those pages must be rendered on demand, because Astro needs to run the page after the form is posted. Add export const prerender = false to them, or read the guide to mixing prerendered and on-demand pages.
The examples in this article were built and run against Astro 7.4.2 with the Node adapter.
Defining Your First Action
Actions live in a server object exported from src/actions/index.ts. Each key becomes the action's name:
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';
const likes = new Map<string, number>();
export const server = {
likePost: defineAction({
input: z.object({ postId: z.string().min(1) }),
handler: async ({ postId }) => {
const next = (likes.get(postId) ?? 0) + 1;
likes.set(postId, next);
return next;
},
}),
};
defineAction() takes three options:
- handler: the function that runs on the server. It receives the validated input and a context object, and whatever it returns is sent back to the caller. This is the only required option.
- input: a Zod schema. If validation fails, the handler never runs and the caller receives a
BAD_REQUESTerror. - accept: either
'json'(the default) or'form'. Use'form'when the action receivesFormDatafrom an HTML form.
Notice that z comes from astro/zod. Astro 6 moved to Zod 4 and this is the import to use, so helpers such as z.email() and the error option for custom messages follow Zod 4 conventions.
The in-memory Map is only there to keep the example self-contained. A real app would write to a database, which is covered in connecting Astro to a database.
Organising Larger Projects
Every action must be reachable from the server object, but it doesn't have to be written in index.ts. Group related actions in their own files and import them:
// src/actions/admin.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';
export const admin = {
publish: defineAction({
input: z.object({ id: z.string() }),
handler: async ({ id }) => ({ published: id }),
}),
};
// src/actions/index.ts
import { admin } from './admin';
export const server = {
admin,
likePost: defineAction({ /* ... */ }),
};
Nested actions are called with dot notation, such as actions.admin.publish(), and their endpoint names follow the same pattern.
Calling an Action from the Browser
Import actions from astro:actions in any client-side script or UI framework component. The generated function has the same name as your action and is fully typed:
---
// src/pages/like.astro
---
<button id="like" data-post-id="hello-world">Like</button>
<span id="count">0</span>
<script>
import { actions } from 'astro:actions';
const button = document.querySelector<HTMLButtonElement>('#like')!;
const count = document.querySelector('#count')!;
button.addEventListener('click', async () => {
const { data, error } = await actions.likePost({
postId: button.dataset.postId!,
});
if (error) {
console.error(error.code, error.message);
return;
}
count.textContent = String(data);
});
</script>
TypeScript knows that data is a number once you have checked error, and it will complain if you pass { id: 1 } instead of { postId: 'hello-world' }. If the actions object doesn't pick up a new action straight away, restart the dev server or run astro sync to regenerate the types.
What Happens on the Wire
Under the hood, each call is a POST request to /_actions/<name>. You can see that for yourself with curl against a running build:
curl -X POST http://localhost:4321/_actions/likePost \
-H 'Content-Type: application/json' \
-d '{"postId":"hello"}'
[1]
The response has the content type application/json+devalue. Astro serialises results with the devalue library rather than plain JSON, which is why you can return Date, Map, Set and URL values and get real instances back on the client. The trade-off is that the raw payload in the Network panel looks odd, so inspect the returned data object when debugging rather than the response body.
Skipping the Error Check with orThrow()
When you're prototyping, or inside code that already has a try...catch, orThrow() returns the data directly and throws if the action fails:
const total = await actions.likePost.orThrow({ postId: 'hello-world' });
For production UI, checking error explicitly usually leads to better messages for users.
Handling Form Submissions
Set accept: 'form' and the action will read FormData. Astro maps form fields onto your schema according to the validator types:
| Validator | How the form value is read |
|---|---|
z.string() | The field's text value |
z.number() | The field's value converted to a number |
z.coerce.boolean() | A checkbox: present means true |
z.instanceof(File) | A file input |
z.array(...) | Every field that shares the same name |
Empty fields become null, except for arrays and booleans. Here is a newsletter action that uses several of those types:
// src/actions/index.ts
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro/zod';
export const server = {
newsletter: defineAction({
accept: 'form',
input: z.object({
email: z.email({ error: 'Enter a valid email address.' }),
topics: z.array(z.string()).optional(),
consent: z.coerce.boolean(),
}),
handler: async ({ email, topics, consent }) => {
if (!consent) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Please tick the consent box.',
});
}
return { email, topics: topics ?? [], at: new Date() };
},
}),
};
If you leave out input entirely, the handler receives the raw FormData object, which is handy when you want to parse it yourself.
Submitting Without JavaScript
The most robust way to use a form action is to point an ordinary <form> at it. Set method="POST" and pass the action itself to the action attribute:
---
// src/pages/newsletter.astro
export const prerender = false;
import { actions, isInputError } from 'astro:actions';
const result = Astro.getActionResult(actions.newsletter);
const fieldErrors = isInputError(result?.error) ? result.error.fields : {};
---
<html lang="en">
<body>
{result?.data && <p>Thanks, {result.data.email} is subscribed.</p>}
{result?.error && !isInputError(result.error) && <p>{result.error.message}</p>}
<form method="POST" action={actions.newsletter}>
<label>
Email
<input type="email" name="email" required />
</label>
{fieldErrors.email && <p class="error">{fieldErrors.email.join(', ')}</p>}
<label><input type="checkbox" name="topics" value="astro" /> Astro</label>
<label><input type="checkbox" name="topics" value="css" /> CSS</label>
<label><input type="checkbox" name="consent" /> I agree to receive emails</label>
<button>Subscribe</button>
</form>
</body>
</html>
In the rendered HTML the attribute becomes a query string on the current page:
<form method="POST" action="?_action=newsletter">
When the form is posted, Astro runs the action and then renders the same page. Astro.getActionResult() returns the action's { data, error } for that request, or undefined when the action didn't run. Posting an invalid email to the built site returned the custom Zod message, and posting a valid email without the checkbox returned the ActionError message:
<p class="error">Enter a valid email address.</p>
<p>Please tick the consent box.</p>
Add enctype="multipart/form-data" to the form if it includes a file input.
A common follow-up is to redirect after success. Because result.data is available in the frontmatter, you can return Astro.redirect(...) as soon as it exists. The guide on handling form submissions in Astro compares this pattern with plain endpoints.
Avoiding the Resubmit Prompt
Results from form actions are tied to the POST request, so refreshing the page triggers the browser's "resubmit form?" warning. To fix that, Astro lets you take over result handling in middleware with getActionContext(). It returns the pending action (with calledFrom, name and handler()), plus setActionResult() and serializeActionResult(). The documented pattern is POST/Redirect/GET: run the handler yourself, store the serialised result in a session, redirect, and restore the result on the next GET. It's more code, so reach for it only when the refresh behaviour matters.
Errors: Input Errors and ActionError
An action can fail in two ways, and both arrive in the same error property.
Validation errors happen before your handler runs. Check for them with isInputError(), which narrows the type and gives you a fields object mapping each field name to an array of messages. This is the response from the built site when postId was an empty string:
HTTP/1.1 400 Bad Request
{"type":"AstroActionInputError","issues":[...],"fields":{"postId":["Too small: expected string to have >=1 characters"]}}
Errors you throw use ActionError. Give it a code and an optional message:
// src/actions/index.ts
deletePost: defineAction({
input: z.object({ id: z.string() }),
handler: async ({ id }, context) => {
if (!context.cookies.has('session')) {
throw new ActionError({ code: 'UNAUTHORIZED', message: 'Log in first.' });
}
return { deleted: id };
},
}),
The code maps to an HTTP status, which helps when you're reading server logs:
HTTP/1.1 401 Unauthorized
{"type":"AstroActionError","code":"UNAUTHORIZED","status":401,"message":"Log in first."}
On the client you branch on error.code, for example showing a login link for UNAUTHORIZED and a generic retry message for anything else. Codes are upper-case versions of HTTP status names, such as BAD_REQUEST, NOT_FOUND, FORBIDDEN, TOO_MANY_REQUESTS and INTERNAL_SERVER_ERROR. Use isActionError() when you need to tell an ActionError apart from other thrown values.
The Action Context
The second argument to handler is an ActionAPIContext. It's a subset of the normal Astro context, so you get the things you need for server logic:
- context.cookies: read and set cookies, covered in detail in reading and setting cookies.
- context.session: server-side session storage, when a session driver is configured.
- context.locals: values set by middleware, such as the current user.
- context.request and context.url: the incoming request.
A few things are deliberately missing: callAction, getActionResult, props and redirect aren't available inside a handler. To redirect after an action, do it from the page or from client code (for example with navigate() from astro:transitions/client).
Calling Actions on the Server
Sometimes you want to reuse an action's logic while rendering a page. Astro.callAction() runs it directly on the server and returns the same { data, error } shape:
---
// src/pages/server-call.astro
export const prerender = false;
import { actions } from 'astro:actions';
const { data, error } = await Astro.callAction(actions.likePost, { postId: 'from-server' });
---
<p>{error ? error.message : `Likes: ${data}`}</p>
<p>Likes: 1</p>
In API endpoints the equivalent is context.callAction(). Input is still validated, so you get the same guarantees as a client call. If the logic is used in many places, though, consider moving it into a plain function that both the action and the page import.
Securing Your Actions
The convenience of actions makes it easy to forget that each one is a public HTTP endpoint. actions.admin.publish() is served at /_actions/admin.publish, and anyone who knows the name can send a request to it. Treat actions exactly like API routes.
Authorise Inside the Handler
The safest place to check permissions is the handler itself, because it runs no matter how the action was called. Read the user from context.locals (set by middleware) or from the session, and throw ActionError with UNAUTHORIZED or FORBIDDEN if they aren't allowed. Post 75 in this series shows how to protect routes with middleware and populate locals.
Gate Groups of Actions in Middleware
getActionContext() also works for coarse checks. This middleware rejects client-side calls to anything under admin. when there's no session cookie:
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { getActionContext } from 'astro:actions';
export const onRequest = defineMiddleware(async (context, next) => {
const { action } = getActionContext(context);
if (action?.calledFrom === 'rpc' && action.name.startsWith('admin.')) {
if (!context.cookies.has('session')) {
return new Response('Forbidden', { status: 403 });
}
}
return next();
});
$ curl -X POST .../_actions/admin.publish -d '{"id":"7"}'
HTTP/1.1 403 Forbidden
$ curl -X POST .../_actions/admin.publish -H 'Cookie: session=abc' -d '{"id":"7"}'
[{"published":1},"7"]
calledFrom is 'rpc' for calls made through the generated client and 'form' for HTML form posts. A cookie check like this is a convenience filter, not real authorisation: it doesn't confirm that the session is valid. Keep the proper check in the handler.
Cross-Site Form Protection
Astro's security.checkOrigin option is on by default for on-demand pages, and it applies to form actions. Posting the newsletter form with an Origin header for a different host was rejected before the action ran:
HTTP/1.1 403 Forbidden
Cross-site POST form submissions are forbidden
Leave this enabled. It blocks the classic cross-site request forgery attack where another site submits a form to yours using the visitor's cookies.
Actions vs API Endpoints
Both are server code reached over HTTP, so how do you choose?
| Actions | API endpoints | |
|---|---|---|
| Typed client | Generated for you | Write your own fetch() |
| Input validation | Built in with Zod | Manual |
| HTML forms without JS | Yes, via action={actions.x} | Yes, but you handle the response |
| Custom URL and HTTP method | No, always POST /_actions/name | Any path and method |
| Public API for other clients | Possible, but not designed for it | Ideal |
| Streaming or custom headers in response | Limited | Full control |
Use actions for mutations triggered by your own UI: forms, likes, comments, settings. Use endpoints for webhooks, public JSON APIs, file downloads or anything that needs a specific URL or method.
FAQ: Astro Actions
They require an adapter, because actions run on the server at request time. Your pages can still be prerendered. Only pages that submit an HTML form directly to an action need to be rendered on demand.
Export a server object from src/actions/index.ts. Each key is an action created with defineAction. You can import grouped actions from other files into that object, and they are then called with dot notation such as actions.user.update.
Pass the error to isInputError from astro:actions. When it returns true, error.fields maps each input name to an array of messages, which you can render next to the matching form field.
Yes. Results are serialised with devalue, which supports Date, Map, Set and URL as well as normal JSON values, and they arrive as real instances on the client.
Input is validated and cross-site form posts are blocked by default, but every action is a public endpoint. You still need to check that the user is allowed to perform the action, ideally inside the handler using context.locals or the session.
From a page or endpoint, use Astro.callAction or context.callAction. callAction isn't available inside an action handler, so share logic between actions by moving it into a normal function that both import.
Conclusion
Astro Actions turn server functions into typed calls you can make from scripts, framework components and plain HTML forms. You define them with defineAction(), describe their input with a Zod schema from astro/zod, and get back a consistent { data, error } result with input errors, typed ActionError codes and devalue serialisation handled for you.
Underneath, each action is still a POST endpoint at /_actions/<name>, so treat it like one: authorise inside the handler, use middleware with getActionContext() for coarse filtering, and keep checkOrigin enabled. With those habits, actions become the simplest way to add forms and mutations to an Astro site without giving up progressive enhancement.


