# Custom form handling with Cloudflare Workers and D1

**Date:** 2026-04-10

---


## Cloudflare Workers

STOP. Your knowledge of Cloudflare Workers APIs and limits may be outdated. Always retrieve current documentation before any Workers, KV, R2, D1, Durable Objects, Queues, Vectorize, AI, or Agents SDK task.

### Docs

- https://developers.cloudflare.com/workers/
- MCP: `https://docs.mcp.cloudflare.com/mcp`

For all limits and quotas, retrieve from the product's `/platform/limits/` page. eg. `/workers/platform/limits`

### Commands

| Command                     | Purpose                   |
| --------------------------- | ------------------------- |
| `pnpm exec wrangler dev`    | Local development         |
| `pnpm exec wrangler deploy` | Deploy to Cloudflare      |
| `pnpm exec wrangler types`  | Generate TypeScript types |

Run `wrangler types` after changing bindings in wrangler.jsonc.

### Node.js Compatibility

https://developers.cloudflare.com/workers/runtime-apis/nodejs/

### Errors

- **Error 1102** (CPU/Memory exceeded): Retrieve limits from `/workers/platform/limits/`
- **All errors**: https://developers.cloudflare.com/workers/observability/errors/

### Product Docs

Retrieve API references and limits from:
`/kv/` · `/r2/` · `/d1/` · `/durable-objects/` · `/queues/` · `/vectorize/` · `/workers-ai/` · `/agents/`


## PNPM Workspaces

Always use `pnpm` instead of `npm`. Use `npm exec` instead of `npx`. Refer to `pnpm-workspace.yaml` for the list of packages in the workspace. Instead of changing directories into a workspace package, run `pnpm --filter`.


## Astro

> Astro is an all-in-one web framework for building websites.

- Astro uses island architecture and server-first design to reduce client-side JavaScript overhead and ship high performance websites.
- Astro’s friendly content-focused features like content collections and built-in Markdown support make it an excellent choice for blogs, marketing, and e-commerce sites amongst others.
- The `.astro` templating syntax provides powerful server rendering in a format that follows HTML standards and will feel very familiar to anyone who has used JSX.
- Astro supports popular UI frameworks like React, Vue, Svelte, Preact, and Solid through official integrations.
- Astro is powered by Vite, comes with a fast development server, bundles your JavaScript and CSS for you, and makes building websites feel fun.

### Documentation Sets

- [Abridged documentation](https://docs.astro.build/llms-small.txt): a compact version of the documentation for Astro, with non-essential content removed
- [Complete documentation](https://docs.astro.build/llms-full.txt): the full documentation for Astro
- [API Reference](https://docs.astro.build/_llms-txt/api-reference.txt): terse, structured descriptions of Astro’s APIs
- [How-to Recipes](https://docs.astro.build/_llms-txt/how-to-recipes.txt): guided examples of adding features to an Astro project
- [Build a Blog Tutorial](https://docs.astro.build/_llms-txt/build-a-blog-tutorial.txt): a step-by-step guide to building a basic blog with Astro
- [Deployment Guides](https://docs.astro.build/_llms-txt/deployment-guides.txt): recipes for how to deploy an Astro website to different services
- [CMS Guides](https://docs.astro.build/_llms-txt/cms-guides.txt): recipes for how to use different content management systems in an Astro project
- [Backend Services](https://docs.astro.build/_llms-txt/backend-services.txt): advice on how to integrate backend services like Firebase, Sentry, and Supabase in an Astro project
- [Migration Guides](https://docs.astro.build/_llms-txt/migration-guides.txt): advice on how to migrate a project built with another tool to Astro
- [Additional Guides](https://docs.astro.build/_llms-txt/additional-guides.txt): guides to e-commerce, authentication, testing, and digital asset management in Astro projects

### Notes

- The complete documentation includes all content from the official documentation
- The content is automatically generated from the same source as the official documentation

### Optional

- [The Astro blog](https://astro.build/blog/): the latest news about Astro development

---

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](https://workers.cloudflare.com/) 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](https://developers.cloudflare.com/d1/), Cloudflare's managed SQL database. I also created a simple dashboard for viewing the submissions, protected by [Cloudflare Access](https://www.cloudflare.com/zero-trust/products/access/), and set up push notifications to my phone via [Pushover](https://pushover.net/) so I know right away when a new submission comes in.

## The shape of the solution

At a high level, there are three pieces:

1. The contact form on my Astro website. Submissions are handled by an [Astro action](https://docs.astro.build/en/guides/actions/), which validates the form fields and saves the data to a database.
2. A D1 database for storing the submissions.
3. 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](https://developers.cloudflare.com/workers/development-testing/multi-workers/#multiple-dev-commands). 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](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) 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:

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

```yaml
packages:
  - 'dashboard'
  - 'site'
```

Then create the D1 database:

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

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

```json
{
  "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`:

```json
{
  "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:

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

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

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

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

```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](https://docs.astro.build/en/guides/actions/). 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`:

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

1. **Success** — `result` exists with no error, so we show the thank-you message and hide the form.
2. **Server error** — `result.error` exists but is not an input error (e.g. the database is down), so we show the error message above the form.
3. **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](https://pushover.net/api) for sending notifications. Add the following to the action handler, after the successful database insert:

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

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

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

```sh
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](https://developers.cloudflare.com/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](https://github.com/panva/jose) library in the dashboard worker:

```sh
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**):

```json
{
  "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:

```ts
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](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 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:

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

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

```json
{
  "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](https://developers.cloudflare.com/cloudflare-one/team-and-resources/devices/warp/configure-warp/route-traffic/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:

```sh
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.