A Next.js contact form that saves every submission
Build a contact form in the Next.js App Router with a server action that stores each submission, shows per-field errors and survives double clicks.
Updated
A contact form needs three things a plain <form> doesn’t give you: somewhere to keep the submissions, errors the visitor can act on, and protection against the same message arriving twice. This guide builds all three with a server action and 1DB, in about 80 lines.
You’ll end up with a form that:
- posts from the server, so your key never reaches the browser
- shows an error next to each field that’s wrong, using the messages 1DB returns
- sends the same
submission_idon every retry, so a double click stores one lead
Before you start
Create a free 1DB account and add your site under Websites. Every site comes with a contact form and an ingest key. Copy the three values from the site’s Integrate tab into .env.local:
DB_API_ENDPOINT=https://gallant-goshawk-513.convex.site
DB_API_KEY=1db_...
DB_FORM_SLUG=contactAn ingest key can only send leads; it can’t read them. Keep it server-side anyway, so nobody can fill your inbox from their own script.
The server action
The action takes the form’s values, adds the visitor’s IP and browser, and posts them to /v1/leads. The X-1DB-Contract: 2 header asks 1DB to check every field and answer with all the problems at once.
// app/contact/actions.ts
"use server";
import { headers } from "next/headers";
export type ContactState = {
status: "idle" | "sent" | "invalid" | "failed";
fields?: Record<string, string>;
};
export async function sendMessage(
_prev: ContactState,
formData: FormData,
): Promise<ContactState> {
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim();
const res = await fetch(`${process.env.DB_API_ENDPOINT}/v1/leads`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.DB_API_KEY!,
"X-1DB-Contract": "2",
...(ip && { "X-1DB-Client-IP": ip }),
"X-1DB-Client-UA": h.get("user-agent") ?? "",
},
body: JSON.stringify({
formSlug: process.env.DB_FORM_SLUG,
submission_id: formData.get("submission_id"),
first_name: formData.get("first_name"),
email: formData.get("email"),
message: formData.get("message"),
}),
});
if (res.ok) return { status: "sent" };
if (res.status === 400) {
const body = await res.json();
return { status: "invalid", fields: body.fields };
}
return { status: "failed" };
}Without the two X-1DB-Client-* headers, every lead would look like it came from your server: one IP for everyone, which breaks rate limits and puts every visitor in your host’s data center. On Cloudflare, read cf-connecting-ip instead of x-forwarded-for.
The form
useActionState gives you the action’s last answer and a pending flag. The submission_id is made once, when the component mounts, so pressing the button twice, or retrying after a timeout, sends the same id.
// app/contact/contact-form.tsx
"use client";
import { useActionState, useState } from "react";
import { sendMessage, type ContactState } from "./actions";
export function ContactForm() {
const [submissionId] = useState(() => crypto.randomUUID());
const [state, action, pending] = useActionState<ContactState, FormData>(
sendMessage,
{ status: "idle" },
);
if (state.status === "sent") return <p>Thanks, we’ll be in touch.</p>;
const error = (name: string) =>
state.fields?.[name] && <p className="error">{state.fields[name]}</p>;
return (
<form action={action}>
<input type="hidden" name="submission_id" value={submissionId} />
<label htmlFor="first_name">Name</label>
<input id="first_name" name="first_name" autoComplete="given-name" />
{error("first_name")}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
{error("email")}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" maxLength={2000} required />
{error("message")}
{state.status === "failed" && (
<p role="alert">Your message wasn’t sent. Try again.</p>
)}
<button disabled={pending}>{pending ? "Sending…" : "Send message"}</button>
</form>
);
}Then put it on a page:
// app/contact/page.tsx
import { ContactForm } from "./contact-form";
export default function ContactPage() {
return <ContactForm />;
}What the visitor sees when something’s wrong
Send an email without an @ and 1DB answers with a 400 that names the field:
{
"error": "Check the highlighted fields",
"fields": { "email": "Enter a valid email address" }
}The messages are written for visitors, so you can show them as they are. Every field that fails is in fields, not only the first, so the visitor fixes everything in one pass.
Retries and duplicates
When 1DB has already stored a submission_id for your key, it answers 200 with "duplicate": true and the original leadId. Nothing is stored, emailed or forwarded a second time. That makes it safe to retry a 429 (after the Retry-After seconds) or a 500 with the same body. See stopping duplicate form submissions for the details.
Go live
.env.local stays on your machine. Add DB_API_ENDPOINT, DB_API_KEY and DB_FORM_SLUG to your host’s production environment (on Vercel, Settings → Environment Variables), redeploy, and send one real message from the live site. It shows up under Leads in the dashboard within a second or two.
From there you can have each message emailed to your team or posted to a webhook, created in HubSpot, or tagged with the campaign that brought the visitor.