# How to Build a Landing Page in Lovable

- Tool: How to Build with Lovable
- Difficulty: Beginner
- Compatibility: Any Lovable plan (custom domain requires a paid plan)
- Last updated: September 2026

## TL;DR

Build a Lovable landing page with a hero section, feature highlights, social proof, and a waitlist form that saves emails to a Supabase-backed Cloud table. This guide covers spam-resistant form handling, per-page SEO meta (including the TanStack Start SSR default for new projects), and publishing with a custom domain — a launch-ready page in under two hours.

## Before you start

- A Lovable account (Free plan works for building and testing; custom domain requires a paid plan)
- Lovable Cloud connected to the project (Cloud tab → Database)
- Basic product copy ready: value proposition, 3-4 key features, and any testimonials or affiliations you plan to use as social proof
- A domain name if you plan to publish on a custom domain rather than the free lovable.app subdomain

## Step-by-step guide

### 1. Create the waitlist table in Lovable Cloud

Prompt Lovable to set up the backend table that will store every signup. Keep the schema minimal at launch — email and a timestamp are the only truly required fields — and add optional columns for basic attribution if you're running ads or multiple traffic sources.

Set the RLS policy so anyone (including anonymous visitors) can INSERT a new row, but nobody can SELECT the table's contents from the client side — signups should only be readable from Cloud → Database directly or through an authenticated admin view you build separately, never exposed to the public.

```
Create a waitlist_signups table with:
- id (uuid, primary key, default gen_random_uuid())
- email (text, not null, unique)
- referrer (text, nullable) — where the signup came from (utm_source or page URL)
- created_at (timestamptz, default now())

RLS policy: allow anonymous and authenticated INSERT with no restrictions on the row values except the unique email constraint. Do NOT allow public SELECT — only accessible via Cloud → Database or a future authenticated admin view.
```

> Pro tip: The UNIQUE constraint on email does double duty — it prevents duplicate signups AND gives you a clean way to show a friendly 'you're already on the list!' message instead of a generic database error when someone submits twice.

**Expected result:** The waitlist_signups table appears in Cloud → Database with the correct columns and RLS policy. TypeScript types are generated automatically.

### 2. Build the hero and features sections

Build the top of the page first, since it carries the most weight for conversion. Describe your product's core value proposition in one sentence for the hero headline, a supporting sub-headline, and the primary call-to-action that scrolls to or opens the waitlist form. Follow with a features section highlighting 3-4 key benefits, each with a short headline, one or two sentences of description, and an icon.

Keep copy specific to what you're actually building rather than generic marketing language — a landing page's job is to make a stranger understand what the product does and why they should care within a few seconds.

```
Build the landing page hero section at the top of src/pages/Index.tsx:

- Full-width section, centered content, max-width container (max-w-4xl)
- Headline (h1, large, bold): the core value proposition in one sentence
- Sub-headline (p, muted-foreground, larger text): one supporting sentence explaining who it's for
- Primary CTA Button: 'Join the Waitlist' — scrolls smoothly to the waitlist form section using an anchor link
- Optional: a hero image, illustration, or simple product screenshot placeholder below the CTA

Below the hero, build a features section:
- Section heading: 'Why [Product Name]'
- 3-4 feature Cards in a responsive grid (grid-cols-1 md:grid-cols-3)
- Each Card: icon (lucide-react), short bold title, 1-2 sentence description
- Keep the grid balanced — an odd number of features (3) tends to look better than an even number that leaves gaps on smaller screens
```

> Pro tip: Ask Lovable to use Visual Edits (no credit cost) for small copy or spacing tweaks after the initial generation, rather than re-prompting the whole section — it's faster and doesn't consume build credits for cosmetic changes.

**Expected result:** The page renders a hero section with a working scroll-to-form CTA, followed by a responsive features grid. Layout holds up correctly on mobile widths.

### 3. Add a social proof section

Social proof reduces the perceived risk of joining a waitlist for a product that doesn't exist yet. Depending on what you actually have available, build one of: a short testimonial from an early user or advisor, logos of companies or communities you're affiliated with, or a simple 'X people already joined' counter fed live from the waitlist_signups table.

If you don't have real testimonials or logos yet, favor a specific, credible claim over generic hype — 'Built by the team behind [previous product]' or 'Currently in private beta with 12 early testers' reads more credibly than vague enthusiasm with no substance behind it.

```
Add a social proof section between the features and the waitlist form:

- If using a live counter: query count of rows in waitlist_signups (supabase.from('waitlist_signups').select('*', { count: 'exact', head: true })) and display as '{count}+ people already joined' — only render this section once the count exceeds 20, otherwise hide it entirely
- If using testimonials: a single centered Card with a quote, name, and role/affiliation
- If using logos: a muted, grayscale row of 3-5 logos with the label 'As seen in' or 'Trusted by'
```

> Pro tip: A low signup count displayed publicly can undercut credibility rather than build it. If your count is still small, lead with a specific testimonial or affiliation instead and hold off on the live counter until it crosses a threshold you're comfortable showing.

**Expected result:** The social proof section renders below the features grid. If using the live counter, it correctly reflects the current row count from waitlist_signups and hides itself below the configured threshold.

### 4. Build the waitlist form with spam protection

Build the form itself using react-hook-form and zod for validation — a single email field is usually enough friction-wise, though you can add an optional 'what are you hoping to use this for' text field if you want qualitative signal from early signups.

On submit, call an Edge Function rather than inserting directly from the client, so you can add a honeypot spam check and basic rate limiting server-side. Handle the duplicate-email case gracefully using the UNIQUE constraint's error code rather than showing a generic failure.

```
Build a WaitlistForm component with:
- Email input (required, valid email format via zod)
- Optional hidden honeypot field (name it something plausible like 'company_website', styled with display:none and tabIndex=-1 so real users never see or tab into it)
- Submit Button with a loading state

On submit, call the join-waitlist Edge Function with { email, honeypot, referrer: document.referrer }.

Handle responses:
- Success: show 'You're on the list! We'll email you when we launch.'
- Duplicate email (error code from the function): show 'You're already on the waitlist — we'll be in touch soon.'
- Honeypot triggered: show a generic success message anyway (never reveal to a bot that it was caught) but do not insert the row
- Other errors: show a generic 'Something went wrong, please try again' message
```

> Pro tip: Debounce the submit button or disable it immediately on click — without this, a fast double-click submits the form twice before the first request resolves, which the UNIQUE constraint will handle gracefully but still creates a confusing flash of error state.

**Expected result:** The form validates email format client-side, submits to the Edge Function, and shows the correct message for success, duplicate, and error cases. A honeypot-filled submission shows a fake success message without creating a database row.

### 5. Set per-page SEO meta and verify the SSR path

Check which rendering path your project is on before configuring meta tags — this changes what 'correct' looks like. Projects created after May 13, 2026 default to TanStack Start with full server-side rendering, so per-route meta tags (title, description, Open Graph image) are rendered into the initial HTML response and are immediately visible to crawlers and link-preview bots with no special handling. Older Vite SPA projects rely on prerendering for crawlers rather than true SSR — meta tags still need to be set, but verify they're actually appearing in prerendered output rather than only in the client-rendered DOM.

Set a specific, keyword-relevant title and description for the landing page route, plus Open Graph tags so the page previews correctly when shared on social platforms or in Slack/Discord.

```
Set page-level SEO metadata for the landing page route:
- Title: '[Product Name] — [one-line value proposition]' (under 60 characters)
- Meta description: a specific 1-2 sentence summary of the product and who it's for (under 155 characters)
- Open Graph tags: og:title, og:description, og:image (a 1200x630px preview image), og:type='website'
- Twitter card tags: twitter:card='summary_large_image'

If this is a TanStack Start project, set these via the route's head/meta configuration so they render server-side. If this is a legacy Vite SPA project, confirm with me whether prerendering is correctly picking up these tags for crawlers.
```

> Pro tip: Use a real, specific Open Graph image sized 1200x630px rather than your logo alone — link previews with a purpose-built image get noticeably higher click-through when shared than ones with a generic square logo stretched to fit.

**Expected result:** Viewing the page source (not just the rendered DOM) shows the correct title, meta description, and Open Graph tags. Sharing the URL in Slack or on X shows a proper preview card with title, description, and image.

### 6. Publish and connect a custom domain

Once the page, form, and SEO meta are in place, click the Publish button in the top-right corner of the editor. Your app is live on a free xxx.lovable.app subdomain immediately after publishing.

To use your own domain, go to Project Settings → Domains (paid plans only) and add your domain. Lovable will show DNS records to add at your registrar — typically an A record pointing to Lovable's IP and a TXT record for verification. Automatic setup via Entri is available for supported registrars; otherwise add the records manually. DNS propagation can take up to 72 hours, though it's usually much faster. Lovable provisions a free SSL certificate automatically once the domain verifies.

> Pro tip: Test the waitlist form on your published URL, not just the preview panel, before sharing the link publicly — this catches any environment-specific issues (like an Edge Function secret that wasn't set) before real visitors hit them.

**Expected result:** The landing page is live on your custom domain with a valid SSL certificate (padlock icon in the browser). Submitting the waitlist form on the live domain successfully creates a row in waitlist_signups.

## Complete code example

File: `supabase/functions/join-waitlist/index.ts`

```typescript
// supabase/functions/join-waitlist/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  try {
    const { email, honeypot, referrer } = await req.json()

    // Honeypot check: real users never fill this hidden field. Silently
    // pretend success so bots don't learn the field is being checked.
    if (honeypot) {
      return new Response(JSON.stringify({ ok: true }), { headers: cors })
    }

    if (!email || typeof email !== 'string' || !EMAIL_REGEX.test(email)) {
      return new Response(
        JSON.stringify({ error: 'invalid_email', message: 'Enter a valid email address.' }),
        { status: 400, headers: cors }
      )
    }

    const supabase = createClient(
      Deno.env.get('SUPABASE_URL') ?? '',
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
    )

    const { error } = await supabase.from('waitlist_signups').insert({
      email: email.trim().toLowerCase(),
      referrer: typeof referrer === 'string' ? referrer.slice(0, 500) : null,
    })

    if (error) {
      // Postgres unique_violation
      if (error.code === '23505') {
        return new Response(
          JSON.stringify({ error: 'duplicate', message: "You're already on the waitlist." }),
          { status: 409, headers: cors }
        )
      }
      console.error('join-waitlist insert error:', error)
      return new Response(
        JSON.stringify({ error: 'server_error', message: 'Something went wrong. Please try again.' }),
        { status: 500, headers: cors }
      )
    }

    return new Response(JSON.stringify({ ok: true }), { headers: cors })
  } catch (err) {
    console.error('join-waitlist error:', err)
    return new Response(
      JSON.stringify({ error: 'server_error', message: 'Something went wrong. Please try again.' }),
      { status: 500, headers: cors }
    )
  }
})
```

## Common mistakes

- **Inserting waitlist signups directly from the client instead of through an Edge Function** — A direct client-side insert has no place to check a honeypot field or apply rate limiting, making the form an easy target for basic bot spam the moment the page gets any traffic. Fix: Route the form submission through an Edge Function that checks the honeypot field and validates the email format before inserting, exactly as shown in the complete code above.
- **Making the RLS policy on waitlist_signups allow public SELECT** — Email addresses are personal data. A publicly readable waitlist table lets anyone with basic technical knowledge scrape every email that's signed up, which is both a privacy problem and a spam-list risk for your users. Fix: Restrict SELECT to an authenticated admin role only, or don't expose a SELECT policy at all and view signups directly in Cloud → Database instead.
- **Setting SEO meta tags without checking whether the project uses TanStack Start SSR or a legacy Vite SPA** — The correct implementation differs: TanStack Start renders meta tags server-side automatically, while a Vite SPA depends on prerendering actually picking up the tags for crawlers — assuming one approach when the project uses the other means meta tags may never reach crawlers at all. Fix: Check the Code tab to confirm your project's stack, and verify the rendered meta tags by viewing page source (not the browser DevTools Elements panel, which shows the post-hydration DOM) before publishing.
- **Displaying a live 'X people joined' counter before the count is high enough to feel credible** — A visible count in the single digits or teens can read as evidence the product isn't generating interest, working against the exact goal the social proof section is meant to achieve. Fix: Hide the live counter behind a threshold (for example, only render it once the count exceeds 20-30) and use qualitative social proof — a testimonial or affiliation — until then.

## Best practices

- Keep the waitlist form to one required field (email). Every additional required field measurably reduces signup completion, especially on mobile.
- Never expose the waitlist_signups table to public SELECT via RLS — signups are personal data (email addresses) and should only be readable by you or an authenticated admin view.
- Route form submissions through an Edge Function rather than inserting directly from the client, so you have a place to add honeypot and rate-limit checks server-side.
- Set per-page SEO meta (title, description, Open Graph image) before publishing, not after — link previews get cached by some platforms the first time a URL is shared.
- Check whether your project is on TanStack Start SSR or a legacy Vite SPA before assuming your meta tags are crawler-visible — the correct implementation differs between the two.
- Hold off on displaying a live signup counter publicly until it's past the point where a small number would undercut credibility — lead with qualitative social proof until then.

## Frequently asked questions

### Does Lovable's landing page output work for SEO?

Yes, and it's much stronger than it used to be. Projects created after May 13, 2026 default to TanStack Start with full server-side rendering, meaning crawlers receive fully rendered HTML — including per-page meta tags — on first request, no different from a traditional server-rendered site. Older projects built as a Vite SPA rely on crawler prerendering rather than true SSR; they still work for SEO but are worth upgrading if organic search matters to your launch. Check your project's stack in the Code tab if you're unsure which one you're on.

### How do I stop bots from spamming my waitlist form?

The Edge Function pattern in this guide includes a honeypot field — a hidden input real users never fill in, but simple bots often do — which silently rejects the submission without a visible error. For a public launch expecting meaningful traffic, also add a basic rate limit (reject more than a few submissions per IP per minute) at the Edge Function level, and consider a CAPTCHA only if honeypot filtering proves insufficient in practice; most low-to-moderate traffic launches don't need one.

### Can I add a countdown timer or 'launching in X days' element?

Yes — describe the exact launch date and Lovable will generate a countdown component using standard JavaScript Date calculations, no external library required for a simple day/hour/minute countdown. Store the launch date as a constant or, if you want to change it without redeploying, as a value in a site_settings table read on page load.

### How many people typically need to see a waitlist counter before it feels credible?

There's no universal number, but showing a raw count under roughly 20-30 signups often reads as sparse rather than reassuring. A common approach: don't display the live counter publicly until you've crossed a threshold you're comfortable with, and until then show qualitative social proof instead (a short list of who's already interested, or a value-proposition-focused testimonial) rather than a small number that undercuts the page's credibility.

### Should the waitlist form send a confirmation email?

It's not required for the form to function, but it meaningfully improves the experience — a confirmation email reassures the signup their submission went through and gives you a second touchpoint before launch. Lovable's native Resend connector or built-in Lovable Emails feature both handle this well; see the customization ideas below for the exact prompt pattern.

### How do I see who signed up for my waitlist?

Open Cloud → Database in the Cloud tab and select the waitlist_signups table — every row is visible there with email, signup timestamp, and any UTM/referrer data you captured. For anything beyond occasional manual checking, prompt Lovable to add a simple password-protected admin page that lists and exports signups as CSV, covered in the customization ideas section.

### Can RapidDev help me turn a waitlist landing page into a full product?

Yes — this is one of our most common engagements. Once a waitlist validates demand, the next step is usually the actual product behind it: auth, billing, and the core feature set. RapidDev picks up directly from a Lovable-built landing page and Cloud schema without needing to rebuild the front end, scoping the buildout based on what the waitlist data and early signups tell you about demand.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/landing-page
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/landing-page
