---
title: "Type-safe form handling in Astro with Actions: validation, spam protection, and file uploads"
description: "Build production-ready contact, waitlist, and file upload forms in Astro using Actions: server-side Zod validation, Turnstile anti-bot protection, Cloudflare D1 and R2, and Resend, all without standing up a separate backend."
source: "https://www.launchfa.st/blog/astro-actions-forms"
author: "Rishi Raj Jain"
created_at: 2026-07-27T00:00:00.000Z
updated_at: 2026-07-27T00:00:00.000Z
---

> **Site index for agents**
> Fetch https://www.launchfa.st/llms.txt to discover every page on this site.
> Every page is also available as Markdown by appending `.md` to its URL.

<img width="1600" height="900" alt="Type-safe form handling in Astro with Actions" decoding="async" loading="eager" class="mt-4 border rounded-sm bg-cover bg-center bg-no-repeat transform will-change-auto" src="https://ik.imagekit.io/vjeqenuhn/launchfast-website/astro-actions-forms.png" />

Almost every site needs a form (a contact box, a waitlist signup, a file upload), and then a form starts growing into a backend. You start with an HTML `<form>`, then you need server-side validation, then spam protection, then somewhere to store the data, then a transactional email.

[Astro Actions](https://docs.astro.build/en/guides/actions/) enable you to create a type-safe server function you call straight from the browser like a local function. Astro handles serialization, runs Zod validation on the server, and hands you typed results and typed errors. In this guide you'll build three forms with Actions: a contact form, a waitlist, and a file upload, all running on the Cloudflare Workers runtime with Turnstile, Cloudflare D1, Cloudflare R2, and Resend.

## Prerequisites

You'll need the following:

- [Node.js 22](https://nodejs.org/en/blog/announcements/v22-release-announce) or later
- A [Cloudflare account](https://dash.cloudflare.com/sign-up) (D1, R2, Turnstile, all with free tiers)
- A [Resend](https://resend.com) account for sending email

## Why Astro Actions for forms

Before Actions, handling a form in Astro meant creating an [API endpoint](https://docs.astro.build/en/guides/endpoints/), parsing `FormData` by hand, validating it manually, and duplicating types between client and server. Actions replace all of that with a single definition that is:

- **Type-safe end to end**: The input schema and the return value are inferred on both sides. If you change the schema, the client call fails to type-check.
- **Validated on the server**: Input is parsed with Zod (imported from `astro/zod`) before your handler runs, so bad input never reaches your logic.
- **Callable directly**: From a `<script>` you call `actions.contact(formData)` and get back `{ data, error }`. Field-level validation errors come back structured, so you can show them inline.
- **Progressive-enhancement friendly**: Actions accept native `FormData`, so the same handler works whether you submit with JavaScript or fall back to a plain HTML form post.

The appeal of using it is in ergonomics of calling a function, with the safety of a real backend.

## Set up Cloudflare Turnstile

Turnstile is Cloudflare's free, privacy-first CAPTCHA alternative that keeps bots and spam out of your forms without making real users solve puzzles.

Set it up before writing any code, because you'll need its keys shortly:

1. In the [Cloudflare dashboard](https://dash.cloudflare.com), open **Application Security > Turnstile** and select **Add widget manually**.
2. Give it a name and add your domains, including `localhost` for local development.
3. Leave the mode on **Managed**, which lets Cloudflare decide when to challenge a visitor.
4. Copy the two keys it generates: the **Site Key** (public, used in the browser) and the **Secret Key** (private, used for server-side verification).

Keep both handy for the environment files later.

## Create a new Astro application

Let's get started by creating a new Astro project. Execute the following command:

```bash
npm create astro@latest my-astro-forms-app
```

`npm create astro` is the recommended way to scaffold an Astro project quickly.

When prompted, choose:

- `Empty` when prompted on how to start the new project.
- `Yes` when prompted if you plan to write TypeScript.
- `Strict` when prompted how strict TypeScript should be.
- `Yes` when prompted to install dependencies.
- `Yes` when prompted to initialize a git repository.

Then move into the project directory:

```bash
cd my-astro-forms-app
```

## Add the Cloudflare adapter for SSR

Actions render on demand, so the project needs the Cloudflare adapter. It also gives us D1, R2, and Turnstile in the same Workers runtime.

```bash
npx astro add cloudflare
```

That wires up `astro.config.mjs`. This example keeps it minimal:

```js
// astro.config.mjs
import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'

export default defineConfig({
  adapter: cloudflare({
    imageService: 'passthrough',
  }),
})
```

## Bind Cloudflare D1 and R2

Create the storage the forms need: a D1 database for the waitlist and an R2 bucket for uploads:

```bash
npx wrangler d1 create forms
npx wrangler r2 bucket create forms-uploads
```

`wrangler d1 create` and `wrangler r2 bucket create` add the bindings (and the `database_id`) to `wrangler.jsonc` for you, creating the file if it doesn't exist. The result looks like this:

```jsonc
// wrangler.jsonc
{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "my-astro-forms-app",
  "compatibility_date": "2026-07-27",
  "compatibility_flags": ["global_fetch_strictly_public"],
  "main": "@astrojs/cloudflare/entrypoints/server",
  "assets": { "directory": "./dist", "binding": "ASSETS" },
  "observability": { "enabled": true },
  "d1_databases": [
    {
      "binding": "forms",
      "database_name": "forms",
      "database_id": "<your-d1-database-id>",
      "remote": true
    }
  ],
  "r2_buckets": [
    {
      "bucket_name": "forms-uploads",
      "binding": "forms_uploads",
      "remote": true
    }
  ]
}
```

The `remote: true` flag tells Wrangler to use your real D1 and R2 even during `astro dev`, so run `npx wrangler login` once before starting.

Secrets stay out of `wrangler.jsonc`. Put them in `.dev.vars` for local development (Wrangler loads it automatically during `astro dev`):

```bash
# .dev.vars
RESEND_API_KEY="re_..."
CONTACT_FROM_EMAIL="Acme <onboarding@resend.dev>"
# Turnstile "always passes" test secret (swap for your real secret in production).
TURNSTILE_SECRET_KEY="1x0000000000000000000000000000000AA"
```

And put the public Turnstile site key in `.env` so it can be inlined into the client:

```bash
# .env
PUBLIC_TURNSTILE_SITE_KEY="1x00000000000000000000AA"
```

Finally, create the `waitlist` table:

```sql
-- schema.sql
CREATE TABLE IF NOT EXISTS waitlist (
  email TEXT PRIMARY KEY,
  created_at TEXT NOT NULL
);
```

```bash
npx wrangler d1 execute forms --remote --file=./schema.sql
```

## Type the runtime environment

Actions read bindings and secrets from the `cloudflare:workers` module. Generate types for them straight from your `wrangler.jsonc` so `env` is fully typed:

```bash
npx wrangler types
```

That writes a `worker-configuration.d.ts` describing your `forms` and `forms_uploads` bindings alongside the secrets. Now install Resend and you're ready to write actions:

```bash
npm install resend
```

## Build a type-safe contact form

Actions are defined in `src/actions/index.ts` and exported from a single `server` object. Let's start with the contact form.

### Define the contact action

Each action declares how it accepts input (`'form'` for `FormData`), an `input` Zod schema, and a `handler`. The schema is the validation: anything that doesn't match never reaches your handler, and the error is returned to the client as structured field errors. Bindings and secrets come from the `cloudflare:workers` `env`, and the Resend client is created once at module scope:

```ts
// src/actions/index.ts
import { ActionError, defineAction } from 'astro:actions'
import { z } from 'astro/zod'
import { Resend } from 'resend'
import { env } from 'cloudflare:workers'

const resend = new Resend(env.RESEND_API_KEY)

export const server = {
  contact: defineAction({
    accept: 'form',
    input: z.object({
      name: z.string().trim().min(1, 'Please enter your name.'),
      email: z.string().trim().email('Enter a valid email address.'),
      message: z.string().trim().min(10, 'Your message should be at least 10 characters.'),
      // Injected by the Turnstile widget as a hidden field.
      'cf-turnstile-response': z.string().min(1, 'Please complete the anti-bot check.'),
    }),
    handler: async (input, context) => {
      // ...verify Turnstile, then send the email (below)
      return { ok: true }
    },
  }),
}
```

### Block bots with Turnstile

A public contact form is a spam magnet. [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gives you a privacy-friendly CAPTCHA alternative. The widget adds a `cf-turnstile-response` token to the form, and you verify it on the server. Add a small helper and call it first in the handler:

```ts
/**
 * Verify a Cloudflare Turnstile token server-side.
 * Docs: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
 */
async function verifyTurnstile(token: string, secret: string, ip?: string) {
  const body = new FormData()
  body.append('secret', secret)
  body.append('response', token)
  if (ip) body.append('remoteip', ip)

  const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
    method: 'POST',
    body,
  })
  const data = (await res.json()) as { success: boolean }
  return data.success
}
```

`ActionError` lets you fail with a proper HTTP-style code that the client can distinguish from a validation error:

```ts
// inside the contact handler:
const ip = context.request.headers.get('CF-Connecting-IP') ?? undefined

const passed = await verifyTurnstile(input['cf-turnstile-response'], env.TURNSTILE_SECRET_KEY, ip)
if (!passed) {
  throw new ActionError({
    code: 'FORBIDDEN',
    message: 'Anti-bot verification failed. Please try again.',
  })
}
// ...send email
```

### Send the email with Resend

With the request validated and verified, deliver it. The Resend SDK runs fine on the Workers runtime:

```ts
const { error } = await resend.emails.send({
  from: env.CONTACT_FROM_EMAIL,
  to: input.email,
  subject: `New contact message from ${input.name}`,
  text: `From: ${input.name} <${input.email}>\n\n${input.message}`,
})
if (error) {
  throw new ActionError({
    code: 'INTERNAL_SERVER_ERROR',
    message: 'Could not send your message. Please try again later.',
  })
}

return { ok: true }
```

Here the confirmation goes to the address from the form (`input.email`). To route contact messages to your own inbox instead, send `to` your address and set `replyTo: input.email`.

## Add a waitlist backed by Cloudflare D1

The waitlist stores emails in D1 through the `env.forms` binding. The interesting detail is idempotency: since `email` is the primary key, a repeat signup raises a `UNIQUE` constraint error, which we treat as success.

```ts
joinWaitlist: defineAction({
  accept: 'form',
  input: z.object({
    email: z.string().trim().toLowerCase().email('Enter a valid email address.'),
  }),
  handler: async (input, context) => {
    try {
      await env.forms
        .prepare('INSERT INTO waitlist (email, created_at) VALUES (?, ?)')
        .bind(input.email, new Date().toISOString())
        .run()
    } catch (err) {
      // A UNIQUE violation just means they already signed up, so treat it as success.
      if (!String(err).includes('UNIQUE')) {
        throw new ActionError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Could not join the waitlist. Please try again.',
        })
      }
    }

    await resend.emails.send({
      from: env.CONTACT_FROM_EMAIL,
      to: input.email,
      subject: "You're on the waitlist 🎉",
      text: "Thanks for joining! We'll email you the moment we launch.",
    })

    return { ok: true, email: input.email }
  },
}),
```

Note `z.string().trim().toLowerCase()`. Zod normalizes the input as part of validation, so `Ada@Example.com ` and `ada@example.com` don't create two rows.

## Handle file uploads to Cloudflare R2

With hand-rolled uploads, you have to parse multipart bodies, validate the file, and stream it to storage. With Actions, the file arrives as a standard `File`, and you validate it with the same Zod schema style you use for everything else. The R2 bucket is the `env.forms_uploads` binding.

```ts
upload: defineAction({
  accept: 'form',
  input: z.object({
    file: z
      .instanceof(File, { message: 'Please choose a file to upload.' })
      .refine((f) => f.size > 0, 'The selected file is empty.')
      .refine((f) => f.size <= 5 * 1024 * 1024, 'File must be 5 MB or smaller.')
      .refine(
        (f) => ['image/png', 'image/jpeg', 'application/pdf'].includes(f.type),
        'Only PNG, JPEG, or PDF files are allowed.',
      ),
  }),
  handler: async (input, context) => {
    const ext = input.file.name.split('.').pop()?.toLowerCase() ?? 'bin'
    const key = `uploads/${crypto.randomUUID()}.${ext}`

    await env.forms_uploads.put(key, await input.file.arrayBuffer(), {
      httpMetadata: { contentType: input.file.type },
    })

    return { ok: true, key, size: input.file.size }
  },
}),
```

Validating type and size *before* touching R2 means you reject a 4 GB upload or a disguised executable at the edge of your handler.

## Wire up the forms on the client

Now the frontend. Load the Turnstile script, render the three forms, and call the actions from a `<script>`. The client import `astro:actions` gives you a typed `actions` object and an `isInputError` helper to pull out field-level validation messages.

```astro
---
// src/pages/index.astro
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY
---

<form id="contact">
  <h2>Contact</h2>
  <label>Name <input name="name" required /></label>
  <label>Email <input name="email" type="email" required /></label>
  <label>Message <textarea name="message" rows="4" required></textarea></label>
  <div class="cf-turnstile" data-sitekey={siteKey}></div>
  <button type="submit">Send message</button>
  <p class="status" role="status"></p>
</form>

<form id="waitlist">
  <h2>Join the waitlist</h2>
  <label>Email <input name="email" type="email" required /></label>
  <button type="submit">Join</button>
  <p class="status" role="status"></p>
</form>

<form id="upload">
  <h2>Upload a file</h2>
  <label>File (PNG, JPEG, or PDF, max 5 MB) <input name="file" type="file" required /></label>
  <button type="submit">Upload</button>
  <p class="status" role="status"></p>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" />

<script>
  import { actions, isInputError } from 'astro:actions'

  const forms: Record<string, (data: FormData) => Promise<{ data?: { ok: boolean }; error?: any }>> = {
    contact: actions.contact,
    waitlist: actions.joinWaitlist,
    upload: actions.upload,
  }

  for (const [id, action] of Object.entries(forms)) {
    const form = document.getElementById(id) as HTMLFormElement | null
    if (!form) continue
    const status = form.querySelector('.status') as HTMLParagraphElement

    form.addEventListener('submit', async (event) => {
      event.preventDefault()
      status.textContent = 'Submitting…'

      const { data, error } = await action(new FormData(form))

      if (error) {
        // Field-level validation errors come back typed from the Zod schema.
        const message = isInputError(error)
          ? Object.values(error.fields).flat()[0] ?? 'Please check the form and try again.'
          : error.message
        status.textContent = message
        return
      }

      status.textContent = data?.ok ? 'Done ✅' : 'Submitted.'
      form.reset()
      // @ts-expect-error global injected by the Turnstile script
      window.turnstile?.reset?.()
    })
  }
</script>
```

The key line is `await action(new FormData(form))`. There's no `fetch` call and no endpoint to write. You call the action with the form's data and get back a typed `{ data, error }`. `isInputError` distinguishes Zod validation failures (show inline) from thrown `ActionError`s (show the message).

## Run it locally

Start the dev server:

```bash
npm run dev
```

Because the bindings are marked `remote: true`, dev talks to your real D1 and R2 (make sure you have run `npx wrangler login`). Open [localhost:4321](http://localhost:4321), submit the contact form (the Turnstile test key passes automatically), join the waitlist, and upload a file.

## Deploy to Cloudflare

`wrangler.jsonc` already points `main` at the adapter's server entrypoint and `assets` at `./dist`, so deploying is a build followed by `wrangler deploy`. Apply the schema and push your secrets first:

```bash
npx wrangler d1 execute forms --remote --file=./schema.sql
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put TURNSTILE_SECRET_KEY
npx wrangler secret put CONTACT_FROM_EMAIL

npm run build && npx wrangler deploy
```

Swap the Turnstile test keys for your real [site and secret keys](https://developers.cloudflare.com/turnstile/get-started/), point `CONTACT_FROM_EMAIL` at a [verified Resend domain](https://resend.com/docs/dashboard/domains/introduction), and your forms are running at the edge.

## References

- [GitHub Repository](https://github.com/rishi-raj-jain/my-astro-forms-app)
- [Astro Actions documentation](https://docs.astro.build/en/guides/actions/)
- [Cloudflare Turnstile server-side validation](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/)
- [Resend Node.js SDK](https://resend.com/docs/send-with-nodejs)

## Conclusion

In this guide you built three production-shaped forms in Astro (contact, waitlist, and file upload) using Actions for type-safe, server-validated logic, with Turnstile for spam protection, Cloudflare D1 and R2 for storage, and Resend for email. The pattern scales: any form you add later is just another entry in the `server` object, validated by Zod and callable directly from the client, with no separate API to build or maintain.

If you'd rather start from a batteries-included SaaS foundation, with auth, billing, storage, and email already wired up on Astro and Cloudflare, take a look at [LaunchFast](https://www.launchfa.st).

If you have any questions or comments, feel free to reach out to me on [Twitter](https://twitter.com/direct_messages/create/rishi_raj_jain_).
