How to host a booking page on Cloudflare

How to host a booking page on Cloudflare puts Slot on one Worker, stores each booking in D1, and serves the hours from a domain you already own.

  • guide
  • vibe-coding
  • calendly

How to host a booking page on Cloudflare is the public URL for Slot, the 6-hour Calendly v1. The catalog line is “Your availability rules, one pleasant booking page.”

You pay $12–$20 a person a month. That is $144–$240 a year, and the card says the price scales with the team. The verdict is “Very vibeable”. The first estimate is 6 hours: one meeting type, weekly availability, bookings.

The Calendly teardown is the availability math. The Calendly guide builds the screens and the slot lock. This guide is where that app lives. One Worker, a D1 database named slot, and book.flaviocopes.com.

The hosting post is the general map. Typeform is rows only. Buffer adds a file and a cron. Slot sits with Typeform. A vote grid with no weekly hours is the Doodle guide. Doodle is $7–$9 a person a month, about 3 hours.

How to host a booking page on Cloudflare as one Worker

The Calendly page splits the work into three rows.

The first row is the one you put on a hostname. One meeting type. Weekday hours. A public page. A booking that lands in a table. Six hours.

The second row is 2 days, 16 hours. Google Calendar sync, timezones, reschedule links. The third row is 1 month and up, 160 hours. Team routing, payments, every calendar provider. The non-goal names those, and it also names extra calendar providers.

The product pages put the bill this way. A focused replacement costs your build time plus close-to-zero hosting. D1 is on Cloudflare’s Free and Paid plans. The Workers Paid plan includes 100,000 databases, and another database is $0.10 a month. One database named slot stays inside that allowance.

This catalog is static Astro on Cloudflare Pages. npm run deploy builds dist/ and runs wrangler pages deploy. There is no D1 binding in this repo, because the posts are markdown in git. Slot is a different project. A page that writes a booking needs the Astro adapter, the same way the hosting post says a form that writes a row needs it.

workers.dev is the hostname you get before a zone exists. Cloudflare treats that subdomain as a Free website and points production Workers at a custom domain or a route. The link in the email to Jordan is book.flaviocopes.com.

Where the page, the rows, and the domain split

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The card lists Astro, HTMX, and D1. This site is an Astro app on Cloudflare, so the pages are Astro and the rows are D1. Use TanStack Start if React is your weekday language. The stack post is which tab you copy. On that tab the database is whatever SQL database you already run, and the custom domain still points at one app.

The guest opens /book/intro-30. The owner opens /admin/bookings. Both requests hit the same Worker. Astro reads the weekly hours, writes the booking, and renders the confirmation. D1 stores the meeting type, the weekly rule, the holds, and the bookings. D1’s get started creates that database with npx wrangler d1 create slot and prints the binding. Nothing in this v1 waits on a queue.

BROWSER book.flaviocopes.com Guest page and owner list ASTRO ON ONE WORKER Reads hours, writes a booking Adapter, so the binding exists D1 · DATABASE slot Meeting type and weekly hours Holds and bookings OUTSIDE THIS HOST workers.dev as the guest link R2 and a cron trigger Google Calendar OAuth A second app for /admin This catalog's Pages project
One hostname, one Worker, one D1 database. The marketing site stays on its own project.

Cloudflare’s custom domains page is the routing rule. You need an active zone on the account, and a Worker to call. A custom domain cannot sit on a hostname that already has a CNAME, and it cannot sit on a zone you do not own. After it attaches, Cloudflare creates the DNS record and the certificate. Every path on that hostname goes to the Worker, so /book/intro-30 and /admin/bookings share an origin.

The binding uses the names from wrangler d1 create slot.

{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "slot",
      "database_id": "replace-after-wrangler-d1-create"
    }
  ]
}

Paste the id the command prints. The hostname is a second block, with workers_dev off so the overview URL is not the one you copy into email.

{
  "workers_dev": false,
  "routes": [
    {
      "pattern": "book.flaviocopes.com",
      "custom_domain": true
    }
  ]
}

npx wrangler deploy creates that hostname. Run it after the zone for flaviocopes.com is on this account, and after book has no CNAME of its own.

What the remote database holds

The card lists MeetingType, AvailabilityRule, CalendarConnection, and Booking. This host stores the meeting type, the weekly rule, and the booking. CalendarConnection is the 2-day row. Leave that table out, so the Worker has no OAuth client to configure.

The meeting type you seed is intro-30. Title Intro call, 30 minutes, timezone America/New_York, Monday to Friday 09:00 to 17:00, 15 minutes of buffer after, 4 hours of notice (noticeMin 240), and a 14-day horizon. A booking stores a UTC start, the guest name, the guest email, and the guest timezone. A hold stores that same start with an expiry five minutes out. The Calendly guide builds the page. How to prevent double booking is the hold, the batch, and the unique start. Here the rows have to exist in remote D1, which is a different file from the SQLite database under .wrangler.

