Skip to content

Next.js Forms

In Next.js projects using the App Router, you can easily build responsive contact forms as client components ("use client") and POST submissions directly to Lucid Forms. This avoids complex Next.js API route or Server Action setup.


If you are building your Next.js application with an AI assistant (such as Cursor, Claude, v0, Windsurf, or ChatGPT), paste this prompt to generate a production-ready Next.js client form component:

Create a responsive Next.js App Router client contact form component ("use client").
Submission Endpoint:
https://api.lucidforms.co/f/{your-form-id}
Requirements:
- Use TypeScript and standard form fields: Name, Email, and Message
- Include an invisible honeypot field (_honeypot) for spam bot filtering
- Submit form data to the submission endpoint via fetch() with 'Accept: application/json' and 'Content-Type: application/json'
- Handle states with React useState: idle, loading (disable button), success, and error messages
- Reset form fields on successful submission
- Style with clean, responsive Tailwind CSS classes and dark mode support

Create a file named components/ContactForm.tsx in your Next.js project. It uses standard React hooks and can be styled with Tailwind CSS or standard CSS modules.

"use client";
import React, { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [errorMessage, setErrorMessage] = useState("");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setStatus("loading");
setErrorMessage("");
const formData = new FormData(e.currentTarget);
const data = Object.fromEntries(formData.entries());
// Bot trap check
if (data._honeypot) {
setStatus("success");
return;
}
try {
const response = await fetch("https://api.lucidforms.co/f/{your-form-id}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
name: data.name,
email: data.email,
message: data.message,
}),
});
const responseData = await response.json();
if (response.ok) {
setStatus("success");
(e.target as HTMLFormElement).reset();
} else {
setStatus("error");
setErrorMessage(responseData.error || "Form submission failed. Please try again.");
}
} catch (err) {
setStatus("error");
setErrorMessage("A network error occurred. Please try again.");
}
};
return (
<div className="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-zinc-950">
<h2 className="mb-4 text-xl font-bold text-gray-900 dark:text-white">Get in Touch</h2>
{status === "success" && (
<div className="mb-4 rounded bg-emerald-50 p-3 text-sm font-medium text-emerald-800 dark:bg-emerald-950/20 dark:text-emerald-400">
✓ Your message has been sent successfully!
</div>
)}
{status === "error" && (
<div className="mb-4 rounded bg-rose-50 p-3 text-sm font-medium text-rose-800 dark:bg-rose-950/20 dark:text-rose-400">
✗ {errorMessage}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Invisible honeypot trap */}
<input
type="text"
name="_honeypot"
className="hidden"
tabIndex={-1}
autoComplete="off"
/>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Full Name
</label>
<input
type="text"
id="name"
name="name"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-800 dark:bg-zinc-900 dark:text-white"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Email Address
</label>
<input
type="email"
id="email"
name="email"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-800 dark:bg-zinc-900 dark:text-white"
/>
</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"
required
rows={4}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-800 dark:bg-zinc-900 dark:text-white"
/>
</div>
<button
type="submit"
disabled={status === "loading"}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 dark:bg-blue-700 dark:hover:bg-blue-600"
>
{status === "loading" ? "Sending..." : "Submit"}
</button>
</form>
</div>
);
}

If you are accepting user attachments (e.g., resumes, screenshots) and have a Pro Plan which supports file uploads (up to 5MB per file, 1GB total account storage), submit the data using the standard FormData object instead of converting to JSON.

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setStatus("loading");
const formData = new FormData(e.currentTarget);
try {
const response = await fetch("https://api.lucidforms.co/f/{your-form-id}", {
method: "POST",
headers: {
// Do NOT set Content-Type header when sending FormData!
// The browser automatically sets it with the correct boundary parameters.
"Accept": "application/json",
},
body: formData,
});
if (response.ok) {
setStatus("success");
e.currentTarget.reset();
} else {
setStatus("error");
}
} catch (err) {
setStatus("error");
}
};

Make sure you include the file input in your JSX markup:

<input type="file" name="attachment" />