1) Backend (Express) — proxy to Cal.com

Run this as a tiny server (or serverless function). Never call Cal.com with secrets from the browser.

// server/index.ts
import express from "express";
import fetch from "node-fetch";
import cors from "cors";

const app = express();
app.use(cors());
app.use(express.json());

const CAL_API = "<https://api.cal.com>";
const CAL_VERSION_SLOTS = "2024-09-04";   // v2 /slots requires this header
const CAL_VERSION_BOOK = "2024-08-13";    // v2 /bookings requires this header
const CAL_TOKEN = process.env.CAL_TOKEN!; // Personal API key (starts with cal_) or managed-user access token

// GET /api/slots?eventTypeId=123&date=2025-10-01&tz=America/New_York
app.get("/api/slots", async (req, res) => {
  const { eventTypeId, username, eventTypeSlug, date, tz } = req.query as any;
  // choose either ?eventTypeId=… OR ?username=…&eventTypeSlug=…
  const qs =
    eventTypeId
      ? `eventTypeId=${eventTypeId}`
      : `eventTypeSlug=${encodeURIComponent(eventTypeSlug)}&username=${encodeURIComponent(username)}`;

  const start = date;                              // YYYY-MM-DD
  const end = new Date(`${date}T00:00:00Z`);       // next day
  end.setUTCDate(end.getUTCDate() + 1);
  const endISO = end.toISOString().slice(0, 10);   // YYYY-MM-DD

  const url = `${CAL_API}/v2/slots?${qs}&start=${start}&end=${endISO}&timeZone=${encodeURIComponent(tz)}`;
  const r = await fetch(url, {
    headers: {
      Authorization: `Bearer ${CAL_TOKEN}`,
      "cal-api-version": CAL_VERSION_SLOTS,
    },
  });
  const json = await r.json();
  res.status(r.status).json(json);
});

// POST /api/book  { eventTypeId, start, name, email, timeZone }
app.post("/api/book", async (req, res) => {
  const body = req.body; // {eventTypeId:number, start:string(ISO UTC), name, email, timeZone}
  const r = await fetch(`${CAL_API}/v2/bookings`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${CAL_TOKEN}`,
      "cal-api-version": CAL_VERSION_BOOK,
    },
    body: JSON.stringify({
      eventTypeId: body.eventTypeId,
      start: body.start,            // e.g. "2025-10-01T14:00:00Z"
      attendee: { name: body.name, email: body.email },
      timeZone: body.timeZone,
      // optional: notes, answers, location, metadata, etc.
    }),
  });
  const json = await r.json();
  res.status(r.status).json(json);
});

app.listen(4000, () => console.log("API on <http://localhost:4000>"));

Env:

CAL_TOKEN=cal_xxxxxx_your_api_key

Docs: Cal.com v2 /slots & /bookings plus required headers. (Cal)


2) Frontend (React) — shadcn calendar + slots + book

Install shadcn’s calendar deps (it wraps react-day-picker):

npm i date-fns react-day-picker
# (and your shadcn/ui setup; assume Calendar is generated at "@/components/ui/calendar")

// Book.tsx (React, CRA/Vite)
import { useEffect, useMemo, useState } from "react";
import { Calendar } from "@/components/ui/calendar";
import { formatISO, parseISO } from "date-fns";

const API = "<http://localhost:4000>";        // your Express base
const EVENT_TYPE_ID = 123;                  // <-- your Cal.com eventTypeId

export default function Book() {
  const [date, setDate] = useState<Date>();
  const [slots, setSlots] = useState<string[]>([]);
  const [loading, setLoading] = useState(false);
  const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;

  useEffect(() => {
    if (!date) return;
    const d = date.toISOString().slice(0, 10); // YYYY-MM-DD
    setLoading(true);
    fetch(`${API}/api/slots?eventTypeId=${EVENT_TYPE_ID}&date=${d}&tz=${encodeURIComponent(timeZone)}`)
      .then(r => r.json())
      .then(({ slots }) => setSlots(slots ?? []))
      .finally(() => setLoading(false));
  }, [date, timeZone]);

  return (
    <div className="mx-auto grid max-w-4xl gap-6 md:grid-cols-2">
      <Calendar mode="single" selected={date} onSelect={setDate} />
      <div className="space-y-4">
        <h3 className="text-lg font-semibold">Available times</h3>
        {loading && <p className="text-sm opacity-70">Loading…</p>}
        <div className="flex flex-wrap gap-2">
          {slots.map((s) => (
            <button
              key={s}
              className="rounded-md border px-3 py-2 hover:bg-accent"
              onClick={() => pickSlot(s)}
            >
              {s}
            </button>
          ))}
        </div>
      </div>
    </div>
  );

  async function pickSlot(localTime: string) {
    if (!date) return;
    // Convert local slot (e.g. "14:00") + selected date to UTC ISO for Cal.com
    const local = new Date(`${date.toISOString().slice(0,10)}T${localTime}:00`);
    const startUTC = new Date(
      local.getTime() - (local.getTimezoneOffset() * 60000)
    ).toISOString().replace(/\\.\\d{3}Z$/, "Z");

    const name = prompt("Your name") ?? "";
    const email = prompt("Your email") ?? "";
    const r = await fetch(`${API}/api/book`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        eventTypeId: EVENT_TYPE_ID,
        start: startUTC,
        name, email,
        timeZone,
      }),
    });
    const json = await r.json();
    if (r.ok) {
      alert("Booked! Check your email for confirmation.");
      // json.bookingUid, calendar links available via /v2/bookings/{uid}/calendar-links
    } else {
      console.error(json);
      alert("Booking failed.");
    }
  }
}

The /v2/slots response returns available times; you render them, then POST /v2/bookings with the chosen start time. Both endpoints require the cal-api-version header and auth. (Cal)