Skip to content
Astro as actions 4 min read

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.

ConcernPlain endpointAction
Input validationManualZod schema, automatic
Return type on clientRe-declared by handInferred
Route wiringYou create the file/pathGenerated
Error handlingCustom status logicStructured ActionError
Form supportManual FormData parsingNative 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 ActionError for expected failures (auth, not-found, validation) so the client receives a typed error instead of an opaque 500.
  • Use astro:schema’s re-exported z to 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 input schema is your only enforced boundary, so make it strict (.min, .email, enums).
  • Confirm an SSR adapter is configured before relying on actions in production.
Last updated June 14, 2026
Was this helpful?