Skip to content

Forms with Astro Actions

Build validated forms in Astro with Actions and bejamas/ui Field primitives.

In this guide, we will build a contact form with Astro Actions and bejamas/ui Field primitives.

You will use:

  • Astro Actions for server-side validation and mutation.
  • Standard HTML forms for progressive enhancement.
  • Field primitives (Field, FieldSet, FieldError, etc.) for consistent UI.
  • Astro v5+ project.
  • @bejamas/ui components installed.
  • zod (already present in Astro).

These demos are interactive and call real Astro Actions from this docs page.

Inline Validation Demo

Submit empty values to trigger server-validated errors from Astro Actions.

Submit with empty fields first, then update values and submit again.

Validation Summary Demo

Test valid and invalid payloads quickly, including a top-level error summary.

Use the quick-fill buttons to test both invalid and valid payloads fast.

Create src/actions/index.ts:

src/actions/index.ts
import { defineAction } from "astro:actions";
import { z } from "astro:schema";
const contactInput = z.object({
name: z.string().trim().min(2, "Name must be at least 2 characters."),
email: z.string().trim().email("Enter a valid email address."),
message: z.string().trim().min(10, "Message must be at least 10 characters."),
});
export const server = {
contactServer: defineAction({
accept: "form",
input: contactInput,
handler: async (input) => {
return {
message: `Thanks ${input.name}, your message has been received.`,
};
},
}),
contactClient: defineAction({
accept: "form",
input: contactInput,
handler: async (input) => {
return {
message: `Submitted from client script for ${input.name}.`,
};
},
}),
};

Use a standard form with method="POST" and action={actions.contactServer}. Then read submission results on render with Astro.getActionResult().

src/pages/contact.astro
---
export const prerender = false;
import { actions, isInputError } from "astro:actions";
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@bejamas/ui/components/field";
import { Input } from "@bejamas/ui/components/input";
import { Spinner } from "@bejamas/ui/components/spinner";
import { Textarea } from "@bejamas/ui/components/textarea";
import { Button } from "@bejamas/ui/components/button";
const result = Astro.getActionResult(actions.contactServer);
const fields =
result?.error && isInputError(result.error) ? result.error.fields : {};
---
<form method="POST" action={actions.contactServer} class="w-full max-w-xl">
<FieldSet>
<FieldLegend>Contact us</FieldLegend>
<FieldGroup>
<Field>
<FieldLabel for="name">Name</FieldLabel>
<Input id="name" name="name" />
<FieldError errors={fields.name?.map((message) => ({ message }))} />
</Field>
<Field>
<FieldLabel for="email">Email</FieldLabel>
<Input id="email" name="email" type="email" />
<FieldError errors={fields.email?.map((message) => ({ message }))} />
</Field>
<Field>
<FieldLabel for="message">Message</FieldLabel>
<Textarea id="message" name="message" rows={4} />
<FieldError errors={fields.message?.map((message) => ({ message }))} />
</Field>
<Button type="submit" class="w-fit" data-submit-button>
<Spinner data-icon class="hidden" />
<span data-submit-label>Send</span>
</Button>
<Button type="reset" variant="outline" class="w-fit" data-reset-button>
Reset
</Button>
</FieldGroup>
</FieldSet>
</form>
{result?.data && <p>{result.data.message}</p>}

FieldError accepts an array shape compatible with action field messages:

<FieldError errors={fields.email?.map((message) => ({ message }))} />

If multiple unique messages are present, FieldError renders a list automatically.

For client-script forms in Astro, mount FieldError once and toggle visibility:

<FieldError forceMount class="hidden" data-error-for="email" />

Then set text and remove hidden when validation fails.

Use Astro.callAction() when invoking an action from a page endpoint-like flow.

server call example
import { actions } from "astro:actions";
const formData = new FormData();
formData.set("name", "Ada Lovelace");
formData.set("email", "ada@example.com");
formData.set("message", "Interested in your Astro UI system.");
const result = await Astro.callAction(actions.contactServer, formData);
  • Action POST flows require on-demand rendering routes.
  • For pages that handle form submissions, set:
export const prerender = false;
  • Astro performs request-origin checks for Action requests.
PatternBest forProsTradeoff
Native POST + Astro.getActionResult()Content sites, progressive enhancementWorks without JS, straightforward SSR error renderingFull-page navigation cycle
Client script + actions.*(formData)App-like interactionsNo page reload, granular UX controlRequires client JS and manual UI state handling