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.
What you're building
Availability computation is the hard part of any booking platform. Rather than storing individual slot rows, providers define reusable weekly availability templates: a row per day-of-week with a start_time and end_time. When a client picks a date, the Edge Function computes slots by: (1) finding the template row matching that day-of-week, (2) generating all slot intervals (start_time + slot_duration increments), (3) querying confirmed bookings for that date, and (4) removing any slot that overlaps an existing booking.
Timezones are handled by storing all availability times as plain time values (HH:MM) in the provider's timezone. The Edge Function receives the client's timezone, converts each generated slot to that timezone, and returns them formatted for display. The client always sees slots in their local time.
The client-facing booking page is a public route — no auth required. The provider shares their slug (e.g. /book/jane-doe) and clients land on a clean two-step flow: pick a date on the Calendar, then pick a time slot from a Select. Step three is a confirmation Dialog with a form for name and email. On submit, the booking is inserted and a confirmation code is generated.
Final result
A fully functional booking platform where providers configure availability once and clients self-serve bookings at their unique URL — with real-time slot computation and timezone awareness.
Tech stack
Prerequisites
- 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
Build steps
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.
1Create a Supabase schema for a booking platform.23Tables:4- 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_at5- 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)6- 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_at7- blocked_dates: id (uuid pk), provider_id (references providers), blocked_date (date), reason (text)89RLS:10- providers: SELECT public; INSERT/UPDATE where id = auth.uid()11- availability_templates: SELECT public; INSERT/UPDATE/DELETE where provider_id = auth.uid()12- bookings: SELECT where provider_id = auth.uid() OR client_email = current_setting('request.jwt.claims', true)::jsonb->>'email'; INSERT public (anyone can book)13- blocked_dates: SELECT public; INSERT/UPDATE/DELETE where provider_id = auth.uid()1415Generate 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.
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.
1// supabase/functions/get-available-slots/index.ts2// Create a Supabase Edge Function that computes available booking slots.3//4// Request: GET ?provider_slug=jane-doe&date=2025-06-09&client_timezone=America/Chicago5//6// Logic:7// 1. Fetch provider by slug (get provider.timezone, slot_duration_minutes, buffer_minutes)8// 2. Check if date is in blocked_dates — if so, return { slots: [] }9// 3. Get day_of_week from the date (0–6)10// 4. Fetch availability_templates WHERE provider_id = provider.id AND day_of_week = dow AND is_active = true11// 5. If no template, return { slots: [] }12// 6. Generate all slot start times between template.start_time and template.end_time13// with slot_duration_minutes + buffer_minutes increments14// 7. Convert each slot from provider timezone to UTC for the given date15// 8. Fetch existing bookings for that date: WHERE provider_id = ? AND starts_at::date = date AND status != 'cancelled'16// 9. Filter out any generated slot that overlaps an existing booking (including buffer)17// 10. Convert remaining UTC slot times to client_timezone for display18// 11. Return { slots: Array<{ utc: string, display: string, label: string }> }19// where label is like '2:00 PM' and display includes timezone abbreviation20//21// Use date-fns-tz (importable via esm.sh) for timezone conversion22// Return CORS headers for browser accessPro 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.
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.
1Build a public booking page at src/pages/BookingPage.tsx with route /book/:slug.23Step 1 — Date selection:4- Fetch provider info by slug (display_name, bio, avatar_url, timezone)5- Render provider Card at the top: avatar, name, bio6- Render shadcn/ui Calendar component below7- Disable past dates and Sundays/Saturdays if provider has no weekend availability8- On date select, call the get-available-slots Edge Function with the selected date and Intl.DateTimeFormat().resolvedOptions().timeZone as client_timezone9- Show a loading skeleton while slots are fetching1011Step 2 — Time slot selection:12- If slots array is empty, show 'No availability on this date' message13- If slots are available, render them as a Select (label shows the display-formatted time in client timezone)14- Add a small note below the Select: 'Times shown in [client timezone name]'15- A 'Continue' Button becomes active once a slot is selected1617Step 3 — Confirmation Dialog:18- Opens when 'Continue' is clicked19- Form fields: client_name (required), client_email (required, email format), notes (Textarea, optional)20- Submit button: 'Confirm Booking'21- On submit: POST to Supabase bookings table with provider_id, client_name, client_email, starts_at (selected slot UTC), ends_at (starts_at + duration), notes22- Show success state with confirmation_code in a highlighted Alert: 'Your booking is confirmed. Reference: ABC12345'23- Do not redirect — keep the confirmation visiblePro 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.
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.
1Build a provider settings page at src/pages/ProviderSettings.tsx (requires auth).23Section 1 — Profile:4- 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)5- Show the booking page URL: yourdomain.com/book/{slug} with a copy button67Section 2 — Weekly availability:8- 7 rows, one per day of week (Sunday through Saturday)9- Each row: day name, a Switch to enable/disable, start time Select (30-min intervals 6am–10pm), end time Select10- On change, upsert into availability_templates via supabase.from('availability_templates').upsert()11- Disabled days show the row grayed out and do not save a template row1213Section 3 — Blocked dates:14- Calendar component for selecting dates to block15- Selected blocked dates highlight in red on the calendar16- Click a date to toggle it blocked/unblocked17- Each blocked date stores a row in blocked_dates18- Show a list of upcoming blocked dates below the calendar with remove buttonsExpected 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.
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.
1Build a provider bookings dashboard at src/pages/ProviderDashboard.tsx.23Requirements:4- Fetch all bookings for the authenticated provider from the bookings table5- Separate into two Tabs: 'Upcoming' and 'Past'6- Upcoming: bookings where starts_at > now() AND status != 'cancelled', sorted by starts_at ASC7- Past: bookings where starts_at <= now() OR status = 'cancelled', sorted by starts_at DESC8- Each booking shown as a Card:9 - Client name and email (with mailto link)10 - Date and time formatted in provider's timezone11 - Duration (slot_duration_minutes from provider profile)12 - Confirmation code as a Badge13 - Notes if present (truncated to 2 lines with 'Show more' expand)14 - Status Badge: confirmed=green, cancelled=red15 - For upcoming confirmed bookings: 'Cancel' Button with AlertDialog confirmation16- Cancel action: UPDATE bookings SET status = 'cancelled' WHERE id = ?17- Show total booking count and upcoming count in Cards at the top of the page18- Add a 'Copy booking link' Button in the page headerPro 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
1import { useState, useEffect } from 'react'23type Slot = {4 utc: string5 display: string6 label: string7}89type UseSlotsResult = {10 slots: Slot[]11 isLoading: boolean12 error: string | null13}1415export function useAvailableSlots(16 providerSlug: string,17 date: Date | null18): UseSlotsResult {19 const [slots, setSlots] = useState<Slot[]>([])20 const [isLoading, setIsLoading] = useState(false)21 const [error, setError] = useState<string | null>(null)2223 useEffect(() => {24 if (!date) {25 setSlots([])26 return27 }2829 const clientTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone30 const dateStr = date.toISOString().split('T')[0]3132 const controller = new AbortController()3334 async function fetchSlots() {35 setIsLoading(true)36 setError(null)37 try {38 const url = new URL(39 `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/get-available-slots`40 )41 url.searchParams.set('provider_slug', providerSlug)42 url.searchParams.set('date', dateStr)43 url.searchParams.set('client_timezone', clientTimezone)4445 const res = await fetch(url.toString(), {46 headers: {47 apikey: import.meta.env.VITE_SUPABASE_ANON_KEY,48 },49 signal: controller.signal,50 })5152 if (!res.ok) throw new Error('Failed to fetch available slots')5354 const data = await res.json()55 setSlots(data.slots ?? [])56 } catch (err) {57 if ((err as Error).name !== 'AbortError') {58 setError((err as Error).message)59 }60 } finally {61 setIsLoading(false)62 }63 }6465 fetchSlots()66 return () => controller.abort()67 }, [providerSlug, date?.toISOString().split('T')[0]])6869 return { slots, isLoading, error }70}Customization ideas
Multiple service types with different durations
Add a service_types table (provider_id, name, duration_minutes, price, description). The booking page shows a service type Select before the date picker. The selected service type's duration is passed to the Edge Function for slot computation. Providers manage service types in the settings page.
Booking confirmation and reminder emails
Add a Supabase Edge Function triggered on new booking INSERT that sends a confirmation email to both the client and provider using Resend. Add a second Edge Function on a cron schedule that sends reminder emails 24 hours before each booking.
Group sessions with max attendee count
Add a max_attendees column to service_types. Change the slot computation logic to show a slot as available if the booking count for that slot is less than max_attendees. This enables group classes or workshops where multiple clients can book the same timeslot.
Stripe payment on booking
Add a price column to service_types. When a client submits the booking form, redirect them to a Stripe Checkout session created by an Edge Function. On Stripe webhook payment success, insert the confirmed booking row. Free services skip this step.
Reschedule and cancellation links
Include a signed reschedule URL in the confirmation email that uses the booking's confirmation_code. The reschedule page lets the client pick a new slot from the same provider. On confirm, cancel the old booking and insert a new one. The link expires 24 hours before the original booking time.
Common pitfalls
Pitfall: Not accounting for buffer_minutes when computing available slots
How to avoid: 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.
Pitfall: Storing availability times in UTC instead of provider local time
How to avoid: 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.
Pitfall: Allowing clients to book past slots by manipulating the date parameter
How to avoid: 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.
Pitfall: Not setting a unique constraint on bookings to prevent race condition double-bookings
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a booking platform in Deno (Supabase Edge Function). I have providers with IANA timezone strings, availability_templates with day_of_week and start_time/end_time as plain HH:MM strings, and a bookings table with starts_at/ends_at as timestamptz. Help me write a TypeScript function computeAvailableSlots(providerTimezone, startTime, endTime, slotDuration, bufferMinutes, existingBookings, targetDate, clientTimezone) that returns an array of available slot objects. Show me how to use the date-fns-tz library to convert between provider timezone and UTC, and how to correctly handle the day boundary when slots near midnight are generated.
Add a 'Booking types' feature to the provider settings. Create a service_types table with provider_id, name (text), description (text), duration_minutes (int), price_cents (int, 0 for free), color (hex string). On the settings page, add a section to manage service types as Cards with an add button. On the public booking page, show a service type selector before the date picker — only show the calendar and time slots after a service type is selected. Pass the selected duration to the get-available-slots Edge Function call.
In Supabase, create a SQL function get_provider_by_slug(p_slug text) that returns a single provider row as JSON including their average booking duration and total confirmed booking count from the bookings table. Make it SECURITY INVOKER and callable by the anon role so the public booking page can fetch provider info without auth. Add a check that returns null if no provider with that slug exists so the booking page can show a 404 state.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation