Lovable 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.
TL;DR
A contact form generated by Lovable is frontend markup with no delivery path, so submissions go nowhere. Point it at a FormsList endpoint instead: action="https://formslist.com/f/YOUR_HASH" method="POST" for plain HTML, or fetch() with Accept: application/json inside the React onSubmit handler. No database, no edge function, no SMTP credentials. Free plan: 5 forms, 500 submissions/month, email notifications included.
Lovable generates a React application. The contact page it produces has proper labels, validation attributes, a submit button with a loading state, and often a nicely animated success message. It looks finished. It is not finished, because everything you just read about is code that runs in the visitor's browser. Delivering a message requires three things that cannot exist in browser code. A server has to be listening at some URL to receive the POST. Something durable has to store the payload, because an HTTP request that is not written down is gone the moment the process handling it moves on. And an authenticated mail sender — an account with a mail provider, a verified sending domain, DNS records proving you are allowed to send as that domain — has to relay it to your inbox. You cannot put an SMTP password in a React component; anyone viewing source would have it. The result is a form that reports success while doing nothing. Sometimes the generated handler only flips a state variable. Sometimes it references a backend integration you were prompted to connect but never did, so the call fails and the catch block swallows it. Sometimes it posts to a relative path that returns your own index.html. All three feel identical to a visitor: they type a message, see a thank-you, and hear nothing back ever.
// Typical generated handler: shows success, sends nothing.
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
// no endpoint, or an endpoint that was never deployed
} catch (err) {
// failure is swallowed; the user still sees the success state
}
setLoading(false);
setSuccess(true);
};There is a real fork here, and picking the wrong branch costs you an afternoon. A contact form is not the same problem as user accounts or a product database. If you need signed-in users, per-user data, or records your app reads back and renders, you need a real backend and the database integration Lovable offers is the right tool. That is a genuine architecture decision worth making deliberately. If you need to receive messages from strangers — contact, quote requests, demo bookings, feedback, waitlist signups — you do not need a database, schema, row-level security policies, an edge function, or a mail provider account. You need a URL that accepts a POST and emails you. That is a fundamentally smaller problem, and treating it like the bigger one is why so many otherwise-finished sites ship with a broken contact page. The rest of this guide covers the smaller problem.
FormsList gives you an endpoint that accepts POSTs, stores every submission, filters spam, and emails you. You can create one from the dashboard after a normal signup, or you can skip the account entirely. One POST to the instant provisioning endpoint, with the email that should receive submissions and a name for the form, returns a live endpoint immediately — no account, no OAuth flow, no credit card. This exists specifically because AI builders generate finished sites before anyone has signed up for anything, so the endpoint has to be obtainable at build time. The response gives you the endpoint URL, a paste-ready HTML snippet, and a claim link, and the address you supplied gets an email so the site owner can claim the dashboard and see the submission history. Do claim it: unclaimed forms are deleted after 7 days. If you already know this form is going into production, signing up normally at formslist.com and creating it from the dashboard is equally fast.
curl -X POST https://formslist.com/api/v1/instant \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","form_name":"Contact"}'
# Response includes:
# endpoint -> https://formslist.com/f/YOUR_HASH
# html_snippet -> paste-ready form markup
# claim_url -> claim the dashboard (also emailed)
# expires_at -> unclaimed forms are deleted after 7 daysLovable output is React, so replace the body of the existing submit handler with a fetch() call. Keep the markup, keep the styling, keep the loading and success states you already have — you are only giving the handler somewhere real to send data. Send the Accept: application/json header. FormsList chooses its response format from that header: with it you get a JSON body containing ok: true and a submission id, and your component stays on the page so your own success UI can take over. Without it, the endpoint treats the request as a browser form post and answers with a 303 redirect to a hosted thank-you page — right for plain HTML, wrong inside a single-page React app. Use new FormData(form) as the body instead of building an object field by field. Every input with a name attribute is included automatically, so adding a phone number or a budget dropdown later means adding one element and touching nothing else. It also means file inputs work without changing the handler. Finally, make the endpoint a constant at the top of the file (or an environment variable) rather than burying the URL in the middle of the handler, so future-you can find it.
import { useState } from "react";
const FORM_ENDPOINT = "https://formslist.com/f/YOUR_HASH";
export default function ContactForm() {
const [status, setStatus] = useState("idle"); // idle | sending | sent | error
async function handleSubmit(e) {
e.preventDefault();
setStatus("sending");
const form = e.currentTarget;
try {
const res = await fetch(FORM_ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
if (!res.ok) throw new Error("Request failed");
setStatus("sent");
form.reset();
} catch {
setStatus("error");
}
}
if (status === "sent") {
return <p className="text-emerald-600">Thanks — we got your message.</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="How can we help?" required />
{/* Honeypot: hidden from humans, irresistible to bots */}
<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 can also delete the handler entirely. A form element with action and method attributes submits without a single line of JavaScript, which means it keeps working when the bundle fails to load, when hydration errors out, or when a browser extension breaks your scripts — all of which happen more often on generated apps than anyone admits. The browser navigates on submit, and FormsList answers with a 303 redirect to a hosted thank-you page. To send visitors to your own page instead, add a hidden input named _next holding the destination URL. In a React app you will want that URL to point at a route that actually exists in your router, otherwise people land on your 404. This version cannot silently fail, because there is no JavaScript in the path to fail silently in. A common approach is to ship it first, confirm a test message arrives, and only then upgrade to the fetch version for the smoother in-page experience.
<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>
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none" />
<input type="hidden" name="_next" value="https://your-site.example.com/thanks" />
<button type="submit">Send message</button>
</form>While you are building, your app is served from a preview origin. Once you publish, it lives at a different one, and if you attach a custom domain it changes again. That is three origins for the same form. This matters because browsers block cross-origin POSTs unless the receiving server explicitly permits the origin making the request. A hand-rolled backend would need every one of those origins allowlisted, and preview origins are exactly the kind that change without warning. FormsList endpoints accept cross-origin POSTs from any origin by default, so the same endpoint works in preview, in the published app, and on your custom domain with no configuration and nothing to update when the domain changes. If you later want to restrict it, form settings include an allowed-domains option — enable it only once your final domain is live, and include every origin you genuinely submit from, or you will lock yourself out of your own preview.
Spam protection: the hidden _gotcha input in the examples above is a honeypot. Bots fill in every field they can find; humans never see this one because it is display:none. A submission arriving with it filled is flagged. It requires no third-party script, no checkbox, and no image puzzle, so it costs your real visitors nothing. Automatic filtering runs on top of it on every plan, free included. If you want a visible challenge as well, endpoints accept reCAPTCHA, Turnstile, and hCaptcha tokens. Email notifications: included on every plan, free included. There is no tier where your messages are held in a dashboard until you upgrade. Submissions are stored as well as emailed, so a message is still recoverable if a notification is filtered into spam. Attachments: add enctype="multipart/form-data" to a plain HTML form and include a file input — a fetch call with FormData sets the encoding for you. Free accepts 1 file up to 2MB per submission; paid plans accept up to 5 files at 4MB each. Enough for a brief, a screenshot, or a photo, and small enough that nobody uses your contact form as a file host.
<!-- Attachment-enabled variant -->
<form action="https://formslist.com/f/YOUR_HASH" method="POST" enctype="multipart/form-data">
<input type="text" name="name" required />
<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>Test from the published app, not just the preview, and send a message you will recognise. Then check both the FormsList dashboard and your inbox. A submission that shows in the dashboard but not your email means the notification address is wrong or the mail was filtered — check the spam folder before concluding the form is broken. If nothing arrives at all, open the browser network tab and inspect the POST. A 404 means the hash in the URL is wrong or the form no longer exists — worth remembering if you provisioned it instantly and let the 7-day claim window lapse. A 422 means server-side validation rejected a field. FormsList error responses include an error code plus a fix field spelling out the corrective action, so debugging is usually a matter of reading the response body. A GET request to your endpoint returns JSON usage documentation for that specific form: accepted content types, expected fields, and an example request. It is the quickest confirmation that an endpoint is alive. Broader reference lives at formslist.com/docs, the programmatic API at formslist.com/form-api, and formslist.com/static-site-forms covers the same pattern for sites with no build step at all.
# Is the endpoint alive, and what does it expect?
curl https://formslist.com/f/YOUR_HASH
# Fire a test submission
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":"Checking delivery"}'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.
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 moreAsk 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 moreLearn how to process form submissions on any website without writing server-side code. Use a form backend service to receive, store, and forward 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.