# How to Build a Booking Platform with Replit

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

## 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.

## Before you start

- 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)

## Step-by-step guide

### 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 to type into Replit Agent:
// Build a Node.js Express booking platform with Replit Auth and built-in PostgreSQL using Drizzle ORM.
// Schema in shared/schema.ts:
// * providers: id serial pk, user_id text not null unique, name text not null, bio text,
//   timezone text not null default 'America/New_York', avatar_url text, created_at timestamp default now()
// * services: id serial pk, provider_id integer references providers not null,
//   name text not null, duration_minutes integer not null, price integer, description text,
//   is_active boolean default true
// * availability: id serial pk, provider_id integer references providers not null,
//   day_of_week integer not null (0=Sunday to 6=Saturday), start_time text not null (HH:MM),
//   end_time text not null (HH:MM), is_active boolean default true,
//   unique on (provider_id, day_of_week, start_time)
// * availability_overrides: id serial pk, provider_id integer references providers not null,
//   date date not null, is_blocked boolean default true,
//   custom_start text, custom_end text
// * bookings: id serial pk, provider_id integer references providers not null,
//   service_id integer references services not null, customer_name text not null,
//   customer_email text not null, start_time timestamp not null, end_time timestamp not null,
//   status text default 'confirmed', notes text,
//   confirmation_code text unique not null, created_at timestamp default now()
// Routes: GET /api/providers/:id, GET /api/providers/:id/slots,
// POST /api/bookings, GET /api/bookings/:code, PATCH /api/bookings/:code/cancel,
// GET /api/provider/bookings, PUT /api/provider/availability
// 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.

```
import { db } from '../db.js';
import { availability, availabilityOverrides, bookings, services } from '../../shared/schema.js';
import { eq, and, gte, lt } from 'drizzle-orm';
import { DateTime } from 'luxon';

export async function getAvailableSlots(req, res) {
  const providerId = parseInt(req.params.id);
  const { date, serviceId } = req.query;
  if (!date || !serviceId) return res.status(400).json({ error: 'date and serviceId are required' });

  const [service] = await db.select().from(services).where(eq(services.id, parseInt(serviceId)));
  if (!service) return res.status(404).json({ error: 'Service not found' });

  const [providerRow] = await db.select().from(availability).where(eq(availability.providerId, providerId)).limit(1);
  const timezone = 'America/New_York'; // TODO: fetch from providers table

  // Parse the target date in provider's timezone
  const targetDate = DateTime.fromISO(date, { zone: timezone });
  const dayOfWeek = targetDate.weekday % 7; // luxon: 1=Mon...7=Sun, we want 0=Sun

  // Check for override on this date
  const [override] = await db.select().from(availabilityOverrides).where(
    and(eq(availabilityOverrides.providerId, providerId), eq(availabilityOverrides.date, date))
  );

  if (override?.isBlocked) return res.json({ slots: [] });

  // Get recurring schedule for this day
  const schedule = await db.select().from(availability).where(
    and(eq(availability.providerId, providerId), eq(availability.dayOfWeek, dayOfWeek), eq(availability.isActive, true))
  );

  if (schedule.length === 0) return res.json({ slots: [] });

  // Use override times if present, otherwise use schedule
  const startStr = override?.customStart || schedule[0].startTime;
  const endStr = override?.customEnd || schedule[0].endTime;
  const workStart = targetDate.set({ hour: parseInt(startStr), minute: parseInt(startStr.split(':')[1]) });
  const workEnd = targetDate.set({ hour: parseInt(endStr), minute: parseInt(endStr.split(':')[1]) });

  // Fetch existing bookings for this provider on this date
  const dayStart = targetDate.startOf('day').toJSDate();
  const dayEnd = targetDate.endOf('day').toJSDate();
  const existingBookings = await db.select().from(bookings).where(
    and(eq(bookings.providerId, providerId), gte(bookings.startTime, dayStart), lt(bookings.startTime, dayEnd))
  );

  // Generate slots
  const slots = [];
  let slotStart = workStart;
  const durationMinutes = service.durationMinutes;

  while (slotStart.plus({ minutes: durationMinutes }) <= workEnd) {
    const slotEnd = slotStart.plus({ minutes: durationMinutes });

    // Check if slot overlaps any existing booking
    const overlaps = existingBookings.some(b => {
      const bStart = DateTime.fromJSDate(b.startTime);
      const bEnd = DateTime.fromJSDate(b.endTime);
      return slotStart < bEnd && slotEnd > bStart;
    });

    if (!overlaps) {
      slots.push({ start: slotStart.toISO(), end: slotEnd.toISO(), displayStart: slotStart.toFormat('h:mm a') });
    }

    slotStart = slotStart.plus({ minutes: durationMinutes + 15 }); // 15-min buffer between slots
  }

  res.json({ slots });
}
```

> 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.

