Skip to main content
RapidDev - Software Development Agency

How to Build a Booking Platform with Replit

Build a Calendly-style booking platform in Replit in 1-2 hours using Express, PostgreSQL, and Drizzle ORM. You'll get provider availability management, slot computation with conflict prevention, customer-facing booking pages, and email confirmations — all without a local development environment.

What you'll build

  • Express REST API with availability slot computation for any date and service combination
  • PostgreSQL schema with providers, services, availability, availability_overrides, and bookings tables
  • Double-booking prevention using SELECT FOR UPDATE transaction on booking creation
  • Weekly recurring availability schedule with date-specific override support (blocked days, custom hours)
  • Customer-facing booking flow: service select, date picker, slot selection, confirmation form
  • Email confirmations sent via SendGrid or Resend API when a booking is created
  • Provider dashboard with upcoming bookings, calendar view, and availability editor
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate12 min read1-2 hoursReplit Core or higherApril 2026RapidDev Engineering Team
TL;DR

Build a Calendly-style booking platform in Replit in 1-2 hours using Express, PostgreSQL, and Drizzle ORM. You'll get provider availability management, slot computation with conflict prevention, customer-facing booking pages, and email confirmations — all without a local development environment.

What you're building

A booking platform lets service providers — consultants, coaches, freelancers, therapists — define their availability and let customers book time slots without back-and-forth emails. Think Calendly but hosted on your own infrastructure.

Replit Agent builds the Express + Drizzle foundation in one prompt. The core logic — slot computation — is the most complex piece: given a date and service duration, the system generates available time windows by intersecting the provider's recurring weekly schedule, any date-specific overrides, and subtracting already-booked slots. All times are stored in UTC and converted to the provider's timezone for display.

The booking creation route uses a SELECT FOR UPDATE transaction to prevent two customers from booking the same slot simultaneously. A confirmation email goes out immediately via SendGrid or Resend. The provider's dashboard shows upcoming bookings in a weekly calendar view with per-booking status controls.

Final result

A booking platform with provider availability management, a public booking page with real-time slot availability, double-booking prevention, and automatic email confirmations for both provider and customer.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth
ReactFrontend
SendGrid / ResendEmail Notifications

Prerequisites

  • A Replit Core account (required for Replit Auth and built-in PostgreSQL)
  • A SendGrid or Resend account for confirmation emails — free tiers are sufficient (store API key in Secrets)
  • Basic understanding of timezones and what UTC means (no coding experience needed)
  • Know your services: names, durations in minutes, and prices (if charging for appointments)

Build steps

1

Scaffold the project with Agent

Use Agent to generate the complete Express + Drizzle project with the booking schema. Getting the availability model right from the start is critical — it's the foundation of all slot computation.

prompt.txt
1// Prompt to type into Replit Agent:
2// Build a Node.js Express booking platform with Replit Auth and built-in PostgreSQL using Drizzle ORM.
3// Schema in shared/schema.ts:
4// * providers: id serial pk, user_id text not null unique, name text not null, bio text,
5// timezone text not null default 'America/New_York', avatar_url text, created_at timestamp default now()
6// * services: id serial pk, provider_id integer references providers not null,
7// name text not null, duration_minutes integer not null, price integer, description text,
8// is_active boolean default true
9// * availability: id serial pk, provider_id integer references providers not null,
10// day_of_week integer not null (0=Sunday to 6=Saturday), start_time text not null (HH:MM),
11// end_time text not null (HH:MM), is_active boolean default true,
12// unique on (provider_id, day_of_week, start_time)
13// * availability_overrides: id serial pk, provider_id integer references providers not null,
14// date date not null, is_blocked boolean default true,
15// custom_start text, custom_end text
16// * bookings: id serial pk, provider_id integer references providers not null,
17// service_id integer references services not null, customer_name text not null,
18// customer_email text not null, start_time timestamp not null, end_time timestamp not null,
19// status text default 'confirmed', notes text,
20// confirmation_code text unique not null, created_at timestamp default now()
21// Routes: GET /api/providers/:id, GET /api/providers/:id/slots,
22// POST /api/bookings, GET /api/bookings/:code, PATCH /api/bookings/:code/cancel,
23// GET /api/provider/bookings, PUT /api/provider/availability
24// React frontend with public booking page and provider dashboard

Pro tip: Add luxon to package.json for timezone handling: it's much more reliable than trying to use JavaScript's built-in Date with timezone strings. In your Agent prompt, add: 'Use the luxon library for all timezone conversions'.

Expected result: Project structure with schema.ts, server/routes/, and client/src/. Run npx drizzle-kit push in the Shell to create tables in PostgreSQL.

2

Build the slot computation engine

The GET /api/providers/:id/slots endpoint is the heart of the platform. It takes a date and service, computes all possible slots based on the provider's schedule, and subtracts already-booked time.

server/routes/slots.js
1import { db } from '../db.js';
2import { availability, availabilityOverrides, bookings, services } from '../../shared/schema.js';
3import { eq, and, gte, lt } from 'drizzle-orm';
4import { DateTime } from 'luxon';
5
6export async function getAvailableSlots(req, res) {
7 const providerId = parseInt(req.params.id);
8 const { date, serviceId } = req.query;
9 if (!date || !serviceId) return res.status(400).json({ error: 'date and serviceId are required' });
10
11 const [service] = await db.select().from(services).where(eq(services.id, parseInt(serviceId)));
12 if (!service) return res.status(404).json({ error: 'Service not found' });
13
14 const [providerRow] = await db.select().from(availability).where(eq(availability.providerId, providerId)).limit(1);
15 const timezone = 'America/New_York'; // TODO: fetch from providers table
16
17 // Parse the target date in provider's timezone
18 const targetDate = DateTime.fromISO(date, { zone: timezone });
19 const dayOfWeek = targetDate.weekday % 7; // luxon: 1=Mon...7=Sun, we want 0=Sun
20
21 // Check for override on this date
22 const [override] = await db.select().from(availabilityOverrides).where(
23 and(eq(availabilityOverrides.providerId, providerId), eq(availabilityOverrides.date, date))
24 );
25
26 if (override?.isBlocked) return res.json({ slots: [] });
27
28 // Get recurring schedule for this day
29 const schedule = await db.select().from(availability).where(
30 and(eq(availability.providerId, providerId), eq(availability.dayOfWeek, dayOfWeek), eq(availability.isActive, true))
31 );
32
33 if (schedule.length === 0) return res.json({ slots: [] });
34
35 // Use override times if present, otherwise use schedule
36 const startStr = override?.customStart || schedule[0].startTime;
37 const endStr = override?.customEnd || schedule[0].endTime;
38 const workStart = targetDate.set({ hour: parseInt(startStr), minute: parseInt(startStr.split(':')[1]) });
39 const workEnd = targetDate.set({ hour: parseInt(endStr), minute: parseInt(endStr.split(':')[1]) });
40
41 // Fetch existing bookings for this provider on this date
42 const dayStart = targetDate.startOf('day').toJSDate();
43 const dayEnd = targetDate.endOf('day').toJSDate();
44 const existingBookings = await db.select().from(bookings).where(
45 and(eq(bookings.providerId, providerId), gte(bookings.startTime, dayStart), lt(bookings.startTime, dayEnd))
46 );
47
48 // Generate slots
49 const slots = [];
50 let slotStart = workStart;
51 const durationMinutes = service.durationMinutes;
52
53 while (slotStart.plus({ minutes: durationMinutes }) <= workEnd) {
54 const slotEnd = slotStart.plus({ minutes: durationMinutes });
55
56 // Check if slot overlaps any existing booking
57 const overlaps = existingBookings.some(b => {
58 const bStart = DateTime.fromJSDate(b.startTime);
59 const bEnd = DateTime.fromJSDate(b.endTime);
60 return slotStart < bEnd && slotEnd > bStart;
61 });
62
63 if (!overlaps) {
64 slots.push({ start: slotStart.toISO(), end: slotEnd.toISO(), displayStart: slotStart.toFormat('h:mm a') });
65 }
66
67 slotStart = slotStart.plus({ minutes: durationMinutes + 15 }); // 15-min buffer between slots
68 }
69
70 res.json({ slots });
71}

Pro tip: The 15-minute buffer between slots (slotStart = slotStart.plus({ minutes: durationMinutes + 15 })) gives the provider a break between appointments. Make this configurable per service: add a buffer_minutes column to the services table.

Expected result: GET /api/providers/1/slots?date=2025-06-15&serviceId=1 returns an array of available time slots as ISO strings for that date, with booked slots excluded.

3

Create the booking endpoint with double-booking prevention

The booking creation route must prevent two customers from booking the same slot. A SELECT FOR UPDATE transaction on the bookings table ensures serialized slot checking.

server/routes/bookings.js
1import crypto from 'crypto';
2import { db } from '../db.js';
3import { bookings } from '../../shared/schema.js';
4import { and, eq, lt, gte } from 'drizzle-orm';
5
6function generateConfirmationCode() {
7 return 'BOOK-' + crypto.randomBytes(4).toString('hex').toUpperCase();
8}
9
10export async function createBooking(req, res) {
11 const { providerId, serviceId, customerName, customerEmail, startTime, notes } = req.body;
12 const start = new Date(startTime);
13
14 const [service] = await db.select().from(services).where(eq(services.id, serviceId));
15 if (!service) return res.status(404).json({ error: 'Service not found' });
16
17 const end = new Date(start.getTime() + service.durationMinutes * 60 * 1000);
18
19 const client = await db.$client.connect();
20 try {
21 await client.query('BEGIN');
22
23 // Check for overlapping bookings with row lock
24 const { rows: conflicts } = await client.query(
25 `SELECT id FROM bookings
26 WHERE provider_id = $1 AND status != 'cancelled'
27 AND start_time < $2 AND end_time > $3
28 FOR UPDATE`,
29 [providerId, end, start]
30 );
31
32 if (conflicts.length > 0) {
33 await client.query('ROLLBACK');
34 return res.status(409).json({ error: 'This time slot is no longer available' });
35 }
36
37 const confirmationCode = generateConfirmationCode();
38 const { rows: [booking] } = await client.query(
39 `INSERT INTO bookings (provider_id, service_id, customer_name, customer_email, start_time, end_time, status, notes, confirmation_code)
40 VALUES ($1, $2, $3, $4, $5, $6, 'confirmed', $7, $8) RETURNING *`,
41 [providerId, serviceId, customerName, customerEmail, start, end, notes || null, confirmationCode]
42 );
43
44 await client.query('COMMIT');
45
46 // Send confirmation email (non-blocking)
47 sendConfirmationEmail(booking).catch(console.error);
48
49 res.status(201).json({ booking, confirmationCode });
50 } catch (err) {
51 await client.query('ROLLBACK');
52 res.status(500).json({ error: 'Booking failed' });
53 } finally {
54 client.release();
55 }
56}

Pro tip: The confirmation code (e.g., BOOK-A3F9B2C1) gives customers a human-readable way to look up and cancel their booking without needing an account. Include it prominently in the confirmation email.

Expected result: POST /api/bookings returns the booking with a confirmation code. If two requests hit simultaneously for the same slot, only one succeeds — the other receives 409 'This time slot is no longer available'.

4

Add email confirmations and deploy on Autoscale

Send confirmation emails to both the customer and provider when a booking is created. Store your email API key in Replit Secrets and deploy on Autoscale — booking pages have unpredictable traffic spikes.

server/utils/email.js
1import { Resend } from 'resend'; // or: import sgMail from '@sendgrid/mail';
2
3const resend = new Resend(process.env.RESEND_API_KEY);
4
5export async function sendConfirmationEmail(booking) {
6 const dateStr = new Date(booking.start_time).toLocaleString('en-US', {
7 weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
8 hour: '2-digit', minute: '2-digit', timeZoneName: 'short',
9 });
10
11 await resend.emails.send({
12 from: 'bookings@yourdomain.com',
13 to: booking.customer_email,
14 subject: `Booking Confirmed — ${dateStr}`,
15 html: `
16 <h2>Your booking is confirmed!</h2>
17 <p><strong>Date:</strong> ${dateStr}</p>
18 <p><strong>Confirmation code:</strong> ${booking.confirmation_code}</p>
19 <p>To cancel, visit: ${process.env.APP_URL}/bookings/${booking.confirmation_code}/cancel</p>
20 `,
21 });
22}
23
24// Add to Replit Secrets (lock icon 🔒):
25// RESEND_API_KEY=re_...
26// APP_URL=https://your-deployed-url.replit.app
27//
28// Deploy on Autoscale:
29// Booking pages get shared on social media and booking links — traffic is unpredictable.
30// Autoscale handles spikes automatically. Cold starts are hidden by the booking form load time.
31// Set deployment target in .replit:
32// [deployment]
33// deploymentTarget = "autoscale"

Pro tip: Also add RESEND_API_KEY to Deployment Secrets (not just workspace Secrets) — they are separate environments. Without this, emails will work in dev but silently fail after deployment.

Expected result: After a successful booking, both the customer and provider receive a confirmation email with the booking date and confirmation code. The code can be used at GET /api/bookings/:code to retrieve booking details.

Complete code

server/routes/slots.js
1import { db } from '../db.js';
2import { availability, availabilityOverrides, bookings, services, providers } from '../../shared/schema.js';
3import { eq, and, gte, lt } from 'drizzle-orm';
4import { DateTime } from 'luxon';
5
6export async function getAvailableSlots(req, res) {
7 const providerId = parseInt(req.params.id);
8 const { date, serviceId } = req.query;
9 if (!date || !serviceId) return res.status(400).json({ error: 'date and serviceId are required' });
10
11 const [[service], [provider]] = await Promise.all([
12 db.select().from(services).where(eq(services.id, parseInt(serviceId))),
13 db.select().from(providers).where(eq(providers.id, providerId)),
14 ]);
15 if (!service || !provider) return res.status(404).json({ error: 'Service or provider not found' });
16
17 const tz = provider.timezone;
18 const target = DateTime.fromISO(date, { zone: tz });
19 const dow = target.weekday % 7;
20
21 const [[override], schedule] = await Promise.all([
22 db.select().from(availabilityOverrides).where(and(eq(availabilityOverrides.providerId, providerId), eq(availabilityOverrides.date, date))),
23 db.select().from(availability).where(and(eq(availability.providerId, providerId), eq(availability.dayOfWeek, dow), eq(availability.isActive, true))),
24 ]);
25
26 if (override?.isBlocked || schedule.length === 0) return res.json({ slots: [] });
27
28 const startStr = (override?.customStart || schedule[0].startTime).split(':');
29 const endStr = (override?.customEnd || schedule[0].endTime).split(':');
30 const workStart = target.set({ hour: parseInt(startStr[0]), minute: parseInt(startStr[1]), second: 0, millisecond: 0 });
31 const workEnd = target.set({ hour: parseInt(endStr[0]), minute: parseInt(endStr[1]), second: 0, millisecond: 0 });
32
33 const existing = await db.select().from(bookings).where(
34 and(eq(bookings.providerId, providerId), gte(bookings.startTime, workStart.toJSDate()), lt(bookings.startTime, workEnd.toJSDate()))
35 );
36
37 const slots = [];
38 let cursor = workStart;
39 const dur = service.durationMinutes;
40 const buf = 15;
41 while (cursor.plus({ minutes: dur }) <= workEnd) {
42 const slotEnd = cursor.plus({ minutes: dur });
43 const busy = existing.some(b => cursor < DateTime.fromJSDate(b.endTime) && slotEnd > DateTime.fromJSDate(b.startTime));
44 if (!busy) slots.push({ start: cursor.toISO(), end: slotEnd.toISO(), display: cursor.toFormat('h:mm a') });
45 cursor = cursor.plus({ minutes: dur + buf });
46 }
47 res.json({ slots });
48}

Customization ideas

Booking cancellation with cancellation window

Allow cancellations up to 24 hours before the appointment. In the PATCH /api/bookings/:code/cancel route, check if start_time is more than 24 hours away before setting status to 'cancelled'. Send a cancellation confirmation email.

Stripe payment for paid services

For services with a price, add a payment_status column to bookings (unpaid/paid/refunded). On booking creation, return a Stripe Checkout URL instead of immediately confirming. Confirm the booking only after the webhook receives checkout.session.completed.

Recurring appointment series

