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
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
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.
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 true9// * 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 text16// * 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/availability24// React frontend with public booking page and provider dashboardPro 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.
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.
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';56export 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' });1011 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' });1314 const [providerRow] = await db.select().from(availability).where(eq(availability.providerId, providerId)).limit(1);15 const timezone = 'America/New_York'; // TODO: fetch from providers table1617 // Parse the target date in provider's timezone18 const targetDate = DateTime.fromISO(date, { zone: timezone });19 const dayOfWeek = targetDate.weekday % 7; // luxon: 1=Mon...7=Sun, we want 0=Sun2021 // Check for override on this date22 const [override] = await db.select().from(availabilityOverrides).where(23 and(eq(availabilityOverrides.providerId, providerId), eq(availabilityOverrides.date, date))24 );2526 if (override?.isBlocked) return res.json({ slots: [] });2728 // Get recurring schedule for this day29 const schedule = await db.select().from(availability).where(30 and(eq(availability.providerId, providerId), eq(availability.dayOfWeek, dayOfWeek), eq(availability.isActive, true))31 );3233 if (schedule.length === 0) return res.json({ slots: [] });3435 // Use override times if present, otherwise use schedule36 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]) });4041 // Fetch existing bookings for this provider on this date42 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 );4748 // Generate slots49 const slots = [];50 let slotStart = workStart;51 const durationMinutes = service.durationMinutes;5253 while (slotStart.plus({ minutes: durationMinutes }) <= workEnd) {54 const slotEnd = slotStart.plus({ minutes: durationMinutes });5556 // Check if slot overlaps any existing booking57 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 });6263 if (!overlaps) {64 slots.push({ start: slotStart.toISO(), end: slotEnd.toISO(), displayStart: slotStart.toFormat('h:mm a') });65 }6667 slotStart = slotStart.plus({ minutes: durationMinutes + 15 }); // 15-min buffer between slots68 }6970 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.
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.
1import crypto from 'crypto';2import { db } from '../db.js';3import { bookings } from '../../shared/schema.js';4import { and, eq, lt, gte } from 'drizzle-orm';56function generateConfirmationCode() {7 return 'BOOK-' + crypto.randomBytes(4).toString('hex').toUpperCase();8}910export async function createBooking(req, res) {11 const { providerId, serviceId, customerName, customerEmail, startTime, notes } = req.body;12 const start = new Date(startTime);1314 const [service] = await db.select().from(services).where(eq(services.id, serviceId));15 if (!service) return res.status(404).json({ error: 'Service not found' });1617 const end = new Date(start.getTime() + service.durationMinutes * 60 * 1000);1819 const client = await db.$client.connect();20 try {21 await client.query('BEGIN');2223 // Check for overlapping bookings with row lock24 const { rows: conflicts } = await client.query(25 `SELECT id FROM bookings26 WHERE provider_id = $1 AND status != 'cancelled'27 AND start_time < $2 AND end_time > $328 FOR UPDATE`,29 [providerId, end, start]30 );3132 if (conflicts.length > 0) {33 await client.query('ROLLBACK');34 return res.status(409).json({ error: 'This time slot is no longer available' });35 }3637 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 );4344 await client.query('COMMIT');4546 // Send confirmation email (non-blocking)47 sendConfirmationEmail(booking).catch(console.error);4849 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'.
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.
1import { Resend } from 'resend'; // or: import sgMail from '@sendgrid/mail';23const resend = new Resend(process.env.RESEND_API_KEY);45export 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 });1011 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}2324// Add to Replit Secrets (lock icon 🔒):25// RESEND_API_KEY=re_...26// APP_URL=https://your-deployed-url.replit.app27//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
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';56export 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' });1011 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' });1617 const tz = provider.timezone;18 const target = DateTime.fromISO(date, { zone: tz });19 const dow = target.weekday % 7;2021 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 ]);2526 if (override?.isBlocked || schedule.length === 0) return res.json({ slots: [] });2728 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 });3233 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 );3637 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation