v0 generates a beautiful contact form — and then nothing happens when someone submits it. Here is why the generated form has no backend, and how to make it actually deliver messages to your inbox in about two minutes.
TL;DR
v0 generates frontend React code only, so the contact form it builds has nowhere to send data. Point the form at a FormsList endpoint — action="https://formslist.com/f/YOUR_HASH" with method="POST" for plain HTML, or fetch() with Accept: application/json for a React onSubmit handler. Submissions land in a dashboard and in your inbox. Free plan: 5 forms, 500 submissions/month.
v0 is a frontend generator. It writes React components — usually Next.js with Tailwind — and it is genuinely good at producing a polished contact section with labels, focus states, and a submit button. What it does not do is create the half of a contact form that lives on a server. A contact form is really two systems pretending to be one. The first is the markup and interaction you can see. The second is invisible: something has to receive the POST request, store the payload so it is not lost, and then hand it to an authenticated mail relay that is allowed to send email on behalf of a domain. That second system needs a running server process, credentials, and a deliverability setup. None of that can be generated into a component file. So you end up with one of three failure modes. If the generated form has no action attribute and no submit handler, the browser posts back to the current URL and the page just reloads. If it has a handler that only calls setSubmitted(true), the user sees a friendly "Thanks, we'll be in touch" that is a complete lie — nothing left the browser. And if v0 scaffolded a Next.js route handler for you, that file almost certainly contains a TODO where the email sending should be, or references an API key that does not exist in your environment. In every case the visitor believes they contacted you and you never find out they tried.
// The pattern v0 typically produces — looks complete, sends nothing.
"use client";
import { useState } from "react";
export function ContactForm() {
const [submitted, setSubmitted] = useState(false);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// TODO: send the data somewhere
setSubmitted(true); // <- the user is told it worked. It did not.
}
return submitted ? <p>Thanks!</p> : <form onSubmit={handleSubmit}>...</form>;
}You need a URL that accepts POST requests, keeps the data, and emails you. FormsList gives you one without writing or deploying any server code. The fastest path takes one command and no signup. POST to the instant provisioning endpoint with the email address that should receive submissions and a name for the form, and you get back a live endpoint immediately — no account, no OAuth, no credit card. This exists because AI builders like v0 generate sites before there is a human ready to sign up for anything, so the endpoint has to be available at generation time. The response includes the endpoint URL, a paste-ready HTML snippet, and a claim link. FormsList also emails the address you supplied so the site owner can claim the dashboard later and see the submission history. One thing to note: an unclaimed form is deleted after 7 days. Click the claim link in that email — or just sign up normally at formslist.com and create the form from the dashboard if you would rather start with an account.
curl -X POST https://formslist.com/api/v1/instant \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","form_name":"Contact"}'
# Response:
# {
# "endpoint": "https://formslist.com/f/YOUR_HASH",
# "html_snippet": "...",
# "claim_url": "...",
# "expires_at": "..."
# }Because v0 output is React, the version you want is a fetch() call inside onSubmit. Keep the component's existing markup and styling — you are only replacing the body of the submit handler. The critical detail is the Accept: application/json header. A FormsList endpoint decides how to answer based on that header. Send it, and you get back a JSON body containing ok: true and a submission id, which lets your component stay on the page and swap in its own success state. Leave it out, and the endpoint answers a browser-style POST with a 303 redirect to a hosted thank-you page — correct behaviour for a plain HTML form, but wrong inside a React app where you want to control the UI. Use FormData(form) as the request body rather than hand-assembling an object. It picks up every named input automatically, so adding a phone or company field later means adding one input element and nothing else. It also means the same handler keeps working if you add a file input later. And put the endpoint in an environment variable — in a Next.js project generated by v0 that means NEXT_PUBLIC_FORMSLIST_URL in .env.local, plus the same variable in your Vercel project settings so production is not pointing at a test form.
"use client";
import { useState, type FormEvent } from "react";
export function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("sending");
const form = e.currentTarget;
try {
const res = await fetch("https://formslist.com/f/YOUR_HASH", {
method: "POST",
headers: { Accept: "application/json" }, // <- keeps you on the page
body: new FormData(form),
});
if (res.ok) {
setStatus("sent");
form.reset();
} else {
setStatus("error");
}
} catch {
setStatus("error");
}
}
if (status === "sent") {
return <p className="text-emerald-600">Thanks — your message is on its way.</p>;
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input name="name" type="text" placeholder="Your name" required />
<input name="email" type="email" placeholder="you@example.com" required />
<textarea name="message" rows={5} placeholder="Your message" required />
{/* Honeypot: bots fill it, humans never see it */}
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending..." : "Send message"}
</button>
{status === "error" && <p className="text-red-600">Something went wrong. Please try again.</p>}
</form>
);
}You do not actually need the fetch handler. A form element with an action and method attribute works with zero JavaScript, which means it also works if your JS bundle fails to load, if the user has scripts disabled, or if hydration breaks — all of which are real failure modes on generated sites. With a plain POST, the browser navigates. FormsList answers with a 303 redirect to a hosted thank-you page by default. If you would rather send people to your own page, add a hidden input named _next containing the URL you want them to land on. This is the most robust possible contact form: no state, no bundle, no hydration, nothing to break. The tradeoff is the full page navigation, which feels less slick than an inline success message. Pick based on what you value. Many people ship the plain version first because it cannot silently fail, then upgrade to the fetch version once the endpoint is confirmed working.
<form action="https://formslist.com/f/YOUR_HASH" method="POST">
<label for="name">Name</label>
<input id="name" type="text" name="name" required />
<label for="email">Email</label>
<input id="email" type="email" name="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<!-- Honeypot: leave empty, keep hidden -->
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none" />
<!-- Optional: where to send the browser after submitting -->
<input type="hidden" name="_next" value="https://your-site.vercel.app/thanks" />
<button type="submit">Send message</button>
</form>A v0 project usually lives at several origins at once: the in-editor preview, one or more Vercel deployment URLs, and eventually your custom domain. This trips people up with self-hosted form handlers because a browser will block a cross-origin POST unless the receiving server explicitly allows that origin — and a preview subdomain that changes on every deploy is impossible to allowlist by hand. FormsList endpoints accept cross-origin POSTs from any origin by default, so the same endpoint works from the preview, from every preview deployment, and from your production domain without configuration. Test in the preview, ship to production, attach a domain later — nothing to update. If you later want to lock things down, form settings include an allowed-domains restriction. Turn it on only after your custom domain is live and remember to include every origin you actually submit from, otherwise your own preview deployments will start getting rejected. For most small sites, leaving it open and relying on spam filtering is the better tradeoff.
Spam protection: include a hidden input named _gotcha and leave it empty. Automated bots fill in every field they find, so a submission that arrives with that field populated is flagged. Real visitors never see it because it is display:none. This costs nothing, adds no third-party script, and requires no user interaction — no checkbox, no image puzzle. FormsList also runs automatic filtering on top of the honeypot, on every plan including free. If you want a visible CAPTCHA as well, endpoints accept reCAPTCHA, Turnstile, and hCaptcha tokens. Email notifications: free on every plan. There is no tier where your submissions sit in a dashboard held hostage until you pay — the whole point is that a message someone typed on your website reaches you. Submissions are also stored in the dashboard so nothing is lost if an email goes astray. File uploads: use enctype="multipart/form-data" on a plain HTML form (a fetch call with FormData sets this automatically) and add an ordinary file input. The free plan accepts 1 file up to 2MB per submission; paid plans accept up to 5 files at 4MB each. That is enough for a resume, a screenshot of a bug, or a photo of the thing someone wants a quote on.
<!-- File upload variant -->
<form action="https://formslist.com/f/YOUR_HASH" method="POST" enctype="multipart/form-data">
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<input type="file" name="attachment" />
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none" />
<button type="submit">Send</button>
</form>Submit a real test message from the deployed site, not just from the editor preview. Then check two places: your inbox, and the FormsList dashboard. If the message is in the dashboard but not your inbox, the notification address is wrong or the mail landed in spam — check both before assuming the form is broken. If the submission does not arrive at all, open your browser's network tab and look at the POST request. A 404 means the hash in your endpoint URL is wrong or the form was deleted (remember the 7-day window on unclaimed instant forms). A 422 means server-side validation rejected a field. Errors come back as JSON containing an error code and a fix field describing the corrective action, so you rarely have to guess. You can also send a GET request to your endpoint in a browser. It returns JSON usage documentation for that specific form: which content types it accepts, which fields it expects, and an example request. That is the fastest way to confirm an endpoint is alive and to see exactly what it wants. Full docs are at formslist.com/docs, the programmatic API is documented at formslist.com/form-api, and formslist.com/ai covers the agent-facing provisioning flow if you are wiring this into a builder rather than a single site.
# Confirm the endpoint is live and see what it expects
curl https://formslist.com/f/YOUR_HASH
# Send a test submission from the command line
curl -X POST https://formslist.com/f/YOUR_HASH \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{"name":"Test","email":"test@example.com","message":"Testing the form"}'Ask Claude for a landing page and you get one, contact form included — but that form has no backend, so submissions go nowhere. Here is the fix, including the version you can paste straight into your prompt.
Learn moreLovable builds a complete-looking React app in minutes, contact form included — but that form has nowhere to deliver messages unless you wire up a backend. Here is the two-minute version that does not involve standing up a database.
Learn moreBolt.new scaffolds a whole project in the browser and the contact form looks done — until you deploy and discover nothing is being delivered. Here is why, and the fix that needs no server, no SMTP account, and no extra dependency.
Learn moreLearn how to add a working contact form to your Next.js application in minutes. No backend code required — just create your form, point it at FormsList, and start receiving submissions by email.
Learn moreSet up your form backend in under a minute. No server required, no complex configuration — just a simple endpoint for your forms.