Add a series_id column to bookings. When a customer books a recurring appointment (weekly for 8 weeks), create 8 individual booking rows sharing the same series_id. Allow cancelling the entire series or individual sessions.

Common pitfalls

Pitfall: Not converting times to UTC before storing in the database

How to avoid: Store all timestamps in UTC in PostgreSQL. Use luxon's DateTime.fromISO(timeString, { zone: providerTimezone }).toUTC().toJSDate() when inserting. Convert back to the provider's timezone only for display.

Pitfall: Not using a transaction for booking creation

How to avoid: Use a BEGIN / SELECT ... FOR UPDATE / INSERT / COMMIT transaction as shown in Step 3. The FOR UPDATE lock ensures only one request can insert for a given time range at a time.

Pitfall: Not adding Resend/SendGrid API key to Deployment Secrets

How to avoid: After deploying, go to Deployments → Secrets and add RESEND_API_KEY (or SENDGRID_API_KEY) with the same value as your workspace Secret.

Best practices

  • Store all timestamps in UTC and convert to provider timezone only for display — avoids DST bugs.
  • Use SELECT FOR UPDATE in a transaction on booking creation to prevent double-bookings.
  • Install luxon for timezone handling — JavaScript's built-in Date is not reliable for timezone conversions.
  • Send confirmation emails asynchronously (don't await in the route handler) so email delays don't slow the booking response.
  • Store email API keys in Replit Secrets (lock icon) and separately in Deployment Secrets after deploying.
  • Deploy on Autoscale — booking page links get shared on social media and appointment reminders cause traffic spikes.
  • Use a PostgreSQL connection retry wrapper to handle the 5-minute idle sleep before the first booking of the day.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a booking platform with Express and PostgreSQL using Drizzle ORM. I have an availability table (provider_id, day_of_week 0-6, start_time HH:MM, end_time HH:MM) and a bookings table (provider_id, start_time timestamp, end_time timestamp, status). Help me write a slot computation function that: takes a providerId, date (YYYY-MM-DD), and service duration in minutes; loads the provider's schedule for that weekday; checks for date-specific overrides; generates available time slots by dividing the work window into service-length chunks; and subtracts existing bookings. Use the luxon library for timezone handling.

Build Prompt

Add appointment reminder emails to the booking platform. Create a reminders_sent table (booking_id integer, reminder_type text '24h'/'1h', sent_at timestamp). Run a setInterval every 5 minutes on Reserved VM that finds bookings where start_time is between 23-25 hours away and no 24h reminder exists, then sends a reminder email and logs to reminders_sent. Repeat for 1-hour reminders.

Frequently asked questions

Can multiple providers use the same booking platform?

Yes. Each Replit Auth user who logs in and completes their provider profile gets their own availability schedule, services, and booking page at /book/:providerId. All data is scoped by provider_id in every query.

How do I set up availability for a provider who works Monday-Friday, 9am-5pm?

Insert 5 rows into the availability table: day_of_week 1 through 5 (Monday through Friday), start_time '09:00', end_time '17:00'. The slot computation engine reads these rows to generate time slots for each weekday.

What happens if a customer books while I'm looking at an available slot?

The SELECT FOR UPDATE transaction ensures only one booking request can check and claim a slot at a time. The second customer sees 'This time slot is no longer available' and is prompted to select a different time.

Do I need Replit Core for this build?

Yes. Replit Auth (used for provider login and dashboard) requires Replit Core or higher. The customer-facing booking page doesn't require auth, but the provider management features do.

How do I handle customers in a different timezone than the provider?

Store all times in UTC. On the public booking page, detect the customer's browser timezone using Intl.DateTimeFormat().resolvedOptions().timeZone and convert the displayed slot times to the customer's local timezone for display. The stored booking times remain in UTC.

Can RapidDev help build a custom booking system for my business?

Yes. RapidDev has built 600+ apps including multi-location booking systems, resource-based scheduling, and booking platforms with Stripe payment collection. Reach out for a free consultation.

Why are my confirmation emails not arriving after deployment?

The most common cause is that the email API key (RESEND_API_KEY or SENDGRID_API_KEY) is only in workspace Secrets but not in Deployment Secrets. Go to Deployments → Secrets and add the same key there. Workspace Secrets don't carry over to deployed environments automatically.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.