Skip to content
Astro as actions 5 min read

Validation & Error Handling

Validation is the boundary that keeps bad data out of your application, and Astro Actions make it a first-class concern. When you attach a Zod schema to an action’s input, Astro validates every submission before your handler runs and returns a structured error object when validation fails. The challenge then shifts from catching errors to displaying them — mapping a failed field back to the input the user typed. This page shows how to define rich Zod schemas and surface precise, field-level messages in the UI.

Validation runs before your handler

When an action has an input schema, Astro parses the incoming JSON or FormData against it before calling handler. If parsing fails, the handler is skipped entirely and the caller receives an ActionError whose code is "BAD_REQUEST". That error carries the full Zod issue list, so you never have to re-run validation manually inside the handler.

// src/actions/index.ts
import { defineAction, ActionError } from "astro:actions";
import { z } from "astro:schema";

export const server = {
  register: defineAction({
    accept: "form",
    input: z
      .object({
        name: z.string().min(2, "Name must be at least 2 characters"),
        email: z.string().email("Enter a valid email address"),
        password: z.string().min(8, "Use at least 8 characters"),
        confirm: z.string(),
        age: z.coerce.number().int().min(18, "You must be 18 or older"),
      })
      .refine((data) => data.password === data.confirm, {
        message: "Passwords do not match",
        path: ["confirm"],
      }),
    handler: async ({ name, email, password }) => {
      const exists = await db.users.findByEmail(email);
      if (exists) {
        throw new ActionError({
          code: "CONFLICT",
          message: "An account with that email already exists.",
        });
      }
      const user = await db.users.create({ name, email, password });
      return { id: user.id };
    },
  }),
};

Note how each Zod check carries a custom message and how .refine uses path: ["confirm"] to attach a cross-field error to a specific input. Those messages and paths are exactly what you will render.

The shape of a validation error

Astro wraps Zod’s flatten() output on the returned error. When you call an action and validation fails, the result’s error is an ActionError with code: "BAD_REQUEST" and a fields object keyed by field name. This is far more convenient than parsing raw Zod issues yourself.

PropertyTypeDescription
error.codestring"BAD_REQUEST" for validation failures, or your own codes.
error.messagestringA human-readable summary message.
error.fieldsRecord<string, string[]>Per-field arrays of messages, present only on validation errors.

To access fields in a type-safe way, narrow the error with isInputError from astro:actions, which tells TypeScript the fields property exists.

Surfacing field errors in an Astro page

In a server-rendered .astro page you can call the action on the server with Astro.getActionResult() and read errors directly during render. This gives you a progressively-enhanced form that works with zero client JavaScript.

---
// src/pages/register.astro
import { actions } from "astro:actions";
import { isInputError } from "astro:actions";

const result = Astro.getActionResult(actions.register);
const fieldErrors = result?.error && isInputError(result.error)
  ? result.error.fields
  : {};
---

<form method="POST" action={actions.register}>
  <label>
    Name
    <input type="text" name="name" />
    {fieldErrors.name && <p class="error">{fieldErrors.name[0]}</p>}
  </label>

  <label>
    Email
    <input type="email" name="email" />
    {fieldErrors.email && <p class="error">{fieldErrors.email[0]}</p>}
  </label>

  <label>
    Password
    <input type="password" name="password" />
    {fieldErrors.password && <p class="error">{fieldErrors.password[0]}</p>}
  </label>

  <label>
    Confirm password
    <input type="password" name="confirm" />
    {fieldErrors.confirm && <p class="error">{fieldErrors.confirm[0]}</p>}
  </label>

  <label>
    Age
    <input type="number" name="age" />
    {fieldErrors.age && <p class="error">{fieldErrors.age[0]}</p>}
  </label>

  <button type="submit">Create account</button>
</form>

Because fields is keyed by the same names as your inputs, rendering an error is a direct lookup. Taking [0] shows the first message per field; map over the array if you want to show all of them.

Tip: Always reflect submitted values back into the inputs (via result?.data or the raw form values) so a validation error does not wipe out everything the user typed. Losing input on a failed submit is one of the most common form UX mistakes.

Handling errors when calling from the client

When you call an action from a script or an island, the returned object is a discriminated union of { data } or { error }. Check error first, then branch on isInputError to read field messages.

import { actions, isInputError } from "astro:actions";

const form = document.querySelector<HTMLFormElement>("#register")!;
form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const { data, error } = await actions.register(new FormData(form));

  if (error) {
    if (isInputError(error)) {
      for (const [name, messages] of Object.entries(error.fields)) {
        const field = form.querySelector(`[name="${name}"]`);
        field?.parentElement
          ?.querySelector(".error")
          ?.replaceChildren(messages[0] ?? "");
      }
    } else {
      alert(error.message);
    }
    return;
  }

  window.location.href = `/welcome?id=${data.id}`;
});

Output:

// On invalid submit, the action resolves without throwing:
{
  data: undefined,
  error: ActionError {
    code: "BAD_REQUEST",
    message: "Failed to validate: ...",
    fields: {
      email: ["Enter a valid email address"],
      confirm: ["Passwords do not match"]
    }
  }
}

Distinguishing validation from logic errors

Not every failure is a validation failure. A "CONFLICT" from a duplicate email or a "NOT_FOUND" from a missing record is a logic error you threw with ActionError. Use isInputError to keep the two paths separate: render fields next to inputs for validation problems, and show a single banner message for thrown logic errors.

if (error) {
  if (isInputError(error)) {
    showFieldErrors(error.fields);
  } else {
    showBanner(error.message); // e.g. "An account with that email already exists."
  }
}

Best Practices

  • Define one Zod schema as the single source of truth — let it drive both validation and the inferred input types.
  • Attach a custom message to every Zod check so the UI never shows a generic “Invalid” string.
  • Use .refine or .superRefine with an explicit path for cross-field rules like password confirmation.
  • Narrow with isInputError before touching error.fields; reserve plain error.message banners for thrown ActionError logic failures.
  • Echo submitted values back into inputs on a failed submit so users never lose their work.
  • Validate with z.coerce for accept: "form" actions, since every form value arrives as a string.
  • Keep validation on the server authoritative; treat any client-side checks as a UX nicety, not a security boundary.
Last updated June 14, 2026
Was this helpful?