INTRO-30 30 min · Mon-Fri 09:00-17:00 America/New_York BOOKING-184 2026-10-27 13:00 UTC Jordan Lee · Europe/Rome HOLD Same start Expires in 5 min LATER Calendar connection REMOTE D1 Deploy reads this database
The seed meeting type and Jordan's booking live in remote D1. The local file is only for wrangler dev.

The screens the hostname has to serve

Three routes. The public page, the confirmation, and the owner list. When one of them 404s on the custom domain and still renders on your laptop, the route is the bug.

Public page

/book/intro-30 shows Intro call, 30 minutes, a date, and start times with a zone label. On Monday 26 October 2026, 14:00 UTC is 10:00 in New York and 15:00 in Rome. The address bar reads book.flaviocopes.com.

WIREFRAME · PUBLIC PAGE book.flaviocopes.com/book/intro-30 Intro call · 30 minutes Mon 26 Oct 2026 13:30 UTC · open 14:00 UTC · booked 15:00 UTC · booked Zone label sits on every time 10:00 New York · 15:00 Rome
The hostname in the bar is the one you send. Times keep a zone label.

Confirmation

Jordan Lee books 13:00 UTC on Tuesday 27 October 2026. The confirmation shows that instant, 09:00 in New York, and 14:00 in Rome. Guests type a name and an email. They do not get an account on this row. The row has to be in remote D1 before you close the tab.

WIREFRAME · CONFIRMATION Jordan Lee · confirmed 2026-10-27 · 13:00 UTC 09:00 America/New_York 14:00 Europe/Rome Guest has no account on this row
The confirmation names the UTC instant and both zones. Email waits for a later row.

Owner list

/admin/bookings is the same Worker, behind the owner login. Refresh it from a phone, on a network that is not your laptop, and Jordan’s row is still there. An empty phone list next to a full wrangler dev screen means the migration ran with --local and the remote database is still empty.

WIREFRAME · OWNER LIST /admin/bookings Jordan Lee · intro-30 13:00 UTC · confirmed Same host as the public page
The owner list and the public page read one database. Refresh the list off the laptop.

The path from a local row to the public URL

Build the 6-hour app on your machine first. Create D1, apply the migration locally, book a test slot, apply the same migration remotely, deploy, and book again on the public URL.

LOCAL BOOKING WORKS wrangler d1 create slot APPLY MIGRATION --local ZONE ON THIS ACCOUNT? And book has no CNAME? NO Fix the zone Then come back YES Apply --remote wrangler deploy PUBLIC PAGE OVER HTTPS book.flaviocopes.com PHONE SHOWS THE ROW Admin list, same host STILL LOCAL ONLY wrangler dev has the row
Remote apply comes after the zone check. A full local list with an empty phone is the dashed state.

The decision is the zone. If flaviocopes.com is not on this account, add it before wrangler deploy. If book already has a CNAME, delete that record first. Cloudflare’s custom-domain page says the attach fails while the CNAME is there, and their migrate-from-routes steps say to delete it, then add the domain.

Cloudflare issues the certificate with the custom domain. If the first HTTPS load fails, reload it before you change the route.

Prompt 1. An Astro app that can take a binding

Copy the Astro prompt from Build your own Calendly. If you write React, copy the TanStack Start prompt instead, and keep the hostname block below. Paste this above the prompt.

Host the 6-hour row on Cloudflare.
Node.js and Astro, with the Cloudflare adapter, so a D1 binding works.
Project name: Slot.

One meeting type, weekly hours, bookings.
Binding name DB. Database name slot.
Leave database_id as a placeholder until I run wrangler d1 create.

Skip Google Calendar, R2, cron, and Durable Objects.
Skip team routing, payments, and a second meeting type.

When the plan lists routes and tables, stop. Do not deploy.

On the TanStack Start tab, the same block names server functions and one SQL database. The custom domain still points at that one app. You add D1 only on the Astro tab, because that is the capability line on the card.

What to check after the scaffold

The Astro config uses the Cloudflare adapter. wrangler.jsonc has d1_databases and no r2_buckets. The dev server shows empty admin screens. The public booking URL 404s until a meeting type exists. A pages_build_output_dir with no adapter is how this catalog ships. Slot needs the other shape.

Prompt 2. The tables, local first

Add the tables for Slot and seed the meeting type.

meeting_types, availability_rules, holds, bookings.
bookings.start_at is a UTC timestamp.
Leave out calendar_connections.

Seed intro-30:
- Intro call, 30 minutes
- Mon-Fri 09:00-17:00 America/New_York
- 15 minute buffer after
- 4 hour minimum notice
- 14 day horizon

