splitforms.com
← Back to the journal

Contact Forms for AI-Built Websites: Claude, Cursor, Lovable

Add a working contact form to Claude, Cursor, Lovable, v0, or Bolt with copy-paste HTML and one hosted endpoint. No backend required.

Start free — 500 submissionsSee pricing →No card, no time limit. Pro is $5/mo.
Short answer
AI site builders generate the form but not the server that receives it, so submit does nothing. Fix: set the form's action to https://splitforms.com/api/submit (method POST) and add a hidden access_key input, or fetch() the same URL with FormData. No API route, no Resend, no Supabase table. Below: the exact prompt for Claude Code, Cursor, Lovable, v0, and Bolt.new, the code each one should produce, and the failure mode each one commonly falls into.
Try it on your own form

Point any HTML form at one endpoint. 500 free submissions — no card, no time limit — and unlimited forms.

Create free account

Why the form does nothing (and the one-line fix)

The form does nothing because there is no backend: every AI code tool renders the inputs and the button, then either leaves the submit handler as a placeholder or scaffolds a server piece that needs an email API key you never set. The fix is to give the form a destination that already exists — a form backend at https://splitforms.com/api/submit that stores the submission, emails you, and filters spam on the free plan.

The plain-HTML version, identical to the one on the contact form API page:

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
  <input type="hidden" name="form_loaded_at" />
  <input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
  <input name="name" required />
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

<script>
  // time-trap: stamp the load time so instant bot submits get flagged
  document.querySelector('[name="form_loaded_at"]').value = Date.now();
</script>

Get YOUR_ACCESS_KEY by creating a free form — 500 submissions total, unlimited forms, no card. The key is a public identifier for one form, so it belongs in the HTML. The two extra hidden fields are spam protection and are explained in the verification section below.

Everything that follows is the same fix, phrased for each tool. If you just want the prompt: copy the block from your tool's section, replace the placeholder key, paste it into the chat. Once you have an account, the dashboard's Copy AI prompt button fills in your key, and the docs have one-click copies plus an AGENTS.md / .cursorrules snippet.

Claude Code

In Claude Code, paste the prompt below and it will add a client component that POSTs FormData to https://splitforms.com/api/submit, with no route.ts. The "do not" list matters: left to its own judgement the agent commonly scaffolds an API route plus Nodemailer or Resend, which then fails until you add SMTP or an API key to .env.

Add a contact form (name, email, message) that POSTs to
https://splitforms.com/api/submit with a hidden input named access_key
set to "YOUR_ACCESS_KEY". Do not create an API route, server action,
email service, database table, or environment variable — the endpoint
handles storage and email. Include a hidden checkbox named "botcheck"
(display:none, tabindex=-1) that stays empty, and a hidden input named
"form_loaded_at" set to Date.now() when the page loads. Show a success
message when the JSON response has success: true.

Put it in the existing contact page as a client component that submits
with fetch() and FormData; keep the site's current styles.

What it should produce for a Next.js App Router project (the same component documented on Next.js contact form):

"use client";
import { useState } from "react";

export default function ContactForm() {
  const [status, setStatus] = useState<"idle" | "loading" | "ok" | "err">("idle");

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus("loading");
    const form = e.currentTarget; // capture before the await — React clears currentTarget afterwards
    const formData = new FormData(form);
    formData.append("access_key", "YOUR_ACCESS_KEY");

    const res = await fetch("https://splitforms.com/api/submit", {
      method: "POST",
      body: formData,
    });
    const data = await res.json();
    setStatus(data.success ? "ok" : "err");
    if (data.success) form.reset();
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text"  name="name"    required />
      <input type="email" name="email"   required />
      <textarea           name="message" required />
      <input type="checkbox" name="botcheck" style={{ display: "none" }} tabIndex={-1} />
      <button type="submit" disabled={status === "loading"}>
        {status === "loading" ? "Sending…" : "Send"}
      </button>
      {status === "ok"  && <p>Thanks! We'll be in touch.</p>}
      {status === "err" && <p>Something went wrong. Try again?</p>}
    </form>
  );
}

Common failure mode: the agent puts the key in process.env.SPLITFORMS_KEY. In Next.js, a variable without the NEXT_PUBLIC_ prefix is undefined in a client component, so the POST goes out with an empty access_key and the endpoint answers 400 "Missing access_key". Either hardcode the key or use NEXT_PUBLIC_SPLITFORMS_KEY. There is also an MCP shortcut for Claude Code — see below.

Cursor

In Cursor, select the contact page, open the agent, and paste the prompt; it will write a React component that fetch()es https://splitforms.com/api/submit. Cursor's agent reaches for the same API-route-plus-email-SDK pattern as Claude Code, so the prompt says not to.

Add a contact form (name, email, message) that POSTs to
https://splitforms.com/api/submit with a hidden input named access_key
set to "YOUR_ACCESS_KEY". Do not create an API route, server action,
email service, database table, or environment variable — the endpoint
handles storage and email. Include a hidden checkbox named "botcheck"
(display:none, tabindex=-1) that stays empty, and a hidden input named
"form_loaded_at" set to Date.now() when the page loads. Show a success
message when the JSON response has success: true.

Make it a React component using fetch() and FormData. Hardcode the
access_key in the component — it is a public identifier, not a secret.

The expected component, as documented on React contact form:

import { useState } from "react";

export default function ContactForm() {
  const [status, setStatus] = useState("idle");

  async function onSubmit(e) {
    e.preventDefault();
    setStatus("loading");
    const formData = new FormData(e.target);
    formData.append("access_key", "YOUR_ACCESS_KEY");

    const res = await fetch("https://splitforms.com/api/submit", {
      method: "POST",
      body: formData,
    });
    const json = await res.json();
    setStatus(json.success ? "ok" : "err");
    if (json.success) e.target.reset();
  }

  return (
    <form onSubmit={onSubmit}>
      <input type="text"  name="name"    placeholder="Name"    required />
      <input type="email" name="email"   placeholder="Email"   required />
      <textarea           name="message" placeholder="Message" required />
      <input type="checkbox" name="botcheck" style={{ display: "none" }} tabIndex={-1} />
      <button type="submit" disabled={status === "loading"}>
        {status === "loading" ? "Sending..." : "Send"}
      </button>
      {status === "ok"  && <p>Thanks!</p>}
      {status === "err" && <p>Error sending. Please try again.</p>}
    </form>
  );
}

Common failure mode: the agent sets a manual Content-Type: application/json header while still sending FormData, or JSON-stringifies the body but forgets access_key at the top level. The endpoint accepts multipart/form-data, application/x-www-form-urlencoded, and application/json — just let the browser set the header for FormData. Details and error codes are on the API reference.

Lovable

In Lovable, paste the prompt into the chat and the generated React component posts straight to https://splitforms.com/api/submit from the browser — no Supabase connection required. Lovable's own guidance for a "working" form is Supabase for storage, Resend for email, and an edge function between them, which is three moving parts and two dashboards for what is one POST.

Add a contact form (name, email, message) that POSTs to
https://splitforms.com/api/submit with a hidden input named access_key
set to "YOUR_ACCESS_KEY". Do not create an API route, server action,
email service, database table, or environment variable — the endpoint
handles storage and email. Include a hidden checkbox named "botcheck"
(display:none, tabindex=-1) that stays empty, and a hidden input named
"form_loaded_at" set to Date.now() when the page loads. Show a success
message when the JSON response has success: true.

Do not connect Supabase or add an edge function for this form — it posts
directly to the external endpoint from the browser.

Lovable generates React (Vite) apps, so the output is the same React component shown in the Cursor section, styled with the project's Tailwind classes. Keep every input's name attribute — those become the field names in your dashboard and notification email.

Common failure mode: if the project is already connected to Supabase, Lovable may still offer to "save submissions to a table" and route the form through an edge function. Decline; the form works without it, and if you want a copy in Supabase later, a Pro webhook does that without an edge function. The longer Lovable walkthrough is at contact form for Lovable and add a contact form to your Lovable app.

v0 (Vercel)

In v0, paste the prompt and it generates a Next.js client component that fetch()es https://splitforms.com/api/submit; the same component works in v0's preview, locally, and on Vercel because nothing runs on your server. v0's default for "make the form work" is a server action using Resend and process.env.RESEND_API_KEY, which is fine once the variable exists in Vercel and a Resend domain is verified — and silently broken in preview until then.

Add a contact form (name, email, message) that POSTs to
https://splitforms.com/api/submit with a hidden input named access_key
set to "YOUR_ACCESS_KEY". Do not create an API route, server action,
email service, database table, or environment variable — the endpoint
handles storage and email. Include a hidden checkbox named "botcheck"
(display:none, tabindex=-1) that stays empty, and a hidden input named
"form_loaded_at" set to Date.now() when the page loads. Show a success
message when the JSON response has success: true.

Use a client component with fetch(), not a server action. No Resend,
no RESEND_API_KEY, no route handler.

The expected output is the Next.js component from the Claude Code section. If v0 still emits a "use server" action, ask it to "replace the server action with a client fetch to the same URL".

