From Form to Function: Astro + Cloudflare Contact Forms

May 29, 2025

This article is both a cautionary tale – about the importance of documenting things you don’t fully understand – and a practical tutorial covering:

  1. How to create a working contact form in Astro.
  2. How to use static file endpoints to fetch data from Cloudflare KV.

A Bit of History

Back in 2023, a friend-of-a-friend uttered the infamous words:

“You’re a programmer. Can you create a website for my business?”

At the time, my web development knowledge was essentially zero. But I said yes – I wanted to spend some time learning webdev, and I was going to get paid. That was enough.

The Stack

After some research into the tools and frameworks, I settled on the following stack:

  • Astro for the static site. It had been gaining popularity thanks to its performance and built-in support for JS frameworks like React.
  • Cloudflare Pages for hosting. The free tier is incredibly generous and includes “Functions” – server-side code for things like form submissions and API endpoints.
  • MailChannels for email integration. Back in 2023, Cloudflare Pages users could use MailChannels’ email API for free.

The Development

90% of the project went smoothly. There are plenty of Astro themes out there, and the site requirements were modest: a basic landing page, contact form, FAQ and gallery.

But then came the last 10% – the contact form. That’s where things went sideways.

Despite following the documentation, the form just wouldn’t work. Cloudflare Pages refused to recognise the /functions directory, where the server-side code was supposed to live.

It took me well over a week of trial, error, and Googling to find a working solution. I saved the articles, and documented some of the steps.

But time passed. Fast-forward two years to another Astro project – and everything was broken again. The documentation was outdated, and none of my notes worked.

Back to square[0].

Astro Endpoints

Astro’s static-first approach makes it ideal for lightning-fast websites, but that doesn’t mean you’re stuck with just static content. In fact, Astro has boasted about pioneering the Islands Architecture pattern – render majority of pages to static, fast HTML files with smaller “islands” of JavaScript-based content added for interactivity and dynamic content. This is one of Astro’s biggest selling points – support for custom endpoints, allowing you to serve dynamic content on demand.

Endpoints in Astro can return JSON, plain text, HTML, or even stream data. This means you can use them for everything from loading dynamic data into your website, to handling contact form submissions, or even powering lightweight APIs – all without leaving your Astro project.

You can read the official guide here, but this section will walk you through the key concepts first.

Server-Side Rendering (SSR)

By default, Astro pre-renders all pages into static HTML during the build process. This makes your site fast and secure, and is perfect for blogs, documentation, portfolios, and other static use cases.

However, if you need server-side logic – like processing form submissions, fetching user data, or generating content at request time – you’ll need Server-Side Rendering (SSR).

Astro supports SSR through adapters, which allow your site to run in environments like Node.js, Vercel, Netlify, or Cloudflare Pages. You can browse the full list of SSR adapters here.

Output mode: static vs server

Astro v5 gives you control over the default rendering behaviour, using the output setting in astro.config.mjs. There are two modes:

static (default)

  • All pages are pre-rendered to HTML at built time.
  • Fastest and most cacheable option.
  • Use this mode when most or all of your content is static.
export const prerender = false;
Add this code to your components to disable pre-rendering.

server

  • Enables SSR globally, rendering pages at request time.
  • Use this mode when most of your website is dynamic.
export const prerender = true;
Add this code to your components to enable pre-rendering.

To switch to server mode, change your config like so:

astro.config.mjs
import cloudflare from '@astrojs/cloudflare';
import { defineConfig } from "astro/config";

// https://astro.build/config
export default defineConfig({
  output: "server", // server mode enabled.
  adapter: cloudflare(),
});

Note on Logging

Normally, console.log will output in the browser console.

When using logging in SSR components, the logs will show up in the terminal, since those components run on the server.

Tutorial: Dynamic Features with Astro and Cloudflare

This section will walk you through building two real-world features in Astro:

  1. A working contact form using API endpoints.
  2. A simple URL shortener powered by Cloudflare KV.

These examples will help you understand how to integrate server-side logic and storage while deploying to Cloudflare Pages.

Creating Astro Project

If you are new to Astro, I recommend checking out the official getting started page. You can also browse the themes if you’d like a quick scaffolded template.

For our purposes, a minimal project will be enough:

npm create astro@latest

We should add the Cloudflare adapter too:

npx astro add cloudflare
This enables deployment to Cloudflare Pages with support for functions and dynamic endpoints.

There is no need to change the default rendering behaviour. Most of the site should be static.

Building a Contact Form

Let’s build a contact form using Astro’s API routes.

Contact Page

Let’s start by creating the contact page:

src/pages/contact.astro
---
// Define the API endpoint.
const contactFormAction = "/api/contact";

// This is not needed. Only adding it here to highlight that the page will be pre-rendered.
export const prerender = true;
---

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Contact Us</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <style>
      body {
        font-family: sans-serif;
        padding: 2rem;
        max-width: 600px;
        margin: auto;
      }

      form {
        display: flex;
        flex-direction: column;
        gap: 1rem;
      }

      input, textarea, button {
        padding: 0.5rem;
        font-size: 1rem;
      }

      button {
        background-color: #333;
        color: #fff;
        border: none;
        cursor: pointer;
      }

      button:hover {
        background-color: #555;
      }
    </style>
  </head>
  <body>
    <h1>Contact Us</h1>
    <form method="POST" action={contactFormAction}>
      <label>
        Name:
        <input type="text" name="name" required />
      </label>
      <label>
        Email:
        <input type="email" name="email" required />
      </label>
      <label>
        Message:
        <textarea name="message" rows="5" required></textarea>
      </label>
      <button type="submit">Send Message</button>
    </form>
  </body>
