Custom form handling with Cloudflare Workers and D1
Contact forms are essential for most websites. Beyond giving visitors an easy way to get in touch, a good contact form helps protect against spam and qualify leads.
I recently started using Cloudflare Workers for hosting my Astro and Next.js websites. When rebuilding my portfolio site with Astro, I decided to roll my own solution for handling the contact form submissions.
To store the form submissions, I decided to use D1, Cloudflare’s managed SQL database. I also created a simple dashboard for viewing the submissions, protected by Cloudflare Access, and set up push notifications to my phone via Pushover so I know right away when a new submission comes in.
The shape of the solution
At a high level, there are three pieces:
- The contact form on my Astro website. Submissions are handled by an Astro action, which validates the form fields and saves the data to a database.
- A D1 database for storing the submissions.
- A dashboard behind Cloudflare Access for viewing submissions.
I decided to create a new worker for the dashboard, to keep things separated from my Astro site. This means that my repository contains two folders at the top level: /dashboard and /site, and each worker runs with a separate dev command. However, I decided to place the code for saving a form submission to the database within the Astro codebase (/site). Placing this in a separate worker and calling it via a service binding would also work well for this, if you’d like to keep concerns completely separated.
Setting up the project
Start by scaffolding the two workers and a pnpm workspace:
mkdir cf-contact-form
cd cf-contact-form
git init
pnpm create cloudflare@latest dashboard --type hello-world --lang ts
pnpm create cloudflare@latest site --framework=astro
Create a pnpm-workspace.yaml at the root:
packages:
- 'dashboard'
- 'site'
Then create the D1 database:
pnpm --filter site exec wrangler d1 create contact-form-db
This will output a database_id — you’ll need it for the wrangler configuration in both workers.
The database
The database itself is tiny — a single messages table. Each row is one submission. Create a schema.sql file at the root of the project:
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL,
message VARCHAR(2000) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
The id and created_at fields are populated automatically when inserting a row, which keeps things simple.
Configuring the workers
Both workers need a D1 database binding in their wrangler.jsonc. For the site worker at site/wrangler.jsonc:
{
"name": "cf-contact-form",
"main": "@astrojs/cloudflare/entrypoints/server",
"compatibility_date": "2026-04-12",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"enabled": true
},
"d1_databases": [
{
"binding": "DB",
"database_name": "contact-form-db",
"database_id": "<YOUR_DATABASE_ID>"
}
]
}
And for the dashboard worker at dashboard/wrangler.jsonc:
{
"name": "dashboard",
"main": "src/index.ts",
"compatibility_date": "2026-04-12",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"enabled": true
},
"d1_databases": [
{
"binding": "DB",
"database_name": "contact-form-db",
"database_id": "<YOUR_DATABASE_ID>"
}
]
}
Replace <YOUR_DATABASE_ID> with the ID from the d1 create command, then generate the TypeScript types for both workers:
pnpm --filter site exec wrangler types
pnpm --filter dashboard exec wrangler types
Because both workers share the same D1 database, the dashboard can read the rows that the website worker inserted without any network calls or APIs in between. One caveat is when running the dev server, I had to configure both workers to use the same local directory for the local D1 server.
To achieve this, add the persistState option to the Cloudflare adapter in site/astro.config.mjs:
import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'
export default defineConfig({
output: 'server',
adapter: cloudflare({
persistState: {
path: '../.wrangler/state',
},
}),
})
And add the --persist-to flag to the dashboard’s dev script in dashboard/package.json:
{
"scripts": {
"dev": "wrangler dev --port 8787 --persist-to ../.wrangler/state"
}
}
Both resolve to the same .wrangler/state directory at the project root.
After configuring the shared state, apply the schema to both remote and local databases:
pnpm --filter site exec wrangler d1 execute contact-form-db --file=../schema.sql --remote
pnpm --filter dashboard exec wrangler d1 execute contact-form-db --file=../schema.sql --local --persist-to=../.wrangler/state
Handling the submission
On the website side, the form itself is a regular HTML form on the contact page. When the user hits submit, the form data gets sent to an Astro action. Actions are Astro’s way of writing type-safe server functions — you define the inputs you expect, and Astro validates them for you before your code ever runs. If any field is missing or too long, the action returns an error and the form shows it next to the relevant field.
Here’s the full action at site/src/actions/index.ts:
import { defineAction, ActionError } from 'astro:actions'
import { z } from 'astro/zod'
import { env } from 'cloudflare:workers'
function isValidId(id: unknown): id is number {
return typeof id === 'number' && Number.isInteger(id)
}
export const server = {
contactForm: defineAction({
accept: 'form',
input: z.object({
firstName: z
.string({ message: 'First name is required' })
.min(1, 'First name is required')
.max(50, 'First name must be 50 characters or less'),
lastName: z
.string({ message: 'Last name is required' })
.min(1, 'Last name is required')
.max(50, 'Last name must be 50 characters or less'),
emailAddress: z
.email({ message: 'A valid email is required' })
.max(255, 'Email must be 255 characters or less'),
message: z
.string({ message: 'Message is required' })
.min(1, 'Message is required')
.max(2000, 'Message must be 2000 characters or less'),
}),
handler: async (input) => {
if (!env.DB) {
console.error('DB binding is undefined')
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to send your message. Please try again later.',
})
}
const query = `
INSERT INTO messages (first_name, last_name, email, message)
VALUES (?, ?, ?, ?)
RETURNING id`
let insertedId: number | null = null
try {
const { success, results } = await env.DB.prepare(query)
.bind(
input.firstName,
input.lastName,
input.emailAddress,
input.message
)
.run()
const resultId = results?.[0]?.id
if (!success || !isValidId(resultId)) {
console.error('DB insert failed')
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to send your message. Please try again later.',
})
}
insertedId = resultId
} catch (error) {
if (error instanceof ActionError) throw error
if (error instanceof Error) {
console.error('Unknown error during DB insert', error.message)
}
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to send your message. Please try again later.',
})
}
return { message: "Thanks! I'll get back to you soon." }
},
}),
}
The RETURNING id part gives me the ID of the newly inserted row without needing to make a separate query. I use this later to include a link to the submission in the Pushover notification.
If the insert fails for any reason, the action returns a friendly error message and the form shows it to the user. If it succeeds, the form is replaced with a small thank you message.
The contact form page
The form uses Astro’s built-in support for actions with form submissions. By setting the form’s action attribute to actions.contactForm and using method="POST", the form works with progressive enhancement — no client-side JavaScript required. Validation errors are rendered server-side using Astro.getActionResult() and isInputError().
Here’s the full page at site/src/pages/index.astro:
---
export const prerender = false
import { actions, isInputError } from 'astro:actions'
const result = Astro.getActionResult(actions.contactForm)
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Contact Form</title>
</head>
<body>
<h1>Contact</h1>
{result && !result.error && <p>{result.data.message}</p>}
{
result?.error && !isInputError(result.error) && (
<p>{result.error.message}</p>
)
}
{
!(result && !result.error) && (
<form method="POST" action={actions.contactForm}>
<div>
<label for="firstName">First name</label>
<input
id="firstName"
name="firstName"
type="text"
required
maxlength={50}
aria-invalid={Boolean(inputErrors.firstName)}
aria-errormessage="firstName-error"
/>
<p id="firstName-error">{inputErrors.firstName?.join(', ')}</p>
</div>
<div>
<label for="lastName">Last name</label>
<input
id="lastName"
name="lastName"
type="text"
required
maxlength={50}
aria-invalid={Boolean(inputErrors.lastName)}
aria-errormessage="lastName-error"
/>
<p id="lastName-error">{inputErrors.lastName?.join(', ')}</p>
</div>
<div>
<label for="emailAddress">Email</label>
<input
id="emailAddress"
name="emailAddress"
type="email"
required
maxlength={255}
aria-invalid={Boolean(inputErrors.emailAddress)}
aria-errormessage="emailAddress-error"
/>
<p id="emailAddress-error">
{inputErrors.emailAddress?.join(', ')}
</p>
</div>
<div>
<label for="message">Message</label>
<textarea
id="message"
name="message"
rows={6}
required
maxlength={2000}
aria-invalid={Boolean(inputErrors.message)}
aria-errormessage="message-error"
/>
<p id="message-error">{inputErrors.message?.join(', ')}</p>
</div>
<button type="submit">Submit</button>
</form>
)
}
</body>
</html>
There are three states to handle:
- Success —
resultexists with no error, so we show the thank-you message and hide the form. - Server error —
result.errorexists but is not an input error (e.g. the database is down), so we show the error message above the form. - Validation errors —
isInputError(result.error)is true, so we show per-field error messages next to each invalid field.
Getting notified
As well as storing the entry in the database, I send out a notification with Pushover. The notification includes the sender’s name and email, plus a direct link to the message in the dashboard.
Pushover is a cheap service for sending notifications to various devices. They provide a REST API for sending notifications. Add the following to the action handler, after the successful database insert:
if (env.PUSHOVER_APP_TOKEN && env.PUSHOVER_USER_KEY) {
const messageData = {
token: env.PUSHOVER_APP_TOKEN,
user: env.PUSHOVER_USER_KEY,
title: 'New contact form submission',
message: `From ${input.firstName} ${input.lastName} (${input.emailAddress})`,
url: `https://dashboard.example.com/${insertedId}`,
url_title: 'View message',
}
try {
const response = await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(messageData),
})
if (!response.ok) {
const data = await response.json()
console.error('Failed to send Pushover notification:', data)
}
} catch (error) {
console.error('Error sending Pushover notification:', error)
}
}
This requires setting up two environment variables: your Pushover app token and user key. The guard clause means the notification is optional — if the secrets aren’t configured, it’s silently skipped.
For local development, create a site/.dev.vars file:
PUSHOVER_APP_TOKEN=your_token_here
PUSHOVER_USER_KEY=your_key_here
For production, set these as secrets with wrangler secret put.
The dashboard
The dashboard is a separate Cloudflare Worker that reads from the same D1 database. It has two pages: a list of all messages, and a detail page for each one.
Here’s the full worker at dashboard/src/index.ts:
export default {
async fetch(request, env, ctx): Promise<Response> {
const url = new URL(request.url)
const path = url.pathname
if (path === '/') {
const { results } = await env.DB.prepare(
'SELECT id, first_name, last_name, email, created_at FROM messages ORDER BY id DESC'
).all()
const rows = results
.map(
(row) =>
`<tr>
<td>${row.id}</td>
<td>${row.first_name} ${row.last_name}</td>
<td>${row.email}</td>
<td>${row.created_at}</td>
<td><a href="/${row.id}">View</a></td>
</tr>`
)
.join('')
return new Response(
`<html>
<head><title>Messages</title></head>
<body>
<h1>Messages</h1>
<table>
<tr><th>ID</th><th>Name</th><th>Email</th><th>Date</th><th></th></tr>
${rows}
</table>
</body>
</html>`,
{ headers: { 'Content-Type': 'text/html' } }
)
}
const idMatch = path.match(/^\/(\d+)$/)
if (idMatch) {
const message = await env.DB.prepare(
'SELECT * FROM messages WHERE id = ?'
)
.bind(Number(idMatch[1]))
.first()
if (!message) {
return new Response('Not found', { status: 404 })
}
return new Response(
`<html>
<head><title>Message #${message.id}</title></head>
<body>
<h1>Message #${message.id}</h1>
<p><strong>From:</strong> ${message.first_name} ${message.last_name}</p>
<p><strong>Email:</strong> ${message.email}</p>
<p><strong>Date:</strong> ${message.created_at}</p>
<p><strong>Message:</strong></p>
<p>${message.message}</p>
<a href="/">Back to list</a>
</body>
</html>`,
{ headers: { 'Content-Type': 'text/html' } }
)
}
return new Response('Not found', { status: 404 })
},
} satisfies ExportedHandler<Env>
Then run both dev servers:
pnpm --filter site dev
pnpm --filter dashboard dev
Securing the dashboard
The dashboard is obviously not something I want anyone on the internet to be able to access. Rather than building my own login system, I put the whole worker behind Cloudflare Access, which is part of Cloudflare One.
The way Cloudflare Access works is: you define a policy that says “only these email addresses can reach this URL.” When someone visits the dashboard, Cloudflare intercepts the request, makes them sign in, and only forwards the request to my worker if they’re on the allow-list. When it does forward the request, it attaches a signed JWT (a kind of cryptographic stamp) to the headers.
On the worker side, I verify that JWT on every request using Cloudflare’s public keys. If it’s missing or invalid, the worker returns a 403. This gives me a belt-and-braces setup: Cloudflare Access is the front door, and the JWT check inside the worker is a second lock in case anyone ever manages to hit the worker directly.
To implement this, first install the jose library in the dashboard worker:
pnpm --filter dashboard add jose
Then add two environment variables to dashboard/wrangler.jsonc — your Cloudflare Access team domain and the policy audience tag (you’ll find these in the Zero Trust dashboard under Access > Applications):
{
"vars": {
"TEAM_DOMAIN": "https://your-team.cloudflareaccess.com",
"POLICY_AUD": "your-policy-audience-tag"
}
}
Now update dashboard/src/index.ts to verify the JWT before handling any request. The createRemoteJWKSet function fetches Cloudflare’s public keys, and jwtVerify validates the token’s signature, issuer, and audience in one call:
import { jwtVerify, createRemoteJWKSet } from 'jose'
export default {
async fetch(request, env, ctx): Promise<Response> {
if (!env.POLICY_AUD) {
return new Response('Missing required audience', { status: 403 })
}
const token = request.headers.get('cf-access-jwt-assertion')
if (!token) {
return new Response('Missing required CF Access JWT', { status: 403 })
}
try {
const JWKS = createRemoteJWKSet(
new URL(`${env.TEAM_DOMAIN}/cdn-cgi/access/certs`)
)
await jwtVerify(token, JWKS, {
issuer: env.TEAM_DOMAIN,
audience: env.POLICY_AUD,
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return new Response(`Invalid token: ${message}`, { status: 403 })
}
// Token is valid — handle the request
const url = new URL(request.url)
const path = url.pathname
// ... rest of the request handling
},
} satisfies ExportedHandler<Env>
The JWT verification runs before any database queries or HTML rendering. If the token is missing, expired, or signed by the wrong key, the worker returns a 403 immediately and never touches the database.
The dev server tunnel
In production, the dashboard worker is deployed to a custom domain and Cloudflare Access handles authentication automatically. During local development, however, the dashboard runs on localhost — which Cloudflare Access can’t reach. To test the full flow locally, I use cloudflared to create a tunnel that exposes my local dev server to a public hostname, which Cloudflare Access can then protect.
First, authenticate cloudflared and create a tunnel:
cloudflared tunnel login
cloudflared tunnel create devserver
Then create the cloudflared configuration file at ~/.cloudflared/config.yml (replace <Tunnel-UUID> with the UUID from the command above):
tunnel: <Tunnel-UUID>
credentials-file: ~/.cloudflared/<Tunnel-UUID>.json
ingress:
- hostname: dashboard-tunnel.example.com
service: http://localhost:8787
- service: http_status:404
warp-routing:
enabled: true
The service port must match the port the dashboard dev server runs on. Make sure the --port flag in dashboard/package.json matches:
{
"scripts": {
"dev": "wrangler dev --port 8787 --persist-to ../.wrangler/state"
}
}
From the Cloudflare dashboard, create a CNAME record for the hostname in the config file, with the value <Tunnel-UUID>.cfargotunnel.com.
If you’re running the Cloudflare WARP client, you’ll also need to create two split tunnels for the profile your device uses, with the type host, to ensure cloudflared traffic is not routed through WARP:
region1.v2.argotunnel.com
region2.v2.argotunnel.com
Then start the tunnel alongside your dev servers:
cloudflared tunnel run devserver
With the tunnel running, visiting dashboard-tunnel.example.com will trigger the Cloudflare Access login flow, and the authenticated request (with its JWT) will be forwarded to your local dashboard worker.
What’s next
The obvious missing piece is spam filtering. Right now I’m relying on the fact that my form isn’t a particularly juicy target, but that won’t hold forever.
The nice thing about storing submissions instead of emailing them is that spam filtering becomes a background problem rather than a user-facing one. I can run each new message through a small language model after the fact, let it flag anything that looks like spam, and quietly hide those from the dashboard — without ever adding a captcha or any extra friction for real visitors.
That’s a post for another day, though. For now, I’m happy to have a contact form that’s simple, fast, and entirely mine.