---
title: "Adding a contact form to a Lovable app | Lovable Field Guide"
description: "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."
lang: en
json-ld: |
  {
    "@context": "https://schema.org",
    "@graph": [
      {
        "@type": "WebSite",
        "@id": "https://prerenderlovable.com/#website",
        "url": "https://prerenderlovable.com",
        "name": "The Lovable Field Guide",
        "description": "A working journal on Lovable SEO: getting apps built with Lovable indexed, ranked and cited, plus the migration and integration work that comes with it.",
        "publisher": {
          "@id": "https://prerenderlovable.com/#organization"
        },
        "inLanguage": "en"
      },
      {
        "@type": "Organization",
        "@id": "https://prerenderlovable.com/#organization",
        "name": "The Lovable Field Guide",
        "url": "https://prerenderlovable.com"
      },
      {
        "@type": "BlogPosting",
        "@id": "https://prerenderlovable.com/blog/lovable-contact-form#article",
        "headline": "Adding a contact form to a Lovable app",
        "description": "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.",
        "datePublished": "2026-09-11T00:00:00.000Z",
        "dateModified": "2026-09-11T00:00:00.000Z",
        "inLanguage": "en",
        "mainEntityOfPage": {
          "@type": "WebPage",
          "@id": "https://prerenderlovable.com/blog/lovable-contact-form"
        },
        "author": {
          "@type": "Organization",
          "name": "The Lovable Field Guide",
          "url": "https://prerenderlovable.com"
        },
        "publisher": {
          "@id": "https://prerenderlovable.com/#organization"
        },
        "keywords": "lovable contact form, lovable form backend, lovable contact form not sending email, lovable form edge function, lovable form spam protection",
        "articleSection": "Integrations"
      },
      {
        "@type": "BreadcrumbList",
        "@id": "https://prerenderlovable.com/blog/lovable-contact-form#breadcrumbs",
        "itemListElement": [
          {
            "@type": "ListItem",
            "position": 1,
            "name": "Home",
            "item": "https://prerenderlovable.com/"
          },
          {
            "@type": "ListItem",
            "position": 2,
            "name": "Integrations",
            "item": "https://prerenderlovable.com/integrations"
          },
          {
            "@type": "ListItem",
            "position": 3,
            "name": "Adding a contact form to a Lovable app",
            "item": "https://prerenderlovable.com/blog/lovable-contact-form"
          }
        ]
      },
      {
        "@type": "FAQPage",
        "@id": "https://prerenderlovable.com/blog/lovable-contact-form#faq",
        "mainEntity": [
          {
            "@type": "Question",
            "name": "Why does my Lovable contact form say it sent but I never get an email?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "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."
            }
          },
          {
            "@type": "Question",
            "name": "Can I add a contact form to a Lovable app without a database?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "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."
            }
          },
          {
            "@type": "Question",
            "name": "Where do I put the Resend API key in a Lovable contact form?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "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."
            }
          },
          {
            "@type": "Question",
            "name": "Why do the emails only arrive at my own address?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "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."
            }
          },
          {
            "@type": "Question",
            "name": "Does Lovable add spam protection to forms it generates?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "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."
            }
          }
        ]
      },
      {
        "@type": "HowTo",
        "@id": "https://prerenderlovable.com/blog/lovable-contact-form#howto",
        "name": "Wire a Lovable contact form to your own edge function",
        "step": [
          {
            "@type": "HowToStep",
            "position": 1,
            "name": "Create the submissions table",
            "text": "Add a table with the fields you collect, enable row-level security on it, and add no anon policies: the function will write with the service role instead."
          },
          {
            "@type": "HowToStep",
            "position": 2,
            "name": "Create the handler",
            "text": "Add an edge function (React + Vite projects) or a TanStack server function (TanStack Start projects) that accepts a POST, validates the payload, and rejects honeypot and fast-timing hits."
          },
          {
            "@type": "HowToStep",
            "position": 3,
            "name": "Store the provider key as a backend secret",
            "text": "Put RESEND_API_KEY in the project's secrets so it is injected at runtime. Never reference it from client code."
          },
          {
            "@type": "HowToStep",
            "position": 4,
            "name": "Insert, then notify",
            "text": "Write the row first and send the notification email second, so a mail provider outage does not lose the submission."
          },
          {
            "@type": "HowToStep",
            "position": 5,
            "name": "Point the form at the handler",
            "text": "Submit JSON from the form component to the function URL, handle the 400 case by rendering field errors, and confirm the row appears in the table."
          }
        ]
      }
    ]
  }
---

