# How to Build a Booking Platform with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a Calendly-style booking platform in Lovable where service providers define their availability and clients book time slots. An Edge Function computes open slots in real time, handles timezone conversion, and blocks double-bookings. The UI uses shadcn/ui Calendar plus a Select for time slot picking and a confirmation Dialog. Build time is 2–2.5 hours.

## Before you start

- Lovable Pro account for Edge Function generation
- Supabase project with Auth enabled for provider accounts
- Supabase URL and service role key in Cloud tab → Secrets (no VITE_ prefix for Edge Function secrets)
- Understanding of how time zones work (UTC offset vs IANA timezone names)
- Optional: a Resend account for booking confirmation emails

## Step-by-step guide

### 1. Create the booking platform schema

Prompt Lovable to set up the database tables for providers, availability, and bookings. Provider accounts use Supabase Auth. The booking page is public — no auth for clients.

```
Create a Supabase schema for a booking platform.

Tables:
- providers: id (uuid pk, references auth.users), display_name (text), bio (text), slug (text unique, URL-safe), timezone (text, IANA format e.g. 'America/New_York'), slot_duration_minutes (int default 30), buffer_minutes (int default 0, added after each booking), avatar_url (text), created_at
- availability_templates: id (uuid pk), provider_id (references providers), day_of_week (int, 0=Sunday through 6=Saturday), start_time (time, e.g. '09:00'), end_time (time, e.g. '17:00'), is_active (bool default true)
- bookings: id (uuid pk), provider_id (references providers), client_name (text), client_email (text), starts_at (timestamptz), ends_at (timestamptz), notes (text), status (text check: pending|confirmed|cancelled, default 'confirmed'), confirmation_code (text unique, 8 chars uppercase), created_at
- blocked_dates: id (uuid pk), provider_id (references providers), blocked_date (date), reason (text)

RLS:
- providers: SELECT public; INSERT/UPDATE where id = auth.uid()
- availability_templates: SELECT public; INSERT/UPDATE/DELETE where provider_id = auth.uid()
- bookings: SELECT where provider_id = auth.uid() OR client_email = current_setting('request.jwt.claims', true)::jsonb->>'email'; INSERT public (anyone can book)
- blocked_dates: SELECT public; INSERT/UPDATE/DELETE where provider_id = auth.uid()

Generate a function generate_confirmation_code() returning text that creates 8 random uppercase alphanumeric characters. Call it as the default for bookings.confirmation_code.
```

> Pro tip: Add a unique constraint on availability_templates(provider_id, day_of_week) so each provider can only have one template per day. Without this, the slot computation Edge Function might double-count available hours if Lovable accidentally inserts a duplicate.

**Expected result:** All four tables are created with RLS enabled. The generate_confirmation_code function works. TypeScript types are generated.

### 2. Build the slot computation Edge Function

This Edge Function is the core of the platform. It receives a provider slug, a date, and the client's timezone, and returns an array of available time slots formatted for display.

```
// supabase/functions/get-available-slots/index.ts
// Create a Supabase Edge Function that computes available booking slots.
//
// Request: GET ?provider_slug=jane-doe&date=2025-06-09&client_timezone=America/Chicago
//
// Logic:
// 1. Fetch provider by slug (get provider.timezone, slot_duration_minutes, buffer_minutes)
// 2. Check if date is in blocked_dates — if so, return { slots: [] }
// 3. Get day_of_week from the date (0–6)
// 4. Fetch availability_templates WHERE provider_id = provider.id AND day_of_week = dow AND is_active = true
// 5. If no template, return { slots: [] }
// 6. Generate all slot start times between template.start_time and template.end_time
//    with slot_duration_minutes + buffer_minutes increments
// 7. Convert each slot from provider timezone to UTC for the given date
// 8. Fetch existing bookings for that date: WHERE provider_id = ? AND starts_at::date = date AND status != 'cancelled'
// 9. Filter out any generated slot that overlaps an existing booking (including buffer)
// 10. Convert remaining UTC slot times to client_timezone for display
// 11. Return { slots: Array<{ utc: string, display: string, label: string }> }
//     where label is like '2:00 PM' and display includes timezone abbreviation
//
// Use date-fns-tz (importable via esm.sh) for timezone conversion
// Return CORS headers for browser access
```

