Skip to content
Lovable Field Guide
Start here

How to add a contact form to a Lovable app

A Lovable contact form needs an endpoint to POST to. Compare an edge function plus Resend, a hosted form backend, and a keyless HTML endpoint.

12 min read

A contact form is two problems wearing one coat. The visible part (labels, inputs, a submit button) takes a Lovable prompt and about ninety seconds. The invisible part is an HTTP endpoint that can hold a secret, write the submission somewhere durable, and tell you it happened. A frontend has nowhere to POST to, which is why so many of these forms look finished and do nothing.

There are three honest ways to fix that, and this post walks all three: build the handler yourself, rent one from a form backend, or post straight to a hosted endpoint from plain HTML. The last section covers the work you own regardless (validation, accessibility, and spam) because none of the three does it for you.

First, work out which stack you’re on

This changes where your handler lives, so check before you prompt.

On 13 May 2026 Lovable changed the default stack for new projects from React + Vite to TanStack Start with SSR on Cloudflare Workers. Older projects were not migrated and still run as client-rendered SPAs. The two put server code in different places:

  • React + Vite (pre-13 May 2026). Server logic lives in separately deployed edge functions, and secrets live in the backend’s secret store.
  • TanStack Start (new default). Server logic lives in the app itself through TanStack server functions (createServerFn), and secrets arrive at request time as Cloudflare Workers bindings.

Read package.json rather than guessing:

grep -E '"(@tanstack/react-start|@tanstack/react-router|react-router-dom)"' package.json

@tanstack/react-start means the new stack. Only react-router-dom means the old one. Lovable’s own docs contradict each other on this (the deployment and hosting page still describes projects as “standard Vite + React”) so the package manifest is the only reliable answer. Some of the same split shows up in how these apps get crawled, which is covered in our guide to Lovable SEO.

Here is the shape of the decision.

Own handler Hosted form backend Plain HTML POST to a hosted endpoint
Time to first working submission An hour or two 10–20 minutes Minutes
Submissions live in your database Yes No (export or webhook them) No (export or webhook them)
Spam handling You build it Included, varies by vendor Included, varies by vendor
Email deliverability Your problem (domain, DNS, from address) Vendor’s problem Vendor’s problem
Needs JavaScript Yes Usually Not for the submission
Survives leaving Lovable hosting Yes, if the backend comes too Yes Yes

Option 1: your own handler, storing the row and sending the mail

This is the most control and the most work. Nothing about it is exotic, but there are five moving parts and every one of them has a way to fail silently.

The table, and the row-level security trap

Start with storage, because email you can retry and a lost submission you cannot.

create table public.contact_submissions (
  id          uuid primary key default gen_random_uuid(),
  created_at  timestamptz not null default now(),
  name        text not null,
  email       text not null,
  message     text not null,
  source_path text,
  consent     boolean not null default false
);

alter table public.contact_submissions enable row level security;
-- Deliberately no policies. Nothing holding the anon key can read or write
-- this table. The function writes with the service role, which bypasses RLS.

The alternative design (let the browser insert directly with the anon key) is the one that produces the most confused bug reports. It needs an explicit insert policy, and if you forget it the insert is rejected at the database while your UI cheerfully shows a success toast. The symptom to look for is a 403 in the network tab or Postgres error code 42501. An insert policy with no matching select policy is usually what you want there: the browser can write, and nobody can read the table back.

Third-party audits of Lovable apps report that the security scan checks whether RLS is enabled rather than whether your policies are restrictive (as of September 2026 Lovable’s docs don’t describe what the scan tests) so a green check is not evidence that the table is safe. Writing through a function sidesteps the whole category.

The handler

On a React + Vite project this is an edge function. The runtime is Deno, so you get Deno.serve and Deno.env.

