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
| Aspect | accept: "json" | accept: "form" |
|---|---|---|
| Body format | JSON | multipart/form-data |
| Works without JS | No | Yes |
| Called via | actions.x({...}) | Native <form> + fetch |
| Input parsing | Direct object | FormData coerced by Zod |
| Best for | Programmatic / island calls | User-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 inputnameattributes to your Zod schema keys. - Keep validation in the action’s
inputschema 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.