# How to Build Scheduling app with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a Calendly-style scheduling app with V0 using Next.js and Supabase that lets professionals set weekly availability, create custom event types, accept bookings from a public page, and sync with Google Calendar — all in about 1-2 hours without local setup.

## Before you start

- A V0 account (Premium recommended for the booking page and dashboard complexity)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Google Cloud project with Calendar API enabled and OAuth 2.0 credentials

## Step-by-step guide

### 1. Set up the project and scheduling schema

Open V0 and create a new project. Use the Connect panel to add Supabase. Create the schema for availability, event types, bookings, and calendar connections.

```
// Paste this prompt into V0's AI chat:
// Build a scheduling app. Create a Supabase schema with:
// 1. availability: id (uuid PK), user_id (uuid FK), day_of_week (integer CHECK 0-6), start_time (time), end_time (time), timezone (text), created_at (timestamptz)
// 2. event_types: id (uuid PK), user_id (uuid FK), name (text), slug (text UNIQUE), duration_minutes (integer), buffer_minutes (integer DEFAULT 0), color (text), description (text), is_active (boolean DEFAULT true), created_at (timestamptz)
// 3. bookings: id (uuid PK), event_type_id (uuid FK), host_id (uuid FK), guest_name (text), guest_email (text), guest_phone (text), start_time (timestamptz), end_time (timestamptz), status (text CHECK confirmed/cancelled/completed/no_show), google_event_id (text), notes (text), created_at (timestamptz)
// 4. calendar_connections: id (uuid PK), user_id (uuid FK), provider (text CHECK google/outlook), access_token (text), refresh_token (text), token_expires_at (timestamptz), created_at (timestamptz)
// RLS: users manage own availability and event types. Bookings: host can read all, guests can insert (public booking page needs no auth for INSERT).
// Generate SQL migration and TypeScript types.
```

> Pro tip: The bookings table needs a special RLS policy: anyone can INSERT (for the public booking page) but only the host can SELECT and UPDATE. Use a policy like: CREATE POLICY "public_insert" ON bookings FOR INSERT WITH CHECK (true); CREATE POLICY "host_read" ON bookings FOR SELECT USING (host_id = auth.uid()).

**Expected result:** Supabase is connected with availability, event_types, bookings, and calendar_connections tables. RLS allows public booking inserts while restricting reads to the host.

### 2. Build the public booking page with slot computation

Create the public booking page where guests select a date, see available time slots, and book a meeting. The slot computation API subtracts existing bookings and buffer times from the host's availability.

```
// Paste this prompt into V0's AI chat:
// Build a public booking page at app/[username]/[slug]/page.tsx.
// This page requires NO authentication — guests book directly.
// Requirements:
// - Left side: event type Card showing name, duration, description, host name
// - Right side: shadcn/ui Calendar for date selection
// - Below calendar: available time slots as a grid of Buttons
//   - Slots come from GET /api/bookings/available-slots?event_type_id=X&date=YYYY-MM-DD&timezone=X
// - On slot click: slide to confirmation form with guest_name Input, guest_email Input, guest_phone Input, notes Textarea
// - "Confirm Booking" Button calls Server Action createBooking()
// - After booking: success Card with date, time, and "Add to Calendar" link
// - 'use client' for Calendar and slot selection
//
// Available slots API at app/api/bookings/available-slots/route.ts:
// - Fetches host's availability for the selected day_of_week
// - Generates all possible slots at duration_minutes intervals
// - Fetches existing bookings for the date (include buffer_minutes before/after)
// - Subtracts conflicts
// - Converts remaining slots to guest's timezone using date-fns-tz
// - Returns array of { start: string, end: string } in guest timezone
//
// Use shadcn/ui Calendar, Card, Button, Input, Textarea, Badge
```

**Expected result:** A public booking page with date selection, available time slot grid, guest information form, and confirmation. The API computes slots by subtracting existing bookings from availability.

### 3. Create the availability editor and event type manager

Build the dashboard pages for setting weekly availability (which days and times the host is available) and managing event types (meeting types with different durations).