One dependency to state before you copy it: the file path and the environment variable names below are the ones you get on a project connected to your own Supabase: the supabase/functions/... CLI directory and the SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY pair. On Lovable Cloud there is no Supabase account, org, project or dashboard to open; the function, its secrets and the database viewer all live in the Cloud tab of the Lovable editor, so you create the function there and supply the service-role credential through the Cloud secrets section. As of September 2026 Lovable’s docs don’t publish the variable names Cloud injects into a function, so confirm what yours is called before assuming these two.

// supabase/functions/contact/index.ts
import { createClient } from 'jsr:@supabase/supabase-js@2'

const ORIGIN = 'https://example.com' // your published domain, not '*'
const cors = {
  'Access-Control-Allow-Origin': ORIGIN,
  'Access-Control-Allow-Headers': 'content-type',
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
}
const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body), {
    status,
    headers: { ...cors, 'content-type': 'application/json' },
  })

const str = (v: unknown, min: number, max: number) =>
  typeof v === 'string' && v.trim().length >= min && v.trim().length <= max
    ? v.trim()
    : null

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') return new Response(null, { headers: cors })
  if (req.method !== 'POST') return json({ error: 'method_not_allowed' }, 405)

  let body: Record<string, unknown>
  try {
    body = await req.json()
  } catch {
    return json({ error: 'bad_json' }, 400)
  }

  // 1. Honeypot. Test for presence, not for a plausible length: a bot that
  //    pastes 5,000 characters into every field must still be caught.
  if (typeof body.company_website === 'string' && body.company_website.trim() !== '') {
    return json({ ok: true })
  }

  // 2. Timing. A form typed by a person takes longer than two seconds.
  //    Requires the hidden rendered_at input shown in the honeypot markup
  //    later in this post. Fail OPEN: if it is missing, skip the check rather
  //    than silently discarding a real message.
  const rendered = Number(body.rendered_at)
  if (Number.isFinite(rendered) && rendered > 0) {
    if (Date.now() - rendered < 2_000) return json({ ok: true })
  }

  // 3. Validation. The client-side checks are a courtesy. These are the rules.
  const name = str(body.name, 1, 120)
  const email = str(body.email, 3, 254)
  const message = str(body.message, 10, 5_000)
  const errors: Record<string, string> = {}
  if (!name) errors.name = 'Enter your name.'
  if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
    errors.email = 'Enter an email address we can reply to.'
  }
  if (!message) errors.message = 'Tell us at least a sentence.'
  if (Object.keys(errors).length) return json({ errors }, 400)

  // 4. Store first.
  const db = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
  )
  const { error } = await db.from('contact_submissions').insert({
    name,
    email,
    message,
    source_path: str(body.source_path, 1, 300),
    consent: body.consent === true,
  })
  if (error) {
    console.error('insert_failed', error)
    return json({ error: 'storage_failed' }, 500)
  }

  // 5. Notify second, and never fail the request on a mail error.
  const sent = await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      from: 'Website <hello@yourverifieddomain.com>',
      to: ['you@yourcompany.com'],
      reply_to: email,
      subject: `Contact form: ${name}`,
      text: `${name} <${email}>\n\n${message}`,
    }),
  })
  if (!sent.ok) console.error('resend_failed', sent.status, await sent.text())

  return json({ ok: true })
})

Two details worth defending. The ordering is store-then-send so a provider outage costs you a notification and you keep the lead. And the honeypot and timing rejections return 200 ok, because telling a bot which check it failed is free tuning data for whoever wrote it.

On a TanStack Start project the same logic goes in a server function instead (createServerFn({ method: 'POST' }).handler(...)) and you call it like a normal function from the component. No CORS block, no separate deploy, same validation.

The from-address trap

The single most common way this setup half-works: the form sends, the row appears, and the email only ever reaches you.

