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
Concern | Technology |
---|---|
š Framework | Astro |
šØ UI Components | React |
š State Management | React Query |
š¢ļø Database | Turso (SQLite) |
š§ ORM | Drizzle |
š Serverless Functions | Cloudflare Workers |
š Type Safety | TypeScript |
šØ Styling | Tailwind 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 ORMgetMessages
: 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:
- Rate limiting: Prevent spam by limiting the number of submissions from a single IP address.
- CAPTCHA: Add a CAPTCHA or honeypot field to prevent automated submissions.
- Input validation: Validate all inputs on both client and server sides.
- Sanitization: Sanitize user inputs to prevent XSS attacks.
- CORS: Configure proper CORS headers to prevent cross-site request forgery.
Performance Optimizations
To ensure your contact form performs well:
- Lazy loading: Use
client:visible
instead ofclient:load
if the form isnāt immediately visible. - Debouncing: Implement debouncing for form submissions to prevent multiple rapid submissions.
- Caching: Configure appropriate caching strategies for static parts of your site.
- 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!