Skip to content
Astro as actions 4 min read

Actions with HTML Forms

Astro Actions can be bound directly to native HTML <form> elements, letting submissions work whether or not JavaScript has loaded. The server processes the form on the standard POST request, and once the client hydrates you get richer client-side behavior for free. This is progressive enhancement done right: the page is useful immediately, then gets better. It matters because it keeps your forms resilient on slow networks, flaky connections, and devices where scripts fail.

Why bind forms to actions

A plain action call uses fetch under the hood, so it only runs after JavaScript executes. By attaching the action to a form, Astro renders a real action and method on the <form> so the browser can submit it natively. The same server logic runs in both cases — you write the validation and business logic once, and it serves both the no-JS fallback and the hydrated experience.

To accept form data, declare your action with a accept: 'form' and an input schema. Astro coerces the FormData into typed values before your handler runs.

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

export const server = {
  subscribe: defineAction({
    accept: "form",
    input: z.object({
      email: z.string().email(),
      plan: z.enum(["free", "pro"]).default("free"),
    }),
    handler: async ({ email, plan }) => {
      await saveSubscriber(email, plan);
      return { ok: true, email };
    },
  }),
};

Set accept: "form" (not the default "json") for any action you bind to an HTML form. With "json", Astro expects a JSON body and the native form submission will fail validation.

Binding the action to a form

Pass the action to the form’s action attribute. Each action exposes special properties under the action object so Astro can wire the right URL and method automatically.

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

<form method="POST" action={actions.subscribe}>
  <label>
    Email
    <input type="email" name="email" required />
  </label>
  <fieldset>
    <label><input type="radio" name="plan" value="free" checked /> Free</label>
    <label><input type="radio" name="plan" value="pro" /> Pro</label>
  </fieldset>
  <button type="submit">Subscribe</button>
</form>

The name attributes on inputs must match the keys in your Zod schema. Astro generates the correct endpoint URL and adds the action token automatically, so this form submits without a single line of client JavaScript.

Reading the result after submission

When a form submits natively, the page re-renders. Use Astro.getActionResult() to read the outcome on the server during that render, so you can show a success message or repopulate fields.

---
import { actions } from "astro:actions";

const result = Astro.getActionResult(actions.subscribe);
---

{result && !result.error && (
  <p class="success">Subscribed {result.data.email}!</p>
)}

<form method="POST" action={actions.subscribe}>
  <input type="email" name="email" required />
  <button type="submit">Subscribe</button>
</form>

getActionResult() returns undefined when the action hasn’t run for this request, an object with a .data property on success, or one with an .error property on failure.

Reading submission state on the client

For hydrated islands you can observe in-flight submissions and show pending UI. Astro’s getActionState() helper (available in astro:actions) lets framework components read the current submission status without managing a fetch lifecycle manually.

import { getActionState } from "astro:actions";

const state = getActionState();
// state.pending === true while the form is submitting

This is most useful inside a client island that wraps the form, letting you disable the submit button or show a spinner while the request is in flight.

JSON vs form actions

Aspectaccept: "json"accept: "form"
Body formatJSONmultipart/form-data
Works without JSNoYes
Called viaactions.x({...})Native <form> + fetch
Input parsingDirect objectFormData coerced by Zod
Best forProgrammatic / island callsUser-facing forms

Form actions perform a full POST/redirect cycle without JavaScript, which means errors and results survive a hard page reload. Reach for them whenever a real user is filling out fields.

Best practices

  • Always set accept: "form" for actions bound to <form> elements, and match input name attributes to your Zod schema keys.
  • Keep validation in the action’s input schema so the same rules protect both the no-JS and hydrated paths.
  • Use Astro.getActionResult() to render success and error states server-side, ensuring feedback survives a full page reload.
  • Add native HTML constraints (required, type="email", min) as a first line of defense, then rely on Zod for authoritative validation.
  • Use getActionState() only inside hydrated islands for pending UI; never assume JavaScript is present for core functionality.
  • Return small, serializable result objects from handlers so they are cheap to read back during the post-submit render.
Last updated June 14, 2026
Was this helpful?