Resend delivers to your app’s users only from a domain you have verified in Resend. Before that, testing runs through resend.dev, which reaches the Resend account owner and nobody else. Lovable’s Resend connector cannot do DNS or domain verification for you (you do that in Resend first) and once the domain is verified you still have to change the from address in the function and redeploy, because that value is code and code does not update itself. Two more limits on that connector: it cannot receive webhooks through Lovable, and one shared Resend account covers every project linked to it. The full Resend setup is its own job.

Be honest about the total

Function, table, RLS policy review, server-side validation, honeypot and timing, per-IP rate limiting, a notification that actually lands, and somewhere to read submissions when your inbox eats one. Per-IP rate limiting in particular is not free: you need a counter table or a KV store and a cleanup job, because an edge function has no memory between invocations.

That is a real afternoon, and it is the right call when the submissions have to live in your database, when an admin screen reads them, when they join against user accounts, or when they feed something else you run. It is the wrong call for a marketing page with a “get in touch” box. Other Lovable integration paths follow the same rule: build it yourself when the data is part of your product.

Option 2: rent a form backend

A form backend gives you a URL. You point action at it, it stores the submission, filters spam, and emails you. Real options: Formspree, Basin, Fillout, Formester, and the embed-style products Tally and Typeform if you’re happy handing over the markup too. Pricing and free-tier submission caps move around, so check them on the day rather than trusting any blog post, including this one.

What to actually evaluate:

  1. Does the free tier’s monthly submission cap survive a bot wave? A form that stops accepting real messages after a scripted burst is worse than no form.
  2. Do you get a submissions dashboard, or only email? Email alone means one spam-folder mistake is a lost lead with no record.
  3. Field-name discipline. Most backends key on the input name attribute. Rename an input during a Lovable re-prompt and submissions keep arriving: empty. This is a genuinely nasty silent failure; it is worth a quick end-to-end test after any prompt that touches the form.
  4. What happens when you leave Lovable hosting. A hosted endpoint is a URL, so it comes with you when you export the code and self-host. An edge function on Lovable Cloud does not.

One category to treat carefully: client-side email senders like EmailJS work by putting an identifier in the browser and sending from the page. That is not a secret, and the protection is domain locking and rate limits rather than confidentiality. Fine for a low-volume personal site; know what you’re choosing.

Option 3: LinkyCal, with a plain HTML form

Most people looking for a Lovable contact form want the same thing: a form that notifies them when someone fills it in. LinkyCal does exactly that, and takes away the three jobs that usually come with it: filtering spam, setting up DNS so you can send email, and working out who the lead actually is. Notifications can go to email, Slack, Telegram or WhatsApp, straight into Excel or Google Sheets, or to a webhook.

The other reason this option deserves its own section is that it takes the submission path out of your JavaScript. A native <form action> POST needs no fetch call, no state, no error handling. On a pre-13 May 2026 React + Vite project the page itself is still client-rendered, so the <form> element only exists once the bundle has run, and the markup is still JSX that a later prompt can rewrite, so re-test end to end after any prompt that touches the form.

LinkyCal documents exactly this. You point the form at a per-form endpoint:

<form
  action="https://linkycal.com/api/public/forms/acme/contact/submit"
  method="post"
  enctype="multipart/form-data"
>
  <label for="full_name">Your name</label>
  <input id="full_name" name="full_name" type="text" autocomplete="name" required />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="message">How can we help?</label>
  <textarea id="message" name="message" rows="5" required></textarea>

  <label for="resume">Attach a file (optional)</label>
  <input id="resume" name="resume" type="file" />

  <button type="submit">Send</button>
</form>

The endpoint shape is POST https://linkycal.com/api/public/forms/:projectSlug/:formSlug/submit. The input name values are the field IDs from the form builder, so copy them from there rather than inventing them: this is the same field-name failure mode as any other backend. The enctype attribute is only needed when the form has a file input. After submission you get a hosted thank-you page by default, or a redirect to a URL you configure.