[Skip to content](#main)

[Lovable  Field Guide ](/)

[SEO](/seo)[Migration](/migration)[Integrations](/integrations)[All posts](/blog)

[Start here](/blog/lovable-seo)

[SEO](/seo)[Migration](/migration)[Integrations](/integrations)[All posts](/blog)

1.  [Home](/)
2.  /  [Integrations](/integrations)
3.  /  Adding a contact form to a Lovable app 

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

Published September 11, 2026 · 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[#](#first-work-out-which-stack-youre-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](/blog/lovable-seo).

There is no forms feature to enable

As of September 2026 Lovable’s documentation describes no forms product: no per-form endpoint, no submission inbox, no built-in spam filtering. Forms are ordinary app code that you or the model write, which is why they land in the same territory as every other [Lovable integration path](/blog/lovable-integrations). Nothing here is a setting you missed.

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[#](#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[#](#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[#](#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 API key never touches the browser

`RESEND_API_KEY` is read from the environment inside the function. If a provider key appears anywhere in your React components, it ships in the JavaScript bundle to every visitor and can be read with view-source. Lovable stores backend secrets in the Cloud secrets section, or syncs them to your Supabase project’s edge function secrets if you brought your own Supabase; on the TanStack Start stack they are injected at request time as Workers bindings. Any of those is fine. A `VITE_`\-prefixed variable is not: that prefix means “expose to the client”.

### The from-address trap[#](#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](/blog/lovable-resend-email-setup) is its own job.

### Be honest about the total[#](#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[#](#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](/blog/self-host-lovable-app). 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[#](#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[#](#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](https://linkycal.com/features/contacts), where you can tag leads, move them through stages and set a next action. [Workflows](https://linkycal.com/features/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](https://linkycal.com/features/api).

### Let Lovable set it up for you[#](#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](https://linkycal.com/features/forms) has the details, and there’s a [comparison with Formspree](https://linkycal.com/alternatives/formspree) if you’re weighing the two.

## The parts you own whichever backend you pick[#](#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[#](#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[#](#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[#](#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.

### Consent and what you log[#](#consent-and-what-you-log)

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[#](#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.

On this page

-   [First, work out which stack you’re on](#first-work-out-which-stack-youre-on)
-   [Option 1: your own handler, storing the row and sending the mail](#option-1-your-own-handler-storing-the-row-and-sending-the-mail)
-   [The table, and the row-level security trap](#the-table-and-the-row-level-security-trap)
-   [The handler](#the-handler)
-   [The from-address trap](#the-from-address-trap)
-   [Be honest about the total](#be-honest-about-the-total)
-   [Option 2: rent a form backend](#option-2-rent-a-form-backend)
-   [Option 3: LinkyCal, with a plain HTML form](#option-3-linkycal-with-a-plain-html-form)
-   [What happens after the lead comes in](#what-happens-after-the-lead-comes-in)
-   [Let Lovable set it up for you](#let-lovable-set-it-up-for-you)
-   [The parts you own whichever backend you pick](#the-parts-you-own-whichever-backend-you-pick)
-   [Validate twice, and mean it the second time](#validate-twice-and-mean-it-the-second-time)
-   [Accessibility, which is also a conversion feature](#accessibility-which-is-also-a-conversion-feature)
-   [The honeypot pattern, done properly](#the-honeypot-pattern-done-properly)
-   [Consent and what you log](#consent-and-what-you-log)
-   [What to do next](#what-to-do-next)

[More on Integrations](/integrations)

-   [Lovable integrations: the complete map](/blog/lovable-integrations)
-   [Adding a contact form to a Lovable app](/blog/lovable-contact-form)
-   [Build Lovable pages with Claude Code or Codex](/blog/lovable-with-claude-code-and-codex)
-   [How to spend fewer credits on Lovable](/blog/save-lovable-credits)
-   [Lovable MCP and chat connectors, explained](/blog/lovable-mcp-servers)
-   [Lovable Stripe integration vs built-in payments](/blog/lovable-stripe-payments)
-   [Lovable vs Cursor: which to use when](/blog/lovable-vs-cursor)
-   [Setting up Resend in a Lovable app](/blog/lovable-resend-email-setup)

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

## Keep going

-   ### [Setting up Resend in a Lovable app](/blog/lovable-resend-email-setup)
    
    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](/blog/lovable-integrations)
    
    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](/blog/lovable-seo)
    
    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.
    

The Lovable Field Guide

A working journal on Lovable SEO.

SEO

-   [SEO hub](/seo)

Migration

-   [Migration hub](/migration)

Integrations

-   [Integrations hub](/integrations)

© 2026 The Lovable Field Guide. Independent guides to shipping Lovable apps.

[RSS](/rss.xml) [Sitemap](/sitemap.xml) [About](/about)