This article demonstrates how to implement a secure, functional contact form that integrates client-side validation, Google reCAPTCHA, and a server-side connection to Notion for storing submissions.
This contact form workflow consists of:
The form is a React component rendered on the client. It includes fields for the sender's email, name, message type, subject, and message content. Google reCAPTCHA is used to prevent spam.
"use client";
const contactFormSchema = z.object({
email: z.string().email("Invalid email address"),
name: z.string().min(1, "Name is required"),
message: z.string().min(10, "Message must be at least 10 characters"),
type: z.string().min(1, "Message type is required"),
subject: z.string().min(1, "Subject is required"),
});
export function ContactForm() {
const [isRecaptchaVerified, setIsRecaptchaVerified] = React.useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const values = {
email: formData.get("email"),
name: formData.get("name"),
message: formData.get("message"),
type: formData.get("type"),
subject: formData.get("subject"),
};
try {
contactFormSchema.parse(values);
await sendContactMessage(values);
} catch (error) {
// Handle validation or server errors here
}
};
return (
<form onSubmit={handleSubmit}>
{/* Input fields and reCAPTCHA */}
<ReCAPTCHA
sitekey={process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY}
onChange={(token) => setIsRecaptchaVerified(!!token)}
/>
<button type="submit" disabled={!isRecaptchaVerified}>
Send Message
</button>
</form>
);
}
Server actions in Next.js allow direct server-side processing without a dedicated API route. This example uses sendContactMessage to validate input and store it in Notion.
"use server";
const NOTION_API_URL = "https://api.notion.com/v1/pages";
export async function sendContactMessage(values) {
const schema = z.object({
email: z.string().email(),
name: z.string().min(2),
message: z.string().min(10),
type: z.string().min(1),
subject: z.string().min(1),
});
const validated = schema.parse(values);
const response = await fetch(NOTION_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NOTION_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
parent: { database_id: process.env.NEXT_PUBLIC_NOTION_DATABASE_ID },
properties: {
Name: { title: [{ text: { content: validated.name } }] },
Email: { email: validated.email },
Message: { rich_text: [{ text: { content: validated.message } }] },
Type: { select: { name: validated.type } },
Subject: { rich_text: [{ text: { content: validated.subject } }] },
},
}),
});
if (!response.ok) {
throw new Error("Failed to save message");
}
}
Notion serves as the storage backend. Each form submission creates a new page in a specified Notion database.
Create a Notion integration and retrieve the API key.
Add the database ID and API key to .env.local:
NOTION_API_KEY=your-api-key
NEXT_PUBLIC_NOTION_DATABASE_ID=your-database-id
Ensure database properties align with the payload structure defined in sendContactMessage.
Using Next.js 15's App Router and server actions, you can implement a contact form with clear separation of concerns, robust validation, and secure handling of user data. This approach is adaptable for other backends and use cases while maintaining simplicity and reliability.