```
// Paste this prompt into V0's AI chat:
// Build availability and event type management pages.
//
// Availability page at app/dashboard/availability/page.tsx:
// - 7 rows for each day of week (Sunday-Saturday)
// - Each row: day name, Switch to enable/disable, start_time Select, end_time Select (15-min increments)
// - Timezone Select at top (with common timezones: US/Eastern, US/Pacific, Europe/London, etc.)
// - Server Action updateAvailability() saves all 7 days at once (delete + re-insert pattern)
// - Visual weekly calendar preview showing enabled hours
// - Use shadcn/ui Switch, Select, Card, Button
//
// Event types page at app/dashboard/event-types/page.tsx:
// - Grid of event type Cards showing name, duration Badge, buffer info, color dot
// - Switch on each Card to toggle is_active
// - "Create Event Type" Button opens Dialog:
//   - name Input, slug Input (auto-generated from name), duration_minutes Select (15/30/45/60/90/120)
//   - buffer_minutes Select (0/5/10/15/30), color picker, description Textarea
// - Each Card has booking link: copy to clipboard Button showing /[username]/[slug]
// - Server Actions: createEventType(), updateEventType(), deleteEventType()
// - Use shadcn/ui Card, Badge, Switch, Dialog, Input, Select, Textarea, Button, Toast
```

**Expected result:** An availability editor with day-of-week toggles and time range selectors, plus an event type manager with create/edit/toggle Cards and copyable booking links.

### 4. Add Google Calendar sync and booking dashboard

Build the Google Calendar OAuth integration that syncs bookings as calendar events, plus the dashboard showing upcoming bookings in a calendar view.

```
// Paste this prompt into V0's AI chat:
// Build Google Calendar integration and a booking dashboard.
//
// Google Calendar OAuth at app/api/calendar/google/callback/route.ts:
// - Handles the OAuth callback: exchanges code for tokens
// - Stores access_token, refresh_token, token_expires_at in calendar_connections
// - Redirects back to dashboard with success Toast
//
// Calendar sync at app/api/calendar/sync/route.ts:
// - Called after booking creation
// - Fetches host's Google Calendar tokens from calendar_connections
// - Refreshes token if expired
// - Creates a Google Calendar event with booking details (guest name, email, time)
// - Stores the google_event_id in the bookings row
//
// Dashboard at app/dashboard/page.tsx:
// - Weekly calendar view showing all confirmed bookings as colored blocks
// - Upcoming bookings list: Table with guest_name, event_type Badge, date, time, status Badge
// - Booking actions: Cancel (Dialog with confirmation), Mark Complete, Mark No-Show
// - "Connect Google Calendar" Button if no calendar_connections exist
//   - Links to Google OAuth URL with scope calendar.events
// - Server Actions: cancelBooking(), updateBookingStatus()
// - Use shadcn/ui Card, Table, Badge, Button, Dialog, Calendar, Toast
//
// Env vars in V0's Vars tab: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET (server-only, no NEXT_PUBLIC_)
// NEXT_PUBLIC_APP_URL (needed for OAuth redirect URI and booking page links)
```

> Pro tip: The Google Calendar OAuth redirect URI must point to your production URL (/api/calendar/google/callback). Set this in Google Cloud Console after your first Vercel deploy. During development, you can add http://localhost:3000 as an additional redirect URI.

**Expected result:** Google Calendar OAuth integration that syncs bookings as calendar events, plus a dashboard with weekly calendar view and booking management.

## Complete code example

File: `app/api/bookings/available-slots/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { addMinutes, format, parse, isWithinInterval, startOfDay, endOfDay } from 'date-fns'
import { toZonedTime, fromZonedTime } from 'date-fns-tz'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const eventTypeId = searchParams.get('event_type_id')!
  const dateStr = searchParams.get('date')!
  const guestTz = searchParams.get('timezone') || 'America/New_York'

  const { data: eventType } = await supabase
    .from('event_types')
    .select('*, availability:user_id(availability(*))')
    .eq('id', eventTypeId)
    .single()

  if (!eventType) {
    return NextResponse.json({ error: 'Event type not found' }, { status: 404 })
  }

  const hostTz = eventType.availability?.availability?.[0]?.timezone || 'UTC'
  const date = new Date(dateStr)
  const dayOfWeek = toZonedTime(date, hostTz).getDay()

  const dayAvailability = (eventType.availability?.availability || []).find(
    (a: { day_of_week: number }) => a.day_of_week === dayOfWeek
  )

  if (!dayAvailability) {
    return NextResponse.json({ slots: [] })
  }

  const hostDate = format(toZonedTime(date, hostTz), 'yyyy-MM-dd')
  const startDt = fromZonedTime(
    parse(`${hostDate} ${dayAvailability.start_time}`, 'yyyy-MM-dd HH:mm:ss', new Date()),
    hostTz
  )
  const endDt = fromZonedTime(
    parse(`${hostDate} ${dayAvailability.end_time}`, 'yyyy-MM-dd HH:mm:ss', new Date()),
    hostTz
  )

  const { data: existingBookings } = await supabase
    .from('bookings')
    .select('start_time, end_time')
    .eq('host_id', eventType.user_id)
    .eq('status', 'confirmed')
    .gte('start_time', startOfDay(date).toISOString())
    .lte('end_time', endOfDay(date).toISOString())

  const buffer = eventType.buffer_minutes || 0
  const duration = eventType.duration_minutes
  const slots: { start: string; end: string }[] = []
  let cursor = startDt

  while (addMinutes(cursor, duration) <= endDt) {
    const slotStart = cursor
    const slotEnd = addMinutes(cursor, duration)
    const bufferedStart = addMinutes(slotStart, -buffer)
    const bufferedEnd = addMinutes(slotEnd, buffer)

    const hasConflict = (existingBookings || []).some((booking) => {
      const bStart = new Date(booking.start_time)
      const bEnd = new Date(booking.end_time)
      return bufferedStart < bEnd && bufferedEnd > bStart
    })

    if (!hasConflict && slotStart > new Date()) {
      const guestStart = toZonedTime(slotStart, guestTz)
      const guestEnd = toZonedTime(slotEnd, guestTz)
      slots.push({
        start: format(guestStart, "HH:mm"),
        end: format(guestEnd, "HH:mm"),
      })
    }

    cursor = addMinutes(cursor, duration)
  }

  return NextResponse.json({ slots })
}
```