> Pro tip: Add a ?duration_override=60 query parameter to the Edge Function so providers with multiple service types (30-min intro call, 60-min full session) can offer different lengths. Store service_types as a table linked to providers with their own durations, and pass service_type_id instead.

**Expected result:** The Edge Function deploys and responds to GET requests. Calling it with a valid provider slug and future date returns an array of available slot objects. Calling it for a day outside availability returns an empty slots array.

### 3. Build the public booking page

Ask Lovable to create the client-facing booking page at /book/[slug]. This is a two-step flow: date selection then time slot selection. No login required for clients.

```
Build a public booking page at src/pages/BookingPage.tsx with route /book/:slug.

Step 1 — Date selection:
- Fetch provider info by slug (display_name, bio, avatar_url, timezone)
- Render provider Card at the top: avatar, name, bio
- Render shadcn/ui Calendar component below
- Disable past dates and Sundays/Saturdays if provider has no weekend availability
- On date select, call the get-available-slots Edge Function with the selected date and Intl.DateTimeFormat().resolvedOptions().timeZone as client_timezone
- Show a loading skeleton while slots are fetching

Step 2 — Time slot selection:
- If slots array is empty, show 'No availability on this date' message
- If slots are available, render them as a Select (label shows the display-formatted time in client timezone)
- Add a small note below the Select: 'Times shown in [client timezone name]'
- A 'Continue' Button becomes active once a slot is selected

Step 3 — Confirmation Dialog:
- Opens when 'Continue' is clicked
- Form fields: client_name (required), client_email (required, email format), notes (Textarea, optional)
- Submit button: 'Confirm Booking'
- On submit: POST to Supabase bookings table with provider_id, client_name, client_email, starts_at (selected slot UTC), ends_at (starts_at + duration), notes
- Show success state with confirmation_code in a highlighted Alert: 'Your booking is confirmed. Reference: ABC12345'
- Do not redirect — keep the confirmation visible
```

> Pro tip: Detect the client's timezone automatically with Intl.DateTimeFormat().resolvedOptions().timeZone and display it clearly on the page ('Showing times in America/Chicago'). Add a 'Change timezone' link that opens a Select with common IANA timezone options so clients in unusual situations can correct it.

**Expected result:** Visiting /book/[slug] shows the provider card and a Calendar. Selecting a date fetches and shows available slots. Completing the form creates a booking and shows the confirmation code.

### 4. Build the provider availability settings page

Ask Lovable to build the provider settings page where they configure their weekly availability templates, slot duration, and blocked dates.

```
Build a provider settings page at src/pages/ProviderSettings.tsx (requires auth).

Section 1 — Profile:
- Form: display_name, bio, slug (validate URL-safe, unique check via Supabase), avatar upload (Supabase Storage), timezone Select (list of 20 common IANA timezones), slot_duration_minutes Select (15/30/45/60 min), buffer_minutes Select (0/5/10/15 min)
- Show the booking page URL: yourdomain.com/book/{slug} with a copy button

Section 2 — Weekly availability:
- 7 rows, one per day of week (Sunday through Saturday)
- Each row: day name, a Switch to enable/disable, start time Select (30-min intervals 6am–10pm), end time Select
- On change, upsert into availability_templates via supabase.from('availability_templates').upsert()
- Disabled days show the row grayed out and do not save a template row

Section 3 — Blocked dates:
- Calendar component for selecting dates to block
- Selected blocked dates highlight in red on the calendar
- Click a date to toggle it blocked/unblocked
- Each blocked date stores a row in blocked_dates
- Show a list of upcoming blocked dates below the calendar with remove buttons
```

**Expected result:** The provider can configure their profile, set weekly availability, and block specific dates. Changes persist to Supabase. The booking page URL is shown and copyable.

### 5. Build the provider bookings dashboard

Ask Lovable to build the dashboard showing all upcoming and past bookings. Providers can cancel bookings and see client details.