Three things make this specifically suited to a Lovable app:

  • Anonymous visitor endpoints require no API key. Forms, widgets, availability and booking creation are all keyless and rate-limited by IP, so nothing secret ships to the browser. That removes the entire class of bug where a provider key ends up in the bundle.
  • Spam protection is honeypot plus timing checks plus per-IP rate limits, with no CAPTCHA or Turnstile to configure. Documented limits include 30 form-response creations per minute per IP and 60 step submissions per minute per IP.
  • Notifications wherever you’ll see them. Each new submission can notify you by email, Slack, Telegram or WhatsApp, straight into Excel or Google Sheets, or to a webhook. None of it needs a verified sending domain or DNS records.

If you want the submission to reach your CRM or your own API, add a webhook step to a workflow triggered by form_submitted and set the URL, method, headers and body. Add a shared-secret header and check it on your side, so your endpoint only accepts LinkyCal’s calls.

For a React SPA that wants to keep its own UI and stay on the page, the same public submit endpoint accepts a fetch with FormData, and there is a longer multi-step JSON API for branching forms. No install step exists for either: there is no npm package, so it is HTTP calls from whatever you already have.

What happens after the lead comes in

This is where LinkyCal goes further than a plain form backend, and why it’s the best free option we’ve found for contact and lead forms on a Lovable site. Every submission becomes a contact in a light CRM, where you can tag leads, move them through stages and set a next action. Workflows run on each new submission: enrich the lead with company and role details, send an auto-responder, deliver a lead magnet by email, tag it, or push it into your own tools with a webhook or the API.

Let Lovable set it up for you

LinkyCal has an MCP server, so you can connect it to Lovable as a chat connector and let Lovable’s agent do the setup: create the form, add the follow-up workflows and wire the form into your page. The MCP connection is only used while you build. The published form keeps working because it posts straight to LinkyCal’s endpoint.

The free tier covers 500 responses a month, which is plenty for a contact form on a marketing site. And because the form is a plain HTML POST, it keeps working unchanged if you later export the code and host it somewhere other than Lovable. LinkyCal’s forms page has the details, and there’s a comparison with Formspree if you’re weighing the two.

The parts you own whichever backend you pick

A form backend handles transport and storage. It does not make your form correct or usable.

Validate twice, and mean it the second time

Client-side validation is a UX feature: it stops a user submitting an obviously broken email and getting a page reload for their trouble. It is not a security control, because anyone can POST to your endpoint directly with curl. Every rule that matters (required fields, length caps, email shape, allowed file types) has to be enforced server-side too, which for options 2 and 3 means checking what the vendor enforces and treating anything else as advisory.

Length caps deserve a specific mention. Without a maximum on the message field, your table accepts a 40 MB paste and your notification email tries to carry it.

Accessibility, which is also a conversion feature

Four things cover most of it.

Real labels. Every input gets a <label for="..."> bound to the input’s id. A placeholder is not a label: it disappears the moment someone types, and screen readers treat it inconsistently.

Autocomplete attributes. autocomplete="name", email, tel, organization. This is one attribute per field and it is the difference between a phone user tapping once and typing their address from memory.

Errors that screen readers announce. Put the message in an element referenced by aria-describedby on the input, mark the input aria-invalid="true", and render a summary in a container with role="alert" so assistive tech reads it on appear. Move focus to the first invalid field after a failed submit. Never signal an error with red text alone.

<label for="email">Email</label>
<input
  id="email"
  name="email"
  type="email"
  autocomplete="email"
  aria-describedby="email-error"
  aria-invalid="true"
  required
/>
<p id="email-error" class="field-error">Enter an email address we can reply to.</p>

A submit button that changes state. Disable it and say “Sending…” while the request is in flight. Double submissions are otherwise the most common duplicate in every submissions table anywhere.

The honeypot pattern, done properly

A honeypot is a field a human never sees and a naive bot always fills in. Two rules make it work:

// mountedAt is set once, when the form mounts:
// const [mountedAt] = useState(() => Date.now())

