Skip to content
Strata

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' parses FormData, so the action works with a plain <form> as well as fetch.
  • Validation uses Zod 4 from astro/zod; note the top-level z.email() instead of z.string().email().
  • Throw ActionError for 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:

  1. A honeypot field named website, positioned off-screen (not display: 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.
  2. Throttling per sender: 3 messages per hour per email address and 5 per 15 minutes per IP, counted in the throttle table (src/lib/throttle.ts) so limits hold across serverless instances. Keys are SHA-256 hashes, never raw addresses. Exceeding a limit returns TOO_MANY_REQUESTS.
  3. Server-side validation with length limits.
  4. 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.