Document these two commands and add no extra flags:
npx wrangler d1 migrations apply slot --local
npx wrangler d1 migrations apply slot --remote

Run the local apply. Stop before the remote apply and before deploy.
Done when a local booking is still there after I restart wrangler dev.

What to check after the local booking

Create Intro call if the seed missed it. Book a slot as yourself. Restart the dev server. The row remains. database_id is the id from npx wrangler d1 create slot, not the placeholder text. The migration file is in the repo.

Refuse a weekly window that starts at 17:00 and ends at 09:00. The Calendly guide keeps overnight rules out because DST math breaks on them.

Prompt 3. Attach the hostname

Attach the production hostname. Deploy only after the zone check passes.

In wrangler.jsonc:
- workers_dev false
- routes pattern book.flaviocopes.com
- custom_domain true

Stop if any line fails:
- The zone flaviocopes.com is on this Cloudflare account.
- book.flaviocopes.com has no CNAME record.
- The remote migration has been applied.

Then run npx wrangler deploy.
Done when https://book.flaviocopes.com/book/intro-30
shows Intro call over HTTPS.

What to check after deploy

The address bar is book.flaviocopes.com. The certificate is valid. /admin/bookings on that host asks the owner to sign in. A request to the workers.dev host for this Worker does not serve the app, because workers_dev is false.

If TLS fails on the first try, wait and reload. If deploy says the hostname already has a CNAME, delete that record on the zone and deploy again.

Prompt 4. Book from a phone

Add no features. I will book on the public URL.

Open https://book.flaviocopes.com/book/intro-30 on a phone.
Submit Jordan Lee, [email protected], Europe/Rome,
for 13:00 UTC on 2026-10-27.

Done when /admin/bookings on that same host shows the row
after a refresh, and a second browser gets the slot-taken
response for that start.
Keep the lock rules from the Calendly guide. Leave them as they are.

What to check after the public booking

The phone is on the public URL. The admin list shows Jordan. A second submit does not insert a second row. If the phone shows open slots and the admin list stays empty, the form and the list are reading different databases. One binding, named DB.

Failures the agent ships anyway

A Pages deploy for a form that writes

wrangler pages deploy is how this catalog goes out. Slot writes a row, so the script is wrangler deploy after the adapter is in the Astro config. A guest page that renders and a POST that 404s is a static build.

The local database treated as production

wrangler d1 migrations apply slot --local fills the file under .wrangler. The public Worker reads remote D1. Apply --remote before the link goes out. The D1 docs use those two flags for the same migration.

A CNAME already on book

The custom domain never appears, or deploy stops on that hostname. Delete the CNAME for book, then deploy again. The hostname also has to be on a zone this account owns.

The workers.dev URL in the email

The overview copies slot.<account>.workers.dev. Cloudflare calls that host a Free website for hobby projects. workers_dev: false leaves book.flaviocopes.com as the URL you send.

Admin on a second project

The agent puts /admin on Pages and /book on a Worker. The list and the form then use two databases. Both paths stay on the one Worker that holds the DB binding.

A calendar key in the host config

CALENDAR_CLIENT_ID in the env file is the 2-day row. The non-goal says no extra calendar providers in v1. Remove the key before deploy. The guardrail you do ship is the booking check inside the write, which the Calendly guide already walks.

Slot wants Astro, HTMX, and D1. The domain is book.flaviocopes.com.

Create slot, apply the migration locally, apply it remotely, deploy, then book from a phone. Export D1 before you cancel Calendly. The card also stands in for Cal.com, SavvyCal, and Acuity Scheduling when you wanted a personal booking link.

The hosting post covers R2 and cron for cards that store a file or publish later. Slot’s first row uses neither.

What to skip on purpose

Leave the zone’s other hostnames alone. This catalog stays on its own Pages project. Slot keeps its own tables.

Skip team routing, payments, and a second calendar provider. Skip a cron whose only job is deleting expired holds. The Calendly guide releases a hold on the next read, after expires_at.

THIS HOST One Worker, adapter on D1 database named slot book.flaviocopes.com SKIP ON THIS DEPLOY workers.dev in the email R2, cron, a second app Google Calendar on day one
Ship the hostname and the database. Calendar sync is the 2-day row, after a guest has booked.

Reschedule links and an .ics file are that 2-day row. Add them after a guest asks. A one-question form in front of the call is Typeform, $29–$99 a month, about 2 hours. The Typeform guide is that form and its table.

Start now

Go to Build your own Calendly. Copy the Astro prompt, or TanStack Start if that is what you write.

Put the host in the same message as the 6-hour row. One Worker, binding DB, database slot, hostname book.flaviocopes.com.

Book one intro call on that URL, from a phone, before you cancel the $12 plan.

Related articles

Read all articles →