A lightweight Cal.com-style booking app. Google Calendar + Google Meet only, team round-robin + individual event types, built to deploy to Cloudflare (primary) or Vercel.
- Next.js 15 App Router
- Cloudflare D1 (SQLite) via Drizzle ORM
- Google OAuth + Google Calendar REST (no
googleapisSDK) - Cloudflare Email Service (public beta) via the
send_emailbinding — no API keys - Tailwind + Radix
pnpm install
# One-time: create D1 and paste the id into wrangler.toml
wrangler d1 create booking-app
# Apply migrations (local dev DB under .wrangler/)
pnpm db:generate # regenerate SQL when schema.ts changes
pnpm db:migrate:local
# Seed
pnpm seed:local
# Google OAuth — see "Google setup" below. Summary:
# create a Web OAuth client, copy the client id/secret into .dev.vars.
cp .dev.vars.example .dev.vars
# edit .dev.vars with real values
pnpm cf:preview # boots wrangler dev with D1 + secretsThe app uses one Google OAuth Web client for sign-in and per-user Calendar
access. Google Meet links are created by the Calendar API via
conferenceData — there's no separate Meet API to enable.
Go to https://console.cloud.google.com and create a project (or select one).
APIs & Services → Library → search Google Calendar API → Enable.
That's the only API to enable. Meet is bundled into Calendar.
APIs & Services → OAuth consent screen.
- User type: External (unless every booker is in a Google Workspace org you control — then Internal).
- Fill in app name, support email, developer email.
- Scopes — add:
openid,email,profile.../auth/calendar.events— write events and Meet links.../auth/calendar.readonly— read free/busy for availability
- Test users: add every Google account that will sign in while the app is in Testing status. Promote to Production when ready to open sign-up.
APIs & Services → Credentials → Create credentials → OAuth client ID.
- Application type: Web application.
- Authorized redirect URIs — add both:
http://localhost:8787/api/auth/google/callback(localwrangler dev)https://<your-prod-domain>/api/auth/google/callback
Copy the Client ID and Client secret.
Generate a session secret first:
openssl rand -base64 32Local — fill in .dev.vars:
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GOOGLE_REDIRECT_URI=http://localhost:8787/api/auth/google/callback
SESSION_SECRET=<output of openssl rand -base64 32>
APP_URL=http://localhost:8787Cloudflare production — set as Worker secrets:
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put GOOGLE_REDIRECT_URI # https://<prod-domain>/api/auth/google/callback
wrangler secret put SESSION_SECRET
wrangler secret put APP_URL # https://<prod-domain>- Per-user OAuth, not a service account. Each signed-in user grants the
app access to their own calendar — there is no central calendar or shared
credential. Refresh tokens are encrypted with
SESSION_SECRETbefore being stored in D1, so rotating that secret forces everyone to re-consent. - Callback path is hard-coded to
/api/auth/google/callback(src/app/api/auth/google/callback/route.ts). Keep the Google Cloud redirect URIs in sync if you change it.
pnpm db:migrate:remote
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put GOOGLE_REDIRECT_URI
wrangler secret put SESSION_SECRET
wrangler secret put APP_URL
pnpm cf:deployEmail is sent through the send_email Worker binding declared in
wrangler.toml. No API keys required.
One-time domain setup (per Cloudflare docs):
- Your sending domain must be on Cloudflare DNS.
- Dashboard → Email Sending → Onboard Domain → pick the domain →
Add records and onboard. Cloudflare writes MX / SPF / DKIM / DMARC
records onto a
cf-bounce.<yourdomain>subdomain. - DNS typically propagates in 5–15 minutes.
Once onboarded, any from using that domain works. The app's default sender
is bookings@<APP_URL hostname> (see src/lib/email/send.ts).
While testing, uncomment allowed_destination_addresses in
wrangler.toml to restrict who the binding can send to. remote = true on
the binding lets wrangler dev call the real service.
The app has a graceful fallback: if env.SEND_EMAIL is missing it logs a
warning and continues — useful on platforms without the binding (e.g. Vercel).
Vercel is a secondary target. The D1 binding is swapped for the Cloudflare D1
REST API, and the Cloudflare Email binding is not available there — you'd
either fall back to a provider HTTP API (Resend, Postmark) in src/lib/email/send.ts
or call the Cloudflare Email REST API if/when Cloudflare exposes one.
/marketing/loginGoogle sign-in/dashboard+/dashboard/bookings+/dashboard/event-types/[id]+/dashboard/teams/[id]/[username]/[slug]individual booking/team/[slug]/[eventSlug]team round-robin booking/booking/[uid]confirmation/booking/[uid]/rescheduleattendee reschedule
See /root/.claude/plans/we-need-to-build-greedy-thompson.md for the full plan.