10 min read

Building a Modern Contact Form with Astro and Cloudflare

Table of Contents

Executive Summary

In this tutorial, Iā€™ll show you how to build a modern contact form for your website using Astro, React, and Cloudflare. This approach offers several advantages over traditional contact forms: itā€™s serverless (no dedicated backend needed), type-safe (catching errors before they happen), and cost-effective (you only pay for what you use). By the end, youā€™ll have a fully functional contact form that stores messages in a database and provides a clean UI for your visitors.

Project Overview

At the end of this tutorial, weā€™ll have:

  • A React-based contact form with validation
  • Server-side actions to process form submissions
  • Database integration with Drizzle ORM and Turso
  • Cloudflare Workers for serverless execution

You can see the final form below, feel free to drop me a message!

The Stack

ConcernTechnology
šŸš€ FrameworkAstro
šŸŽØ UI ComponentsReact
šŸ”„ State ManagementReact Query
šŸ›¢ļø DatabaseTurso (SQLite)
šŸ’§ ORMDrizzle
šŸŒ Serverless FunctionsCloudflare Workers
šŸŽ­ Type SafetyTypeScript
šŸŽØ StylingTailwind CSS

Prerequisites

  • Basic knowledge of Astro and React
  • Familiarity with TypeScript
  • A Cloudflare account for Workers
  • A Turso database account

Setting Up the Database Schema

First, letā€™s define our database schema using Drizzle ORM. Create a file at src/lib/db/schema.ts:

import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

// Table for contact messages
export const messages = sqliteTable("messages", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull(),
  message: text("message").notNull(),
  created_at: integer("created_at")
    .notNull()
    .$defaultFn(() => Date.now()),
  updated_at: integer("updated_at")
    .notNull()
    .$defaultFn(() => Date.now()),
});

export type Message = typeof messages.$inferSelect;
export type NewMessage = typeof messages.$inferInsert;

This schema creates a messages table with fields for the contact form data and automatically generates TypeScript types for our messages.

Setting Up the Database Connection

Next, letā€™s set up the connection to our Turso database in src/lib/db/index.ts:

import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import * as schema from "./schema";

// Load environment variables
const tursoDbUrl = import.meta.env.TURSO_DATABASE_URL;
const tursoDbAuthToken = import.meta.env.TURSO_AUTH_TOKEN;

if (!tursoDbUrl) {
  throw new Error("TURSO_DATABASE_URL environment variable is not set");
}

// Create a client connection to the Turso database
const client = createClient({
  url: tursoDbUrl,
  authToken: tursoDbAuthToken,
});

// Create a Drizzle ORM instance
export const db = drizzle(client, { schema });

// Export schema for use in other files
export { schema };

Make sure to add your Turso database URL and auth token to your environment variables.

Creating a React Query Wrapper

To manage API requests and state, weā€™ll use React Query. When building with Astro weā€™re using client-islands. This means that we donā€™t have one single React Context like we would on a regular SPA. So letā€™s create a higher-order component to wrap our form component and any future client-islands with React Query in src/components/withQuery.tsx

This way we can share the same QueryClient across all components even without sharing the same React Context.

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ComponentType, ReactNode } from "react";

// Create a singleton QueryClient that will be shared across all components
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      refetchOnWindowFocus: false,
    },
  },
});

/**
 * Higher-order component that wraps a component with a QueryClientProvider
 * using a shared QueryClient instance.
 *
 * This is useful for Astro islands where you need isolated contexts
 * but want to share the same query client.
 *
 * @param Component - The component to wrap
 * @returns A new component wrapped with QueryClientProvider
 */
export function withQuery<P extends object>(
  Component: ComponentType<P>,
): ComponentType<P> {
  // The display name for the wrapped component
  const displayName = Component.displayName || Component.name || "Component";

  // The wrapped component
  const WithQuery = (props: P) => {
    const { children, ...componentProps } = props as P & {
      children?: ReactNode;
    };

    return (
      <QueryClientProvider client={queryClient}>
        <Component {...(componentProps as P)}>{children}</Component>
      </QueryClientProvider>
    );
  };

  // Set the display name for better debugging
  WithQuery.displayName = `withQuery(${displayName})`;

  return WithQuery;
}

// Export the queryClient in case it needs to be accessed directly
export { queryClient };

For a deeper dive into sharing state between Astro islands, check out my article on sharing React Query state across Astro islands, which expands on this pattern with more advanced use cases.

Creating Server-Side Actions

Now, letā€™s create the server-side actions to handle form submissions in src/actions/index.ts:

import { db, schema } from "@lib/db";
import { defineAction, type ActionAPIContext } from "astro:actions";
import { desc } from "drizzle-orm";
import { nanoid } from "nanoid";

export const server = {
  submitMessage: defineAction({
    handler: async (
      input: Omit<schema.NewMessage, "id" | "createdAt">,
      context: ActionAPIContext,
    ) => {
      await db.insert(schema.messages).values({
        ...input,
        id: nanoid(),
        created_at: Date.now(),
        updated_at: Date.now(),
      });

      return { success: true };
    },
  }),
  getMessages: defineAction({
    handler: async (_, context: ActionAPIContext) => {
      const messages = await db.query.messages.findMany({
        orderBy: desc(schema.messages.created_at),
      });

      return { messages };
    },
  }),
};

This file defines two actions:

  • submitMessage: Stores a new contact message in the Turso database using Drizzle ORM
  • getMessages: Retrieves all messages ordered by creation date (useful for an admin panel)

This is simple for now, but we would want to add rate limiting and some form of request hashing to prevent spam.

Building the React Contact Form Component

Now, letā€™s create our React contact form component in src/components/islands/ContactMe.tsx:

import { withQuery } from "@components/withQuery";
import { useMutation } from "@tanstack/react-query";
import { actions } from "astro:actions";
import { useState } from "react";

interface ContactFormData {
  name: string;
  email: string;
  message: string;
}

export const ContactMeIsland = withQuery(function () {
  const [successVisible, setSuccessVisible] = useState(false);
  const [errorMessage, setErrorMessage] = useState("");

  const mutation = useMutation({
    mutationFn: async (formData: ContactFormData) => {
      const response = await actions.submitMessage(formData);
      return response;
    },
    onSuccess: () => {
      setSuccessVisible(true);
      setErrorMessage("");
    },
    onError: (error: Error) => {
      setErrorMessage(
        error.message ||
          "There was an error sending your message. Please try again.",
      );
      setSuccessVisible(false);
    },
  });

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const form = e.currentTarget;
    const formData = new FormData(form);

    mutation.mutate({
      name: formData.get("name") as string,
      email: formData.get("email") as string,
      message: formData.get("message") as string,
    });

    if (mutation.isSuccess) {
      form.reset();
    }
  };

  return (
    <div className="mt-6">
      <form id="contact-form" className="space-y-4" onSubmit={handleSubmit}>
        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
          <div>
            <label
              htmlFor="name"
              className="block text-sm font-medium text-gray-700 dark:text-gray-300"
            >
              Name
            </label>
            <input
              type="text"
              name="name"
              id="name"
              required
              className="mt-1 block w-full rounded-md bg-white px-3 py-2 text-sm shadow outline-none focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
              placeholder="Your name"
            />
          </div>
          <div>
            <label
              htmlFor="email"
              className="block text-sm font-medium text-gray-700 dark:text-gray-300"
            >
              Email
            </label>
            <input
              type="email"
              name="email"
              id="email"
              required
              className="mt-1 block w-full rounded-md border bg-white px-3 py-2 text-sm shadow outline-none focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
              placeholder="[email protected]"
            />
          </div>
        </div>
        <div>
          <label
            htmlFor="message"
            className="block text-sm font-medium text-gray-700 dark:text-gray-300"
          >
            Message
          </label>
          <textarea
            id="message"
            name="message"
            rows={4}
            required
            className="mt-1 block w-full rounded-md border bg-white px-3 py-2 text-sm shadow outline-none focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
            placeholder="Your message here..."
          />
        </div>
        <div>
          <button
            type="submit"
            disabled={mutation.isPending}
            className="inline-flex justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
          >
            {mutation.isPending ? "Sending..." : "Send Message"}
          </button>
        </div>

        {/* Status messages */}
        {successVisible && (
          <div className="rounded-md bg-green-50 p-4 text-green-700 dark:bg-green-900/30 dark:text-green-400">
            Your message has been sent successfully! I'll get back to you soon.
          </div>
        )}

        {errorMessage && (
          <div className="rounded-md bg-red-50 p-4 text-red-700 dark:bg-red-900/30 dark:text-red-400">
            {errorMessage}
          </div>
        )}
      </form>
    </div>
  );
});

This component:

  • Uses React Query for mutation state management
  • Handles form submission and validation
  • Shows success and error messages
  • Has a responsive design with Tailwind CSS

Creating the Astro Component Wrapper

Finally, letā€™s create an Astro component to use our React island in src/components/ContactMe.astro:

---
import { ContactMeIsland } from "@components/islands/ContactMe";
---

<ContactMeIsland client:load />

This simple wrapper allows us to use the React component as an Astro island with client-side hydration.

Using the Contact Form in Your Pages

Now you can use the contact form in any of your Astro pages:

---
import Layout from "../layouts/Layout.astro";
import ContactMe from "../components/ContactMe.astro";
---

<Layout title="Contact Me">
  <main class="container mx-auto px-4 py-8">
    <h1 class="mb-6 text-3xl font-bold">Contact Me</h1>
    <p class="mb-4">
      Have a question or want to work together? Fill out the form below and I'll
      get back to you as soon as possible.
    </p>
    <ContactMe />
  </main>
</Layout>

Setting Up Cloudflare Workers

For this setup to work, youā€™ll need to configure your Astro project to deploy to Cloudflare Pages with Workers. Add the following to your astro.config.mjs:

import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import tailwind from "@astrojs/tailwind";

export default defineConfig({
  output: "server",
  adapter: cloudflare({
    mode: "advanced",
  }),
  integrations: [react(), tailwind()],
});

Youā€™ll also need to set up your Turso database credentials in your Cloudflare Pages environment variables.

Creating an Admin Panel

To view and manage the messages you receive, you can create a simple admin panel. Hereā€™s a basic implementation:

---
// src/pages/admin/messages.astro
import Layout from "../../layouts/Layout.astro";
import { server } from "../../actions";

// This should be protected with authentication in a real application
const { messages } = await server.getMessages();
---

<Layout title="Message Admin">
  <main class="container mx-auto px-4 py-8">
    <h1 class="mb-6 text-3xl font-bold">Messages</h1>

    <div class="overflow-x-auto">
      <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
        <thead class="bg-gray-50 dark:bg-gray-800">
          <tr>
            <th
              class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
              >Name</th
            >
            <th
              class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
              >Email</th
            >
            <th
              class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
              >Message</th
            >
            <th
              class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
              >Date</th
            >
          </tr>
        </thead>
        <tbody
          class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900"
        >
          {
            messages.map((message) => (
              <tr>
                <td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white">
                  {message.name}
                </td>
                <td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400">
                  {message.email}
                </td>
                <td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
                  {message.message}
                </td>
                <td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400">
                  {new Date(message.created_at).toLocaleString()}
                </td>
              </tr>
            ))
          }
        </tbody>
      </table>
    </div>

    {
      messages.length === 0 && (
        <p class="py-8 text-center text-gray-500 dark:text-gray-400">
          No messages yet.
        </p>
      )
    }
  </main>
</Layout>

In a production environment, youā€™d want to add authentication to protect this page.

Security Considerations

When implementing a contact form, consider these security measures:

  1. Rate limiting: Prevent spam by limiting the number of submissions from a single IP address.
  2. CAPTCHA: Add a CAPTCHA or honeypot field to prevent automated submissions.
  3. Input validation: Validate all inputs on both client and server sides.
  4. Sanitization: Sanitize user inputs to prevent XSS attacks.
  5. CORS: Configure proper CORS headers to prevent cross-site request forgery.

Performance Optimizations

To ensure your contact form performs well:

  1. Lazy loading: Use client:visible instead of client:load if the form isnā€™t immediately visible.
  2. Debouncing: Implement debouncing for form submissions to prevent multiple rapid submissions.
  3. Caching: Configure appropriate caching strategies for static parts of your site.
  4. Edge deployment: Deploy to Cloudflareā€™s edge network for low-latency responses worldwide.

Conclusion

Weā€™ve built a fully functional contact form using Astro, React, Drizzle ORM, Turso, and Cloudflare Workers. This setup provides:

  • A modern, responsive UI with React
  • Type-safe database operations with Drizzle ORM and Turso
  • Serverless functions with Cloudflare Workers
  • Efficient data storage with a SQLite-compatible database in the cloud

This architecture is not only performant but also cost-effective, as you only pay for the resources you use. The combination of Astroā€™s partial hydration, Tursoā€™s edge database, and Cloudflareā€™s edge network ensures your contact form loads quickly and works reliably for your users.

Feel free to extend this implementation with additional features like:

  • Email notifications when you receive a new message
  • CAPTCHA integration to prevent spam
  • More advanced admin panel features

To see this implementation in action, feel free to leave me a message at jessermoreno.com/#contact-form. Iā€™d love to hear your thoughts on this tutorial or answer any questions you might have!

Happy coding!