Beginner9 minUpdated Aug 14, 2026

How to Add a Working Contact Form to a Bolt.new Site

Bolt.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.

TL;DR

A contact form generated by Bolt.new is frontend-only, so submissions have nowhere to go once deployed. Point the form at a FormsList endpoint: action="https://formslist.com/f/YOUR_HASH" method="POST" for plain HTML, or fetch() with Accept: application/json in the React handler. Nothing to install, nothing to deploy. Free plan: 5 forms, 500 submissions/month, email notifications included.

By Vaibhav Jain·Published August 14, 2026

Prerequisites

  • A project built with Bolt.new
  • The ability to edit the contact form component or page markup
  • An email address where submissions should be delivered
1

Why the form works in the editor but delivers nothing

Bolt.new builds and runs your project inside the browser, which is genuinely impressive and also the source of the confusion. You watch a dev server start, you click around a working app, you submit the contact form and see a success message. Everything behaves like a real, finished application, because in every visible respect it is one. What is missing is the part of a contact form that was never in the project to begin with. Delivering a message requires a server listening at a public URL to receive the POST, durable storage so the payload survives the request, and an authenticated mail relay — a provider account with a verified sending domain and DNS records proving you may send as it. A frontend project has none of these, and it cannot have them, because credentials shipped to the browser are readable by anyone who opens devtools. So the generated handler does the only thing it can: it sets a state variable and renders "Thanks, we'll get back to you." If Bolt did scaffold a server route, that route runs in the in-browser dev environment during development and simply is not there in a static deployment. Either way the visitor is told their message was sent, you never receive it, and there is no error anywhere to tell you that is happening.

// The shape of the problem: a handler with nowhere to send data.
async function handleSubmit(e) {
  e.preventDefault();
  setSending(true);
  // Nothing here is talking to a server that exists in production.
  await new Promise((r) => setTimeout(r, 800)); // fake latency
  setSending(false);
  setSent(true); // the visitor believes the message was delivered
}
2

Get an endpoint that exists in production

What you need is a URL that is up regardless of your project — one that accepts POST requests, keeps the submission, filters spam, and emails you. FormsList provides that, and you can get one without leaving the terminal. A single POST to the instant provisioning endpoint, supplying the email that should receive submissions and a name for the form, returns a live endpoint immediately: no account, no OAuth, no card. The reason this exists is exactly the situation you are in — AI builders produce finished sites before anyone signs up for anything, so the endpoint has to be obtainable at build time rather than after. The response contains the endpoint URL, a paste-ready HTML snippet, and a claim link, and that email address also receives a claim message so the site owner can take over the dashboard and browse submission history. Unclaimed forms are deleted after 7 days, so claim it once the site is real. If you would rather start with an account, signing up at formslist.com and creating the form from the dashboard takes about the same time.

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  -> paste-ready markup
#   claim_url     -> claim the dashboard (also emailed)
#   expires_at    -> unclaimed forms are deleted after 7 days
3

Point the React component at the endpoint

Bolt output is typically a React project built with Vite, so the change is inside your existing submit handler. Keep the markup and styling — you are swapping a fake submission for a real one. Include the Accept: application/json header. FormsList picks its response format from that header: with it, you get JSON back containing ok: true and a submission id, and the browser stays put so your own success state renders. Without it, the endpoint treats the request as a browser form post and replies with a 303 redirect to a hosted thank-you page, which is correct for plain HTML and wrong inside a single-page app. Pass new FormData(form) as the body rather than assembling an object. Every named input is picked up automatically, so a new field is one element and no handler changes, and file inputs work without touching the code. If your project uses Vite environment variables, put the endpoint in one prefixed with VITE_ so it is available in the client bundle — and remember to set the same variable wherever you deploy, or production will point at nothing.

import { useState } from "react";

const FORM_ENDPOINT = "https://formslist.com/f/YOUR_HASH";
// Or, with Vite env vars: import.meta.env.VITE_FORMSLIST_URL

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" }, // stay on the page
        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 — your message came through.</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: hidden field bots fill in and humans never see */}
      <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>
  );
}
4

