Actions and forms
Type-safe form handling with Astro Actions, validation, honeypots and the contact form example.
Astro Actions let you define server functions
with typed input and call them from the client with full type safety. Because actions are
always executed on the server, they work from prerendered pages too, which is how the static
/contact page submits a form.
Defining an action#
import { ActionError, defineAction } from 'astro:actions';
import { z } from 'astro/zod';
export const server = {
contact: defineAction({
accept: 'form',
input: z.object({
name: z.string().trim().min(2).max(80),
email: z.email(),
message: z.string().trim().min(10).max(2000),
website: z.string().max(200).optional(), // honeypot
}),
handler: async (input, context) => {
// honeypot, throttling, persistence, notification (src/lib/contact.ts)
return submitContactMessage(input, { ip: context.clientAddress }, deps);
},
}),
};
accept: 'form'parsesFormData, so the action works with a plain<form>as well asfetch.- Validation uses Zod 4 from
astro/zod; note the top-levelz.email()instead ofz.string().email(). - Throw
ActionErrorfor expected failures; the client receives the code and message.
Calling from a React island#
import { actions, isInputError } from 'astro:actions';
const { data, error } = await actions.contact(new FormData(form));
if (error && isInputError(error)) {
// error.fields is keyed by input name
}
The included ContactForm.tsx shows loading states, per-field error messages with
aria-describedby, and a success message.
Spam protection#
The contact form combines four inexpensive measures, all implemented in src/lib/contact.ts
and covered by src/lib/contact.test.ts:
- A honeypot field named
website, positioned off-screen (notdisplay: none, which simple bots skip). The schema accepts up to 200 characters so a filled field reaches the handler, which answers with the normal success response without storing anything. - Throttling per sender: 3 messages per hour per email address and 5 per 15 minutes per
IP, counted in the
throttletable (src/lib/throttle.ts) so limits hold across serverless instances. Keys are SHA-256 hashes, never raw addresses. Exceeding a limit returnsTOO_MANY_REQUESTS. - Server-side validation with length limits.
- Origin checks by Astro (
security.checkOrigin) so only same-origin pages can post.
For high-traffic sites add a CAPTCHA (Turnstile, hCaptcha) inside the action handler.
Persistence and notification#
The database write is the authoritative success: the message is inserted into
contact_message first, then the owner notification (CONTACT_TO_EMAIL) is attempted and its
result is recorded on the row (delivery_status: sent, failed, skipped when no recipient
is configured, plus attempts and the last error). A failed notification never fails the request,
so visitors are not pushed into resending a message that was already stored; administrators
resend the notification from the inbox. The notification’s Reply-To
header carries the visitor’s name and address, so answering it from your mail client replies to
the visitor rather than to the sending address. The success message tells visitors the message
was received and only promises a reply when one is needed.
Submissions are not idempotent: if the success response is lost on the network and the visitor submits again, a second row is stored. The throttles bound how often that can happen. If you need exactly-once behaviour, send a client-generated key with the form and add a unique index on it.
Without RESEND_API_KEY the notification is printed to the console in development and marked
failed in production (see authentication).
Progressive enhancement#
Actions can also be submitted without JavaScript by pointing a form at the action:
---
import { actions } from 'astro:actions';
---
<form method="POST" action={actions.contact}>
…
</form>
Handle the result with Astro.getActionResult(actions.contact) in a server-rendered page. The
template uses the React island for richer feedback, but both patterns are supported.