Common failure mode: the key stored as a non-public env var (same NEXT_PUBLIC_ issue as Claude Code), or a server action that never ran because the env var was not set in the v0 preview. Per-tool details: add a form backend to a v0 app.

Bolt.new

In Bolt.new, paste the prompt and submit with fetch(); a fetch POST to an external HTTPS endpoint works inside StackBlitz's WebContainer exactly as it does after you publish. Bolt commonly scaffolds an Express server.js with Nodemailer when asked for a working form, which then needs SMTP credentials and a process to keep running — none of which is needed here.

Add a contact form (name, email, message) that POSTs to
https://splitforms.com/api/submit with a hidden input named access_key
set to "YOUR_ACCESS_KEY". Do not create an API route, server action,
email service, database table, or environment variable — the endpoint
handles storage and email. Include a hidden checkbox named "botcheck"
(display:none, tabindex=-1) that stays empty, and a hidden input named
"form_loaded_at" set to Date.now() when the page loads. Show a success
message when the JSON response has success: true.

Submit with fetch() from the browser rather than a native form action so
the preview does not navigate away. Do not add an Express server or
nodemailer.

For a static Bolt project the plain-HTML snippet at the top of this page works too, with one caveat.

Common failure mode: Bolt's preview runs in an iframe. A native <form action="…"> POST navigates that frame to the endpoint's response (JSON, or your redirect URL), which looks like the app "broke". In production it is the normal post-redirect flow; in the preview it is confusing. Use fetch() during development, or set a redirect hidden input to a thank-you page so the navigation is intentional. More at add a contact form to a Bolt.new app.

Any other tool (Replit, Windsurf, Copilot, Base44, Framer AI…)

Any tool that writes HTML or JSX can use the base prompt unchanged; the endpoint is language-agnostic and the only requirements are method POST, the access_key in the body, and a name on every field.

Add a contact form (name, email, message) that POSTs to
https://splitforms.com/api/submit with a hidden input named access_key
set to "YOUR_ACCESS_KEY". Do not create an API route, server action,
email service, database table, or environment variable — the endpoint
handles storage and email. Include a hidden checkbox named "botcheck"
(display:none, tabindex=-1) that stays empty, and a hidden input named
"form_loaded_at" set to Date.now() when the page loads. Show a success
message when the JSON response has success: true.

Framework pages with ready-made snippets: Next.js, React, Astro, and the rest of the platform guides. The comparison of backends for this use case is at best form backends for AI website builders.

The Claude Code / Cursor shortcut: let the agent create the form

With the splitforms MCP server connected, Claude Code or Cursor can create the form on your account itself — no key to copy. The server runs at https://splitforms.com/api/mcp with a bearer token from Dashboard → MCP, and works on every plan including Free. Once connected, run the bundled build_form prompt (/mcp__splitforms__build_form in Claude Code) with a one-line description; the agent calls create_form, gets the access_key back, and writes the component. add_form_to_site then detects your framework and inserts it.

Setup snippets for Claude Code, Cursor, Windsurf, and Claude Desktop, the full 12-tool list, and a worked example are in best MCP servers for building forms with AI agents; the product page is /mcp. If your agent is not on an MCP host, the AI agent form backend and ChatGPT form backend pages cover the same endpoint through plain instructions.

Verify it works, then keep the spam out

Verification is one submission: send it from the tool's preview (or with curl) and it appears in the splitforms dashboard immediately, with the owner notification email following on every plan.

curl -X POST https://splitforms.com/api/submit \
  -F access_key=YOUR_ACCESS_KEY \
  -F name="Test" -F email="[email protected]" -F message="Hello from curl"

The JSON response is { "success": true, "message": "..." } on 200 — there is no submission-ID field. The errors you will actually see come back as { "success": false, "message": "...", "code": "..." }: 400 "Missing access_key" (the input is outside the form, or uses id instead of name), 404 "Invalid access_key" (whitespace, a rotated key, or the MCP token pasted by mistake), 415 (a manual Content-Type that does not match the body), and 429 (per-IP limit of 6/min or 20/15min — usually a handler firing on every keystroke).

Spam protection is two hidden fields, both already in the snippet at the top and both checked server-side on the free plan:

  • botcheck honeypot. A checkbox hidden with display:none and tabindex="-1". Humans never see it; bots that fill every field they find tick it, and the submission is dropped. Use the literal name botcheck (or another name from the recognized list, such as _gotcha). How and why it works: what is a honeypot field.
  • form_loaded_at time-trap. A hidden input your page stamps with Date.now() on load. A submission that arrives less than 2 seconds after that stamp (or more than 24 hours after) was not typed by a person and is flagged. In React, set it in the component's initial render or append form_loaded_at to the FormData from a value captured on mount.

Both are free, invisible, and need no CAPTCHA. If the site is public and busy, also lock the form to your domain in the dashboard (Allowed Domains) so a copied key cannot be used from elsewhere. The full layer list is on the spam-free contact form page.

FAQ

Why does my AI-generated contact form do nothing when I click submit?

Because the tool generated the front-end only. A form needs an HTTP endpoint that accepts the POST, stores it, and emails you, and Claude Code, Cursor, Lovable, v0, and Bolt.new all leave that part as a placeholder handler, a console.log, or a scaffold that needs an email API key you have not set. Point the form at https://splitforms.com/api/submit with a hidden access_key and it works in preview and production.

What is the one-line fix?

Set the form's action to https://splitforms.com/api/submit with method POST and add <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">. For React-style apps, append access_key to the FormData and fetch() the same URL. Nothing else is required; spam filtering and owner email are on every plan.

Is it safe to put the access_key in client-side code?

Yes. The access_key is a public identifier for one form, like a Formspree form ID, not a secret. Abuse is limited server-side by per-IP rate limits (6/min, 20/15min), the botcheck honeypot, the form_loaded_at time-trap, allowed-domain locking, and rule-based spam scoring. Your account's MCP/API token is the secret; never put that in a form.

Do I need Supabase, Resend, or an API route?

No. Those are the defaults each tool reaches for when you ask for a working form. For a contact or lead form you only need somewhere to POST. Keep Supabase for auth and app data if you use it; splitforms receives the form, emails you, and can webhook a copy into Supabase, Airtable, or Notion on Pro.

Will the form work in the tool's preview and after I publish?

Yes, because the browser posts directly to splitforms; there is no server in your project to deploy or keep alive. The exception is Bolt.new's iframe preview with a native form action: the response navigates the preview frame, so submit with fetch() there (the Bolt prompt above says so).

Can the AI create the form for me instead of me copying a key?

Yes on Claude Code, Cursor, Windsurf, or Continue with the splitforms MCP server connected (free on every plan). The build_form prompt calls create_form to provision the form on your account and returns paste-ready markup with the access_key filled in; add_form_to_site inserts it into your project. See the MCP servers guide for setup.

How much does splitforms cost for an AI-built site?

Free is 500 submissions total with unlimited forms and no card. Pro is $5/month for 5,000 submissions and adds webhooks, integrations, the auto-responder, and exports. Annual is $49/year for 15,000 submissions a month with the same features.

The submission arrived in the dashboard but no email came. What now?

The API worked, so the problem is mail. Check spam and promotions, confirm Settings → Email → Email to is a real mailbox, and on Microsoft 365 ask IT to safelist @splitforms.com. Submissions quarantined as spam do not send a notification; look for them in the dashboard's spam view.

Next steps

Create a free form to get an access key, then paste your tool's prompt. When email alone is not enough, Pro ($5/month) adds signed webhooks — form to webhook — an auto-responder, and exports. Full endpoint docs live at /docs; pricing at /pricing.

Related articles

More practical guidance from tutorials.

Browse the journal →
Tutorials

How to Add a Working Contact Form to a Bolt.new App (2026)

Bolt.new builds a full-stack app from a prompt, but the contact form still needs somewhere t…

9 min readRead →
Tutorials

Add a Form Backend in Cursor with the splitforms MCP (2026)

Stop hand-rolling a form API in Cursor. Connect the splitforms MCP server and let the agent…

9 min readRead →
Tutorials

Give Claude Code a Form Backend via MCP (2026 Walkthrough)

Connect the splitforms MCP server to Claude Code so the agent can create forms, read submiss…

10 min readRead →

Explore this topic

Start with the overview, then move into focused guides.

OverviewWhat Is a Form Backend? (Complete Guide)Start here →GuideSelf-Hosted vs SaaS Form BackendRead →GuideForm Backend vs Form BuilderRead →GuideWhere Do Form Submissions Go?Read →ReferenceForm backend guideOpen →

Building forms with ChatGPT, Claude, Cursor, or v0? Connect the native MCP server and give your agent a production form backend.

Explore the MCP server →

Give your form a production backend.

One endpoint adds delivery, spam filtering, storage, and integrations. Start with 500 free submissions — no card, no time limit.

Create free accountRead the docs →
Secure checkoutSSL encryptionPrivacyProtected
VISAAMERICANEXPRESSstripe