The zero-JavaScript version

You can drop the handler completely. A form element with action and method attributes submits natively, with no bundle involved. On a generated project this is worth taking seriously: if your JavaScript throws during hydration or a chunk fails to load, a fetch-based form dies silently while a native form keeps working. The browser navigates on submit and FormsList answers with a 303 redirect to a hosted thank-you page. Add a hidden input named _next with your own URL to control where visitors land instead. In a single-page app, make sure that URL corresponds to a route your router actually serves, and that your host is configured to serve it directly rather than 404ing on a deep link. This version has the fewest moving parts of anything you can ship. A reasonable sequence is to deploy it, confirm a test message arrives, then upgrade to the fetch version now that you know the endpoint half is correct.

<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>
5

Test on the deployed site, not only in the editor preview

The in-editor preview and your deployed site are different origins, and they are different in ways that matter. The preview runs inside the builder's sandboxed environment; the deployed site runs on ordinary hosting at a domain you control. A form can behave differently in each, and the deployed behaviour is the one your visitors get. Cross-origin rules are the usual reason a self-hosted backend works in one place and not the other: browsers block a cross-origin POST unless the receiving server explicitly allows that origin, and sandboxed preview origins are not something you can practically allowlist ahead of time. FormsList endpoints accept cross-origin POSTs from any origin, so the same endpoint works from the preview, from the deployed site, and from a custom domain later, with nothing to configure. If you do enable the allowed-domains restriction in form settings, do it after your production domain is live and list every origin you actually submit from. Otherwise your next preview test will be rejected and you will spend an hour debugging a restriction you added yourself.

6

Spam protection, notifications, and file uploads

Spam protection: the hidden _gotcha input is a honeypot. Automated submitters fill every field they can find, so a submission with that field populated is flagged; real visitors never see it because it is display:none. There is no third-party script, no checkbox and no image puzzle, so legitimate visitors pay nothing for it. Automatic filtering runs alongside it on every plan including free, and endpoints also accept reCAPTCHA, Turnstile, and hCaptcha tokens if you want a visible challenge. Email notifications: included on every plan, free included. Messages are never held in a dashboard pending an upgrade. Every submission is stored as well as emailed, so nothing is lost if a notification gets filtered. File uploads: add enctype="multipart/form-data" to a plain HTML form and include a file input; a fetch call with FormData sets the encoding automatically. Free accepts 1 file up to 2MB per submission, paid plans accept up to 5 files at 4MB each — enough for a screenshot, a brief, or a resume without turning your contact form into file storage.

<!-- Uploads: multipart encoding + a file input -->
<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>
7

Confirm delivery and debug from the response

Send a recognisable test message from the deployed site, then check the FormsList dashboard and your inbox. Present in the dashboard but absent from email means the notification address is wrong or the message was filtered — check spam before assuming a bug. If nothing arrives, open the network tab and read the POST response. A 404 means the hash in your URL is wrong or the form is gone, which is worth remembering if you provisioned instantly and let the 7-day claim window lapse. A 422 means server-side validation rejected a field. Error responses carry an error code plus a fix field describing the corrective action, so the response body usually tells you exactly what to change. A GET request to the endpoint returns JSON usage documentation for that specific form — accepted content types, expected fields, an example request — which is the fastest way to confirm an endpoint is live. Wider reference: formslist.com/docs for setup, formslist.com/form-api for the programmatic API, formslist.com/static-site-forms for the same pattern on sites with no build step, and formslist.com/ai if you are wiring this into an agent or builder rather than a single project.

# Endpoint alive? What does it accept?
curl https://formslist.com/f/YOUR_HASH

# Send 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":"Verifying delivery"}'

Frequently Asked Questions

Ready to collect form submissions?

Set up your form backend in under a minute. No server required, no complex configuration — just a simple endpoint for your forms.