Type-safe form handling in Astro with… | LaunchFast
LaunchFast LogoLaunchFast
Blog
2,962 words15 min read

Type-safe form handling in Astro with Actions: validation, spam protection, and file uploads

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.

Rishi Raj Jain
Rishi Raj JainAuthor
Type-safe form handling in Astro with Actions

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 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:

Why Astro Actions for forms

Before Actions, handling a form in Astro meant creating an API endpoint, 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, 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:

Terminal window
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:

Terminal window
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.

Terminal window
npx astro add cloudflare

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

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:

Terminal window
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:

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):

.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:

.env
PUBLIC_TURNSTILE_SITE_KEY="1x00000000000000000000AA"

Finally, create the waitlist table:

-- schema.sql
CREATE TABLE IF NOT EXISTS waitlist (
email TEXT PRIMARY KEY,
created_at TEXT NOT NULL
);
Terminal window
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:

Terminal window
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:

Terminal window
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:

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 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:

/**
* 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:

// 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:

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.

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.

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.

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 ActionErrors (show the message).

Run it locally

Start the dev server:

Terminal window
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, 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:

Terminal window
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, point CONTACT_FROM_EMAIL at a verified Resend domain, and your forms are running at the edge.

References

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.

If you have any questions or comments, feel free to reach out to me on Twitter.

Continue reading