```
import crypto from 'crypto';
import { db } from '../db.js';
import { bookings } from '../../shared/schema.js';
import { and, eq, lt, gte } from 'drizzle-orm';

function generateConfirmationCode() {
  return 'BOOK-' + crypto.randomBytes(4).toString('hex').toUpperCase();
}

export async function createBooking(req, res) {
  const { providerId, serviceId, customerName, customerEmail, startTime, notes } = req.body;
  const start = new Date(startTime);

  const [service] = await db.select().from(services).where(eq(services.id, serviceId));
  if (!service) return res.status(404).json({ error: 'Service not found' });

  const end = new Date(start.getTime() + service.durationMinutes * 60 * 1000);

  const client = await db.$client.connect();
  try {
    await client.query('BEGIN');

    // Check for overlapping bookings with row lock
    const { rows: conflicts } = await client.query(
      `SELECT id FROM bookings
       WHERE provider_id = $1 AND status != 'cancelled'
       AND start_time < $2 AND end_time > $3
       FOR UPDATE`,
      [providerId, end, start]
    );

    if (conflicts.length > 0) {
      await client.query('ROLLBACK');
      return res.status(409).json({ error: 'This time slot is no longer available' });
    }

    const confirmationCode = generateConfirmationCode();
    const { rows: [booking] } = await client.query(
      `INSERT INTO bookings (provider_id, service_id, customer_name, customer_email, start_time, end_time, status, notes, confirmation_code)
       VALUES ($1, $2, $3, $4, $5, $6, 'confirmed', $7, $8) RETURNING *`,
      [providerId, serviceId, customerName, customerEmail, start, end, notes || null, confirmationCode]
    );

    await client.query('COMMIT');

    // Send confirmation email (non-blocking)
    sendConfirmationEmail(booking).catch(console.error);

    res.status(201).json({ booking, confirmationCode });
  } catch (err) {
    await client.query('ROLLBACK');
    res.status(500).json({ error: 'Booking failed' });
  } finally {
    client.release();
  }
}
```

> 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.

```
import { Resend } from 'resend'; // or: import sgMail from '@sendgrid/mail';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendConfirmationEmail(booking) {
  const dateStr = new Date(booking.start_time).toLocaleString('en-US', {
    weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
    hour: '2-digit', minute: '2-digit', timeZoneName: 'short',
  });

  await resend.emails.send({
    from: 'bookings@yourdomain.com',
    to: booking.customer_email,
    subject: `Booking Confirmed — ${dateStr}`,
    html: `
      <h2>Your booking is confirmed!</h2>
      <p><strong>Date:</strong> ${dateStr}</p>
      <p><strong>Confirmation code:</strong> ${booking.confirmation_code}</p>
      <p>To cancel, visit: ${process.env.APP_URL}/bookings/${booking.confirmation_code}/cancel</p>
    `,
  });
}

// Add to Replit Secrets (lock icon 🔒):
// RESEND_API_KEY=re_...
// APP_URL=https://your-deployed-url.replit.app
//
// Deploy on Autoscale:
// Booking pages get shared on social media and booking links — traffic is unpredictable.
// Autoscale handles spikes automatically. Cold starts are hidden by the booking form load time.
// Set deployment target in .replit:
// [deployment]
// 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 example

File: `server/routes/slots.js`

```javascript
import { db } from '../db.js';
import { availability, availabilityOverrides, bookings, services, providers } from '../../shared/schema.js';
import { eq, and, gte, lt } from 'drizzle-orm';
import { DateTime } from 'luxon';

export async function getAvailableSlots(req, res) {
  const providerId = parseInt(req.params.id);
  const { date, serviceId } = req.query;
  if (!date || !serviceId) return res.status(400).json({ error: 'date and serviceId are required' });

  const [[service], [provider]] = await Promise.all([
    db.select().from(services).where(eq(services.id, parseInt(serviceId))),
    db.select().from(providers).where(eq(providers.id, providerId)),
  ]);
  if (!service || !provider) return res.status(404).json({ error: 'Service or provider not found' });

  const tz = provider.timezone;
  const target = DateTime.fromISO(date, { zone: tz });
  const dow = target.weekday % 7;

  const [[override], schedule] = await Promise.all([
    db.select().from(availabilityOverrides).where(and(eq(availabilityOverrides.providerId, providerId), eq(availabilityOverrides.date, date))),
    db.select().from(availability).where(and(eq(availability.providerId, providerId), eq(availability.dayOfWeek, dow), eq(availability.isActive, true))),
  ]);

  if (override?.isBlocked || schedule.length === 0) return res.json({ slots: [] });

  const startStr = (override?.customStart || schedule[0].startTime).split(':');
  const endStr = (override?.customEnd || schedule[0].endTime).split(':');
  const workStart = target.set({ hour: parseInt(startStr[0]), minute: parseInt(startStr[1]), second: 0, millisecond: 0 });
  const workEnd = target.set({ hour: parseInt(endStr[0]), minute: parseInt(endStr[1]), second: 0, millisecond: 0 });

  const existing = await db.select().from(bookings).where(
    and(eq(bookings.providerId, providerId), gte(bookings.startTime, workStart.toJSDate()), lt(bookings.startTime, workEnd.toJSDate()))
  );

  const slots = [];
  let cursor = workStart;
  const dur = service.durationMinutes;
  const buf = 15;
  while (cursor.plus({ minutes: dur }) <= workEnd) {
    const slotEnd = cursor.plus({ minutes: dur });
    const busy = existing.some(b => cursor < DateTime.fromJSDate(b.endTime) && slotEnd > DateTime.fromJSDate(b.startTime));
    if (!busy) slots.push({ start: cursor.toISO(), end: slotEnd.toISO(), display: cursor.toFormat('h:mm a') });
    cursor = cursor.plus({ minutes: dur + buf });
  }
  res.json({ slots });
}
```

## Common mistakes

- **Not converting times to UTC before storing in the database** — Storing times in the provider's local timezone causes incorrect slot computation when the provider changes timezone or during DST transitions. Fix: 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.
- **Not using a transaction for booking creation** — Without a transaction, two simultaneous booking requests can both pass the overlap check and create two bookings for the same slot. Fix: 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.
- **Not adding Resend/SendGrid API key to Deployment Secrets** — Email confirmations work in development but fail silently after deployment because workspace Secrets aren't copied to the deployed environment. Fix: 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.

## 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.

---

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