```
Build a provider bookings dashboard at src/pages/ProviderDashboard.tsx.

Requirements:
- Fetch all bookings for the authenticated provider from the bookings table
- Separate into two Tabs: 'Upcoming' and 'Past'
- Upcoming: bookings where starts_at > now() AND status != 'cancelled', sorted by starts_at ASC
- Past: bookings where starts_at <= now() OR status = 'cancelled', sorted by starts_at DESC
- Each booking shown as a Card:
  - Client name and email (with mailto link)
  - Date and time formatted in provider's timezone
  - Duration (slot_duration_minutes from provider profile)
  - Confirmation code as a Badge
  - Notes if present (truncated to 2 lines with 'Show more' expand)
  - Status Badge: confirmed=green, cancelled=red
  - For upcoming confirmed bookings: 'Cancel' Button with AlertDialog confirmation
- Cancel action: UPDATE bookings SET status = 'cancelled' WHERE id = ?
- Show total booking count and upcoming count in Cards at the top of the page
- Add a 'Copy booking link' Button in the page header
```

> Pro tip: Subscribe to Supabase Realtime on the bookings table filtered to the provider's ID. When a new booking row is inserted, show a toast notification 'New booking from [client_name]' and prepend the booking to the Upcoming tab without a full page refresh.

**Expected result:** The dashboard shows upcoming and past bookings in tabs. Cancelling a booking updates its status badge. Realtime notifications appear for new bookings.

## Complete code example

File: `src/hooks/useAvailableSlots.ts`

```typescript
import { useState, useEffect } from 'react'

type Slot = {
  utc: string
  display: string
  label: string
}

type UseSlotsResult = {
  slots: Slot[]
  isLoading: boolean
  error: string | null
}

export function useAvailableSlots(
  providerSlug: string,
  date: Date | null
): UseSlotsResult {
  const [slots, setSlots] = useState<Slot[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    if (!date) {
      setSlots([])
      return
    }

    const clientTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
    const dateStr = date.toISOString().split('T')[0]

    const controller = new AbortController()

    async function fetchSlots() {
      setIsLoading(true)
      setError(null)
      try {
        const url = new URL(
          `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/get-available-slots`
        )
        url.searchParams.set('provider_slug', providerSlug)
        url.searchParams.set('date', dateStr)
        url.searchParams.set('client_timezone', clientTimezone)

        const res = await fetch(url.toString(), {
          headers: {
            apikey: import.meta.env.VITE_SUPABASE_ANON_KEY,
          },
          signal: controller.signal,
        })

        if (!res.ok) throw new Error('Failed to fetch available slots')

        const data = await res.json()
        setSlots(data.slots ?? [])
      } catch (err) {
        if ((err as Error).name !== 'AbortError') {
          setError((err as Error).message)
        }
      } finally {
        setIsLoading(false)
      }
    }

    fetchSlots()
    return () => controller.abort()
  }, [providerSlug, date?.toISOString().split('T')[0]])

  return { slots, isLoading, error }
}
```

## Common mistakes

- **Not accounting for buffer_minutes when computing available slots** — If a provider has a 60-minute slot with 10 minutes buffer and a booking from 2–3pm, a slot at 2:50pm would technically 'not overlap' but the provider is still recovering from the previous session. Fix: When checking for conflicts in the Edge Function, treat each existing booking as occupying starts_at to ends_at + buffer_minutes. Any generated slot that starts before the buffered end time is excluded from results.
- **Storing availability times in UTC instead of provider local time** — If a provider in New York sets their availability as 9am–5pm and you store '14:00–22:00 UTC', switching to daylight saving time causes their availability to shift by an hour silently. Fix: Store availability_templates.start_time and end_time as plain time values (HH:MM) without a timezone, and store the provider's timezone separately. The Edge Function converts these times to UTC for a specific date using the provider's timezone. Daylight saving is handled correctly because the IANA timezone rules are applied per-date.
- **Allowing clients to book past slots by manipulating the date parameter** — The Edge Function receives a date as a query parameter. A client could send a past date and the function might return slots if no validation exists. Fix: At the top of the Edge Function, check that the requested date is today or in the future: if (new Date(date) < new Date(new Date().toDateString())) { return slots: [] }. Also validate that slots returned are at least 30 minutes in the future to prevent same-day last-second bookings.
- **Not setting a unique constraint on bookings to prevent race condition double-bookings** — Two clients booking the same slot at exactly the same time can both succeed if the check and insert are not atomic. Fix: Add a unique index on bookings(provider_id, starts_at) WHERE status != 'cancelled'. Supabase will return a unique constraint error if a race condition occurs. Handle this error on the client with a friendly message: 'That slot was just taken. Please choose another time.'