## Common mistakes

- **Computing available slots on the client instead of the server** — Client-side slot computation exposes the host's full availability and all existing bookings to the browser. It also cannot securely query other users' bookings. Fix: Use an API route (app/api/bookings/available-slots/route.ts) that computes slots server-side with the service role key, returning only the final available slots to the client.
- **Not accounting for buffer minutes when checking for conflicts** — Without buffer time, a 30-minute meeting ending at 2:00 PM allows a booking starting at 2:00 PM — leaving the host zero transition time between meetings. Fix: Expand each existing booking's time range by buffer_minutes on both sides when checking for conflicts. A 10-minute buffer means a meeting ending at 2:00 blocks slots until 2:10.
- **Storing times in the host's local timezone instead of UTC** — Timezone-local storage causes comparison errors when guests in different timezones book the same slot. DST transitions can corrupt calculations. Fix: Store all booking times as timestamptz (UTC). The availability table stores time-of-day in the host's timezone for weekly scheduling, but all bookings and comparisons use UTC. Convert to guest timezone only for display using date-fns-tz.

## Best practices

- Compute available slots server-side in an API route — never expose the host's full calendar to the client
- Store booking times as timestamptz (UTC) and use date-fns-tz for timezone conversions on display only
- Account for buffer_minutes on both sides of existing bookings when computing available slots
- Store GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in V0's Vars tab without NEXT_PUBLIC_ prefix — they are server-only
- Use V0's Design Mode (Option+D) to adjust the booking page layout and Calendar styling without spending credits
- Allow public INSERT on the bookings table via RLS while restricting SELECT to the host for the public booking flow

## Frequently asked questions

### Can I build this on V0's free tier?

V0 Free provides enough credits for the basic booking page and dashboard. Premium ($20/month) is recommended because this project has a public booking page, availability editor, event type manager, and calendar integration that benefit from prompt queuing.

### How does the slot computation handle timezones?

The host sets availability in their local timezone. The API route generates slots in the host's timezone, checks conflicts in UTC, then converts the remaining available slots to the guest's timezone using date-fns-tz. All bookings are stored as UTC timestamptz.

### Do guests need to create an account to book?

No. The public booking page at /[username]/[slug] requires no authentication. Guests enter their name, email, and optionally phone number. The bookings table has an RLS policy that allows public INSERT while restricting reads to the host.

### How does Google Calendar sync work?

The host connects their Google account via OAuth. When a booking is confirmed, the system creates a Google Calendar event using the Calendar API with the booking details. The google_event_id is stored for future updates or cancellations.

### How do I deploy the scheduling app?

Click Share then Publish to Production in V0. After deploying, update the Google Cloud Console OAuth redirect URI to https://yourdomain.vercel.app/api/calendar/google/callback. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET (server-only), and NEXT_PUBLIC_APP_URL in V0's Vars tab.

### Can RapidDev help build a custom scheduling platform?

Yes. RapidDev has built 600+ apps including scheduling platforms with team round-robin, payment collection, and multi-calendar sync. Book a free consultation to discuss your requirements.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/scheduling-app
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/scheduling-app
