Skip to content
Astro as actions 4 min read

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).

OptionTypePurpose
inputZod schemaValidates and types the action’s arguments. Optional.
handlerasync (input, context) => resultServer-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 input schema accepts no arguments and is called as actions.myAction(). Add an input only 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.ts lean: define schemas and import the real work from service modules rather than inlining business logic.
  • Always provide an input schema for actions that take arguments — let Zod validate at the boundary instead of hand-checking inside the handler.
  • Use z.coerce for accept: "form" actions so numeric and date fields arrive correctly typed.
  • Throw ActionError with a meaningful code for expected failures, and reserve thrown plain Errors 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.
Last updated June 14, 2026
Was this helpful?