## Best practices

- Always compute available slots server-side in the Edge Function, not client-side. Client-side slot computation exposes your booking data query logic and can be manipulated.
- Store all booking timestamps as UTC in Supabase. Convert to provider and client timezones only at display time using IANA timezone names, not UTC offsets, to handle daylight saving correctly.
- Add a database unique constraint on bookings(provider_id, starts_at) WHERE status != 'cancelled' as the final line of defense against double-bookings — even if the UI logic has a bug.
- Use abort controllers when fetching slots in the booking page. If a user quickly clicks through multiple dates, stale responses from earlier fetches should not overwrite the current date's slots.
- Make the booking page fully public (no Supabase auth required) and use the service role key only inside the Edge Function. The client-facing page should only call the Edge Function and the anon-key-accessible booking insert.
- Generate short uppercase confirmation codes (6–8 characters) rather than exposing the booking UUID. Clients use these to reschedule or cancel, and they are easier to communicate over the phone or in email.

## Frequently asked questions

### Do I need a backend server to compute available slots, or can this run on the client?

You need the Edge Function. Computing slots on the client requires sending all existing booking data to the browser, which exposes other clients' appointment times. The Edge Function keeps booking data server-side and returns only the list of available slots — no personal data exposed.

### How does the slot computation handle daylight saving time transitions?

By storing availability as plain HH:MM times plus an IANA timezone string (like 'America/New_York'), the Edge Function uses date-fns-tz to resolve the exact UTC timestamp for each slot on the specific target date. The IANA timezone database includes daylight saving rules, so a 9:00 AM slot in New York is correctly resolved to 14:00 UTC in winter and 13:00 UTC in summer.

### What happens if two clients try to book the same slot at the exact same time?

The unique constraint on bookings(provider_id, starts_at) WHERE status != 'cancelled' ensures only one succeeds at the database level. The second insert fails with a unique constraint violation. Handle this on the client by catching the error and showing 'That time slot was just taken — please choose another.' Then refetch the available slots to show the updated list.

### Can providers have different availability on different weeks, like a rotating schedule?

The weekly template system in this guide supports the same schedule every week. For rotating schedules (week A vs week B), you would need to add a week_pattern column to availability_templates and logic to determine which pattern applies for a given date. Alternatively, use blocked_dates to manually block the off weeks.

### How do I let providers charge for bookings?

Add Stripe to the booking flow. When the client submits their details, instead of inserting directly to bookings, call an Edge Function that creates a Stripe Checkout session with the service price. On payment success, a Stripe webhook triggers another Edge Function that inserts the confirmed booking. Free services bypass Stripe entirely.

### Can I have multiple providers on the same platform (a marketplace)?

Yes, the schema already supports multiple providers. Each provider has their own slug, availability template, and bookings. The public booking page at /book/:slug routes to the correct provider. Adding a provider directory page that lists all active providers makes it a marketplace.

### How do I let clients cancel their bookings without an account?

Include the confirmation_code in the booking confirmation page and email. Create a public cancellation page at /cancel/:confirmation_code that fetches the booking by code and shows a Cancel button. The UPDATE policy can allow cancellation using a SECURITY DEFINER function that accepts the confirmation code and sets status to cancelled.

### How do I handle bookings across midnight — for example a session from 11pm to 1am?

The slot computation Edge Function should check if end_time is less than start_time in the template, which indicates a midnight-spanning range. In that case, generate slots up to midnight using the current date and continue from midnight using the next date's UTC representation. Storing starts_at and ends_at as full timestamptz values handles the display correctly.

---

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