</html>
This is a simple HTML form that submits a POST request to /api/contact.

Creating the Contact API Endpoint

Now let’s add the handler for the contact form endpoint:

src/pages/api/contact.ts
// Telling Astro to not pre-render this
export const prerender = false;
import type { APIRoute } from "astro";

export const POST: APIRoute = async ({ request }) => {
  const formData = await request.formData();
  const name = formData.get("name");
  const email = formData.get("email");
  const message = formData.get("message");

  // Log it, send to email, store in KV, or trigger a webhook...
  console.log({ name, email, message });

  return new Response(null, {
    status: 302,
    headers: {
      Location: "/contact-success", // Or a thank-you page
    },
  });
};

Astro’s API Routes run on the edge and are perfect for small forms or webhooks. Endpoint source files are created in the same directory as astro pages.

URL Shortening with Cloudflare KV

Here, we’re gonna build a basic dynamic route handler using Cloudflare KV.

For this tutorial, you will need to create a KV namespace. I recommend reading the official docs which cover some of the nitty-gritty details.

Installing Wrangler

Install Wrangler as a dev dependency:

npm install -D wrangler

Wrangler is a command-line tool for building and deploying Cloudflare Pages and Workers. It can also be used for local development (to mock data), and to manage data in the cloud.

Creating KV bindings

  • Create wrangler configuration file in your project root:
wrangler.toml
compatibility_date = "2024-05-18"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = "dist"

[[kv_namespaces]]
binding = "MY_DATA"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Replace the id with the actual namespace ID from your Cloudflare dashboard.


  • Update your environment declaration to recognize the binding:
env.d.ts
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

type KVNamespace = import("@cloudflare/workers-types").KVNamespace;
type ENV = {
  MY_DATA: KVNamespace;
};

type Runtime = import("@astrojs/cloudflare").Runtime<ENV>;

declare namespace App {
  interface Locals extends Runtime {}
}
This is necessary because Astro uses type augmentation via App.Locals to pass runtime bindings like MY_DATA.


  • Generate configuration types:
wrangler types
This ensures the dev server and deployment use consistent bindings and types.


Dynamic Page: Slug

Create the dynamic route page:

[slug].astro
---
// Tell Astro that this is a dynamic page
export const prerender = false;

// Type definition
import type { SmallUrl } from "@/types";
import { getSmallUrl } from "@/utils/url_lookup";

// Access the bound KV namespace
// Astro exposes Cloudflare bindings via Astro.locals.runtime.env, 
//   which comes from your wrangler.toml KV configuration and the Cloudflare adapter.
const { MY_DATA } = Astro.locals.runtime.env;

const slug = Astro.params.slug;
const data: SmallUrl | null = await getData(slug!, MY_DATA);

if (!data) {
  return Astro.redirect("/404");
}
---

<div>
  <h1>Short URL Redirect</h1>
  <p>Destination: {data.url}</p>
  <script>
    window.location.href = "{data.url}";
  </script>
</div>

Adding URL Lookup logic

Define your KV access logic:

src/utils/url_lookup.ts
import type { SmallUrl } from "@/types";

export async function getData(slug: string, kv: KVNamespace): Promise<SmallUrl | null> {
  const raw = await kv.get(slug);
  if (!raw) return null;

  try {
    return JSON.parse(raw) as SmallUrl;
  } catch {
    return null;
  }
}
Separating the code into two files is not necessary. You can define the logic inside the page file.

Testing and Deployment

project-root/
├── public/
├── src/
│   ├── pages/
│   │   ├── contact.astro
│   │   ├── [slug].astro
│   │   └── api/
│   │       └── contact.ts
│   ├── utils/
│   │   └── url_lookup.ts
│   └── env.d.ts
├── wrangler.toml
├── astro.config.mjs
└── package.json
Your Astro project should look something like this.

Building, Testing and Deploying

  • Firstly, generate the build:
npm run build


  • Verify the build output matches the output specified in the config:
wrangler.toml
pages_build_output_dir = "dist"


  • You can use wrangler to deploy to Cloudflare Pages:
npx wrangler pages deploy ./dist --project-name=your-project-name
Make sure the KV namespace is correctly bound to this Pages project under **Settings > Functions > Environment Variables**


  • You can use wrangler test the build locally with live data:
npx wrangler dev --remote

Testing the contact form

Navigate to your site and go to /contact. Fill out the form and submit.

  • Open browser dev tools and look out for a POST request in the network tab.
npx wrangler tail
You can monitor server logs in real time with this command.

Testing the KV short URLs

npx wrangler kv:key put my-slug '{"url": "https://www.example.com"}' --binding=MY_DATA
Insert data into the KV store

Navigate to https://your-pages-site.com/my-slug. You should see a redirect or the destination URL.

Conclusion

In this guide, you’ve seen how to build and deploy a modern Astro site using Cloudflare’s edge infrastructure. We have implemented:

  • A serverless contact form using Astro Endpoints.
  • URL shortening router using Cloudflare KV as storage.
  • Deployment and remote KV testing with Wrangler.

These examples show that Astro has become more than a static-site generator, like Hugo or Jekyll. It is now a yet another JavaScript Framework.

You either die an SSG or live long enough to see yourself become a JavaScript framework.