<div className="hp" aria-hidden="true">
  <label htmlFor="company_website">Leave this field empty</label>
  <input id="company_website" name="company_website" type="text"
         tabIndex={-1} autoComplete="off" />
</div>
<input type="hidden" name="rendered_at" value={mountedAt} />
/* Off-screen, not display:none: some bots skip hidden fields. */
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

First, aria-hidden="true" plus tabindex="-1" keeps it away from screen readers and keyboard users, so your spam trap does not become an accessibility bug. Second, autocomplete="off" stops a password manager filling it in and getting your real user silently discarded: a failure you will never hear about, because the victim thinks the message sent.

The hidden rendered_at field is what the timing check in the function above reads, and it is the half people forget: without it, a handler that treats a missing timestamp as suspicious drops every real message while the UI still shows success. Set it once from Date.now() when the component mounts. Setting it on every render resets the elapsed time with each keystroke. Between the two checks they stop the overwhelming majority of low-effort form spam without a single CAPTCHA. Neither stops a targeted attacker, and both are a better default than nothing.

If you store the submission, say so next to the button and link your privacy policy. A checkbox is only required where you’re collecting consent for something beyond answering the message (marketing, in practice) and if you use one, persist the value with the row rather than just gating the button. Log enough to debug (timestamp, path, a hashed IP if you rate limit) and no more.

What to do next

Open your published site, submit your existing form with the browser network tab open, and read the response. No request means there is no backend and you are choosing between the three options above. A 403 or 42501 means an RLS policy is eating the insert. A 200 with no email means the from-address problem, so check whether your sending domain is verified.

Then pick on one question: do these submissions need to live in your database? If yes, build the function and budget the afternoon. If no, point the form at a hosted endpoint and spend the afternoon on the page the form is sitting on.

Found something wrong or out of date? Lovable changes fast and we'd rather fix a guide than let it rot.

Disclosure: the team behind this guide also builds LinkyCal, mentioned above.

Frequently asked questions

Why does my Lovable contact form say it sent but I never get an email?
A browser form needs a server-side endpoint to POST to, and a frontend alone has none, so the submit button can show a success state while nothing leaves the page. Open your browser's network tab, submit the form, and read the actual response: no request at all means there is no backend, and a 403 or Postgres error 42501 means a row-level security policy rejected the insert.
Can I add a contact form to a Lovable app without a database?
Yes. Point the form at a hosted form backend such as LinkyCal or Formspree and it stores the submission and emails you. You only need your own database if you want the submissions inside your app, for an admin list, a CRM view, or to join them against other data.
Where do I put the Resend API key in a Lovable contact form?
In a backend secret, never in client code. Lovable stores backend secrets in the Cloud secrets section, or syncs them to your Supabase edge function secrets if you brought your own Supabase. Anything in your React components ships to every visitor and can be read from the browser.
Why do the emails only arrive at my own address?
Per Lovable's Resend connector docs you can only send from a domain you have verified in Resend. Until you verify one, testing uses resend.dev, which reaches the Resend account owner only. Lovable's Resend connector cannot do the DNS verification for you, and after you verify a domain you have to change the from address in your function and redeploy.
Does Lovable add spam protection to forms it generates?
Not on its own. As of September 2026 Lovable's documentation describes no forms product and no default CAPTCHA or honeypot for public forms, so whatever protection you want, you either ask for it explicitly or get it from the form backend you post to.

Read next

  • Setting up Resend in a Lovable app

    The Lovable Resend setup that actually ships: what the shared connector can't do, the DNS you verify first, and the deliverability work nobody documents.

  • Lovable integrations: the complete map

    Lovable integrations come in six connection types, and picking the wrong one is why wiring fails. What each type does, what exists, and how secrets flow.

  • Lovable SEO: the complete 2026 guide

    Lovable SEO splits across two stacks, and SSR alone does not put your data in the HTML. Test both on your own routes, then close the gaps Lovable leaves to you.