Astro Actions: Form Validation That Survives JS

Astro Actions form validation turns a FormData submission into typed input before your handler runs. Set accept: 'form', give defineAction() a Zod object, and Astro returns field-level errors when parsing fails. Use a native method="POST" form for a zero-JavaScript baseline, then add client behaviour only where it improves the experience.

Actions arrived in Astro 4.15 and remain the shortest route from an Astro form to server code. They remove the repeated request.formData(), parsing and error-envelope work that tends to collect in small API routes. They still run on the server, so the deployment needs an adapter.

How does Astro Actions form validation work?

Every server action is exported beneath the server object in src/actions/index.ts. The Astro Actions guide documents two input modes: JSON is the default, while accept: 'form' parses form fields. With a Zod object as input, invalid data produces a BAD_REQUEST result and the handler does not run.

Here is a complete action for an enquiry form. The webhook URL stays in server-side environment configuration.

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

export const server = {
  sendEnquiry: defineAction({
    accept: 'form',
    input: z.object({
      name: z.string().trim().min(2).max(80),
      email: z.email(),
      message: z.string().trim().min(20).max(2_000),
    }),
    handler: async (input) => {
      const response = await fetch(import.meta.env.CRM_WEBHOOK_URL, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(input),
      });

      if (!response.ok) {
        throw new ActionError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'The enquiry could not be saved.',
        });
      }

      return { accepted: true };
    },
  }),
};

Astro’s form parser has a few rules that save manual coercion. Number controls can use z.number(), checkboxes can use z.boolean(), repeated names can use z.array(), and uploads can use z.instanceof(File). Empty scalar controls arrive as null, however. A field that may be blank needs a schema that accepts that result, rather than an optimistic .optional() that only accepts undefined.

Keep native attributes such as required, type="email" and sensible length limits. They give immediate feedback, while Zod remains the authoritative check for altered or direct HTTP requests.

How do you submit without client-side JavaScript?

Point a normal POST form at the generated action. Astro handles the action query string and makes the result available during the rendered POST response.

---
// src/pages/enquiry.astro
export const prerender = false;

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

const result = Astro.getActionResult(actions.sendEnquiry);
const errors = isInputError(result?.error) ? result.error.fields : {};
---

{result?.data ? (
  <p role="status">Thanks, your enquiry has been received.</p>
) : (
  <form method="POST" action={actions.sendEnquiry}>
    <label>
      Name
      <input
        name="name"
        required
        minlength="2"
        maxlength="80"
        aria-invalid={errors.name ? 'true' : undefined}
        aria-describedby={errors.name ? 'name-error' : undefined}
      />
    </label>
    {errors.name && <p id="name-error">{errors.name.join(', ')}</p>}

    <label>
      Email
      <input
        name="email"
        type="email"
        required
        aria-invalid={errors.email ? 'true' : undefined}
        aria-describedby={errors.email ? 'email-error' : undefined}
      />
    </label>
    {errors.email && <p id="email-error">{errors.email.join(', ')}</p>}

    <label>
      Message
      <textarea
        name="message"
        required
        minlength="20"
        maxlength="2000"
        aria-invalid={errors.message ? 'true' : undefined}
        aria-describedby={errors.message ? 'message-error' : undefined}
      ></textarea>
    </label>
    {errors.message && <p id="message-error">{errors.message.join(', ')}</p>}

    {result?.error && !isInputError(result.error) && (
      <p role="alert">We could not send that. Please try again.</p>
    )}

    <button type="submit">Send enquiry</button>
  </form>
)}

The page must render on demand. In Astro’s default static output, export const prerender = false opts this route out of the build-time render. The on-demand rendering guide also requires an adapter for the target runtime. With output: 'server', pages already render on demand unless you opt them into prerendering.

This boundary matters on a mostly static site. A single action form does not require turning every route into a server response. The rest of the site can remain prebuilt, much as Astro server islands keep personalised fragments away from cacheable page HTML.

What should the page do with validation errors?

Astro.getActionResult(actions.sendEnquiry) returns undefined on an ordinary visit, then a safe result with either data or error after submission. isInputError() narrows a validation failure and exposes error.fields, whose keys match the Zod object.

Put each message beside its control and connect it with aria-describedby. Reserve a general alert for handler failures. Sending an upstream service’s raw response to the browser can expose implementation detail and produces poor copy, so translate those failures into an ActionError with a stable code and a useful public message.

A plain POST response has two visible costs. Submitted values clear on failure, and refreshing can show the browser’s form-resubmission warning. Astro documents transition:persist for retaining controls when View Transitions are active. Our cross-document View Transitions guide covers that browser mechanism. For a business-critical or long form, use the POST/Redirect/GET pattern and store the result in a session, or enhance the form with actions.sendEnquiry(new FormData(form)) so the DOM remains in place.

Are actions secure by default?

Treat every action as a public endpoint. Astro exposes named routes such as /_actions/sendEnquiry, and the Actions security guidance says authorisation checks belong in the handler. Input validation only proves the data has the expected shape.

Authenticated mutations should inspect the session or context.locals inside the handler and throw ActionError({ code: 'UNAUTHORIZED' }) when needed. Public forms need rate limiting and abuse controls close to the action. Do not rely on a hidden button, an unlinked page or a client-side check.

The second handler argument is an action context with access to cookies, locals, the request and other server data. That gives the action enough information to enforce ownership, tenant boundaries and request limits where the mutation happens.

When should you keep an API route?

Use an action when the caller is your Astro application and you want typed input, a standard result, and direct form integration. The Actions API reference notes that return values use Devalue, which supports values such as Date, Map, Set and URL beyond ordinary JSON.

Keep a server endpoint when another system needs a stable HTTP contract, when you must control response headers and status bodies precisely, or when the response is a stream or file. Webhooks and public APIs should look like HTTP APIs. An action is a strong fit for account settings, basket mutations and internal admin forms.

For a new Astro form, I would start with a native POST action, a Zod object and field-level rendering through isInputError(). Add JavaScript only after the server path works. I would keep an endpoint for third-party callers or any interface whose HTTP representation is part of the product.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch