Airbnb cloneVacation rental and stays marketplace

How I Built an Airbnb Clone Using Claude Code: nestly

Designed with Claude, coded and tested with Claude Code, and planned for millions of users with Claude Opus 5.5: how I built nestly, a working Airbnb-style app.

By Suhas Bhairav · Published November 11, 2026 · 46 min read

nestly home page with Homes, Experiences and Services tabs, the pill search bar and carousels of Goa and Coorg stays

Quick answer

Build the frontend on Next.js with stateless servers, keep PostgreSQL with PostGIS as the source of truth, and block double-booking with a daterange exclusion constraint plus idempotency keys. Serve geo and date search from OpenSearch or Meilisearch with availability bitmaps, put images behind a CDN, handle payouts with Stripe Connect or Razorpay Route, and add replicas and caching before sharding.

  • Built with Claude Code in about 33 minutes of agent time: 41 files, 10 pages and 5 API routes, with build and lint passing.
  • Double-booking is solved in the database: EXCLUDE USING gist (listing_id WITH =, stay WITH &&) on a half-open daterange turns races into a clean 409.
  • Model nights as dates in the listing's time zone (daterange), not timestamps; use tstzrange only for hourly experiences and services.
  • Date-filtered search needs a precomputed availability bitmap per listing; joining calendar tables per query does not scale.
  • Checkout should be a signed quote plus an Idempotency-Key stored in the same transaction as the booking.
  • Payments are a marketplace problem: hold funds, pay out after check-in, and drive state from verified, deduplicated webhooks into a double-entry ledger.
  • On Hetzner, put Postgres and search on dedicated AX hardware early and keep cloud servers for stateless and bursty roles; pair with a CDN since there is no India region.
  • The clone is fully clickable: server-side search API, booked-date calendars, 409 on overlap, host publishing and INR/USD/EUR conversion. Server state is in memory.
Built with Claude
  • DesignClaude design canvasThe app screens were designed with Claude on a design canvas, one board per product.
  • CodingClaude CodeA Claude Code sub-agent wrote nestly: 41 files, 10 pages, 5 API routes, in about 33 minutes.
  • Parallel agentsClaude Code sub-agentsSix clones were built at the same time, one sub-agent per app.
  • Testing & screenshotsClaude CodeBuild, lint, a running server and 8 screenshots that Claude reviewed and fixed.
  • Scalability & infrastructure analysisClaude Opus 5.5Capacity math, Hetzner server plans, real-time design, security and cost models.
Run it locally
git clone https://github.com/suhasbhairav/airbnb-clone.git && cd airbnb-clone && npm install && npm run dev
Stack
Built with Claude CodeDesigned with ClaudeAnalysed with Claude Opus 5.5Next.js 16 (App Router, Cache Components)React 19Tailwind CSS v4Inter via next/fontNext.js Route Handlers (REST API)useSyncExternalStore + localStorageDependency-free SVG map (equirectangular projection)Production: PostgreSQL 17 + PostGIS + btree_gistProduction: OpenSearch / MeilisearchProduction: Valkey, NATS JetStream, CentrifugoProduction: Stripe Connect / Razorpay RouteProduction: Hetzner Cloud + AX dedicated, k3s, pgBackRest
Palette
Accent coral #FF385CInk #222222Secondary text #717171Header background #FBFBFBDivider #EBEBEBBorder #DDDDDDControl grey #F2F2F2Canvas #FFFFFF

Screenshots of nestly

nestly home page with Homes, Experiences and Services tabs, the pill search bar and carousels of Goa and Coorg stays
Home, matching the Stays design: tabs, four-field search pill and carousels whose prev/next buttons scroll. Hearts save to a persisted wishlist.
Two-month date-range calendar popover open under the search bar with 24 to 27 November selected
The Check in / Check out picker: a real two-month range calendar with past dates disabled, hover preview and Clear dates. The same component blocks booked nights on listing pages.
Search results for Goa with eight homes and a schematic coastline map showing price pins
/s results filtered server-side by /api/listings (dates exclude booked stays), with an SVG map whose price pins are projected from latitude and longitude.
Listing detail page with a five-photo grid, Guest favorite block, sticky booking card with price breakdown and an availability calendar with booked dates struck through
/rooms/[id]: photo grid with lightbox, amenities, reviews, and a booking card computing nights x price + cleaning + service fee + GST. Booked nights can't be selected.
Confirm and pay page with a demo banner, trip details and a card form filled with the 4242 test card
Demo checkout with Luhn, expiry, CVV and PIN validation. Submits with an Idempotency-Key; the API answers 409 if the dates were taken.
Reservation confirmed page showing reservation code, check-in and checkout dates and a payment summary
Confirmation at /trips/[code]?new=1 with the frozen price breakdown. Cancelling frees the dates on the listing calendar.
Become a host wizard on the location step with Goa selected and a map pin labelled Assagao
The nine-step Become a host flow: type, click-to-place map pin, basics, amenities, photo upload with preview, title, price and review. Published listings appear in search.
Mobile layout with a collapsed Start your search pill, icon tabs, peeking carousels and a bottom navigation bar
Mobile: the search collapses to a pill that opens a full-screen Where / When / Who sheet, and a bottom nav replaces the header menu.

What works in the clone

  • Search bar with real pickers

    Where suggestions from destinations and neighbourhoods, a two-month date-range calendar popover, and Who steppers for adults, children, infants and pets. It collapses into a full-screen sheet on mobile. Implemented in components/SearchBar.js and components/Calendar.js.

  • Server-side search API

    GET /api/listings filters by text, guests, availability for the dates, price band, type, bedrooms, amenities and Guest favorite, ranks by a quality score, returns a price histogram and paginates with a cursor.

  • Price-pin map without a map key

    MapView projects lat/lng onto a simplified India coastline in SVG with HTML price pins, fit-to-results, drag to pan, wheel and button zoom, card-to-pin highlighting and pin popups.

  • Filters modal with live counts

    Price histogram with a dual range slider, type chips, bedroom minimum, amenity toggles and a Guest favorite switch. The button shows the API's live result count for the draft filters.

  • Availability calendar with booked nights

    Seeded bookings relative to today appear struck through. Check-in can't land on a booked night, and a range can't cross one, but the first booked night stays valid as checkout.

  • Sticky booking card with exact pricing

    A shared quote() computes nights × price + cleaning fee + 14% service fee + 12% GST on the client for display and on the server for the stored booking.

  • Lightbox gallery

    Five-photo grid and a full-screen gallery addressable at ?photo=N, with keyboard arrows and thumbnails, using history.pushState so no server round trip.

  • Demo checkout with 409 handling

    Card form with Luhn, brand, expiry, CVV and PIN validation, clearly marked as a demo. It posts with an Idempotency-Key, and overlapping dates return 409 with a link to choose new dates.

  • Trips, confirmation and cancellation

    Confirmation at /trips/[code], a trips list grouped by upcoming, past and cancelled, and cancellation via DELETE that frees the nights immediately.

  • Become a host wizard

    Nine steps: type, location with click-to-place map pin, basics, amenities, photo upload with in-browser resize and preview, title, price with earnings and review. POST /api/listings publishes it into search and the map.

  • Currency and language

    A language and region modal with an INR/USD/EUR switch that converts every price through one useMoney() hook backed by a localStorage store.

  • Experiences and services

    Separate seeded cards on their tabs, plus detail pages with time slots whose seat capacity is enforced server-side (409 when full).

What we built: a working stays marketplace clone

nestly is a working clone of a short-stay marketplace: homes, experiences and services across six Indian destinations (Goa, Coorg, Udaipur, Munnar, Rishikesh and Pondicherry). It reproduces the Stays design file: a #FBFBFB header with the coral #FF385C house logo, three category tabs, the pill search bar with four fields and a round accent button, and carousels of square-ish cards (aspect-ratio: 1 / 0.95, 16px radius) with a "Guest favorite" badge, a heart and a "₹18,400 for 2 nights · ★ 4.96" line. Inter is loaded through next/font/google, and the palette uses the design's exact hex values: #222222 text, #717171 secondary, #DDDDDD and #EBEBEB borders.

The goal was not a static mock-up. Every control on the screen does something. The rest of this guide explains how each piece would change to serve millions of travellers and hosts.

What is real in the clone

  • Search runs on the server. GET /api/listings filters by destination text, guest count, availability for the chosen dates, price band, property type, bedrooms, amenities and the "Guest favorite" flag. It ranks results, returns a price histogram for the filter modal and paginates with an opaque cursor.
  • Availability is enforced. Every listing has seeded bookings relative to today, so calendars always show struck-through nights. The calendar component won't let you start a stay on a booked night or stretch a range across one. The booking API re-checks overlap and answers 409 Conflict if someone got there first.
  • Pricing is computed in both places. One quote() function runs in the browser for display and on the server for the stored booking: nights × nightly rate + cleaning fee + 14% guest service fee + 12% GST.
  • Idempotency works. The checkout page creates one Idempotency-Key per attempt, so a double-click or a retry after a timeout returns the original booking instead of creating a second one.
  • Hosts can publish. The nine-step "Become a host" flow resizes uploaded photos in the browser, lets you click a map to place the pin, and POSTs the listing. It appears in search, on the map and on the home page immediately.
  • Currency switching converts every price. INR, USD and EUR conversion is wired through a single useMoney() hook backed by useSyncExternalStore over localStorage.

What is simulated

  • Server state lives in memory, so it resets when the process restarts.
  • Payments are a demo form. The card number is only Luhn-checked, and the page says so in a yellow banner.
  • "Log in" stores a display name in localStorage.
  • The map is a schematic: a simplified coastline polygon in an equirectangular projection, not map tiles. That avoids any API key.

Each simplification maps to a production system covered below: PostgreSQL exclusion constraints, PostGIS/H3 geo search, Stripe Connect or Razorpay Route, an image pipeline and a realtime gateway.

How I built nestly with Claude: design, code and scaling plan

nestly was built with Claude at every step. I designed it with Claude on a design canvas in Claude, coded and tested it with Claude Code, and used Claude Opus 5.5 to work out how it scales to millions of users. Here is the actual workflow, including what I decided and what Claude did.

StageClaude toolWhat it did
DesignClaude design canvasThe app screens were designed with Claude on a design canvas, one board per product.
CodingClaude CodeA Claude Code sub-agent wrote nestly: 41 files, 10 pages, 5 API routes, in about 33 minutes.
Parallel agentsClaude Code sub-agentsSix clones were built at the same time, one sub-agent per app.
Testing & screenshotsClaude CodeBuild, lint, a running server and 8 screenshots that Claude reviewed and fixed.
Scalability & infrastructure analysisClaude Opus 5.5Capacity math, Hetzner server plans, real-time design, security and cost models.

1. Design with Claude

I designed the screens with Claude on a design canvas: six app screens, one board per product, each in the palette of the product it is modelled on. The nestly design uses an original name and original copy, so the clone borrows the product category and layout conventions, not Airbnb's brand.

2. One shared brief, written with Claude Code

Before any app code, Claude Code wrote one brief that all six builds followed. It read the design boards from the Claude canvas, pulled my channel's videos from YouTube, and fetched current prices from Hetzner. The brief covered:

  • Stack: the existing create-next-app scaffold (Next.js 16, React 19, Tailwind CSS v4). Next 16 has breaking changes, so Claude was told to read the docs bundled in node_modules/next/dist/docs/ rather than rely on memory.
  • Fidelity: match the design's layout, hex colours, fonts and spacing; replace grey placeholders with real media; use stroke SVG icons, not emoji.
  • "Fully working": every visible control must do something, backed by Next.js route handlers so the UI talks to an API like a real product.
  • Media: real videos from @suhasbhairav played through privacy-enhanced embeds.
  • Proof: build, run, screenshot every main flow, look at each screenshot, fix what's wrong.

3. Coding with Claude Code sub-agents, in parallel

The main Claude Code session started six sub-agents, one per clone, each owning one folder and one port, so the six apps were built at the same time. Every agent ran on Claude Opus 5.5. The nestly agent worked for about 33 minutes. In that run Claude:

  1. Read the design file and the Next.js 16 docs it needed.
  2. Wrote the app: 41 source files, about 4,545 lines, with 10 page routes and 5 API route handlers (/api/bookings, /api/bookings/[id], /api/experiences, /api/listings, /api/listings/[id]).
  3. Ran npm run build and npm run lint until both passed cleanly.
  4. Started the production server and drove headless Chrome to capture 8 screenshots. For true phone-width shots it wrote a small Chrome DevTools Protocol script, because Chrome's --window-size flag won't lay out narrower than about 500px.
  5. Opened every screenshot, fixed the bugs it saw, and re-shot.

4. Analysing scalability with Claude

The sections that follow, from the data model to the cost breakdown, were drafted with Claude Opus 5.5. That covers capacity math, the Postgres and caching plan, Hetzner server choices per growth stage, the real-time notification design, security and monthly costs. Each one is marked where it starts. Claude worked from Hetzner's own price pages, fetched that day, rather than from memory, and showed its arithmetic so the estimates can be checked.

5. Review: Claude checking Claude

The main Claude Code session reviewed each agent's output instead of trusting it. It looked at the screenshots and rendered every page. It also caught an inconsistency across the six write-ups: some cost tables listed the price of one server where others listed the row total. All tables were normalised to show both. The agents were explicit about which parts are simulated (in-memory server state, fake payments or uploads where relevant) and which paths were not tested, and this page keeps that explicit.

What this means if you want to do the same

  • Spend your time on the brief, not the boilerplate. A precise brief (stack, fidelity bar, definition of "working", proof required) is what made six parallel Claude Code builds come back consistent.
  • Make Claude prove it. "Build passes, here are screenshots I looked at" is a much stronger signal than "done".
  • Keep the architecture decisions yours. Claude wrote the code and the first draft of the scaling plan; deciding what is real, what is simulated and what goes to production is still the engineer's job.
  • Start here: Claude Code overview · Sub-agents · Claude Opus

Page routes in the clone

/ · /book/[id] · /experiences/[id] · /host · /host/new · /rooms/[id] · /s · /trips · /trips/[id] · /wishlists

Screens walkthrough

The screenshots were taken from a production build (next build && next start) with headless Chrome.

Home: Homes tab

nestly home page with Goa and Coorg carousels

The first two sections match the design copy exactly: "Popular homes in Goa" (Villa in Assagao, ₹18,400 for 2 nights, 4.96) and "Available next month in Coorg". The page is a server component that calls the same searchListings() function the API uses. Each section is a CSS grid with grid-auto-flow: column and responsive grid-auto-columns: six cards per view at 1440px, fewer on smaller screens. Prev/next scroll the row by 90% of its width and disable themselves at either end, which a ResizeObserver and the scroll position detect. Card photos have their own mini-carousel with dots. The heart writes to a localStorage store, so the wishlist survives reloads.

Date-range picker

Two-month calendar popover with a three-night range selected

Clicking Check in raises that field, greys the rest of the pill and opens a two-month calendar. Past days are disabled, and a hover preview shades the range while you choose checkout. You can clear or move on to Who, where steppers for adults, children, infants and pets keep at least one adult whenever anyone else is added. The same Calendar component runs on the listing page with a blocked set of booked nights. ?search=checkin opens this popover straight from a URL, so the state can be screenshotted and linked.

Search results with map

Results for Goa with a price-pin map of the coastline

/s?where=Goa&checkin=…&checkout=…&guests=2 is a client component that calls /api/listings. Card prices switch to "for 3 nights" when dates are set. Listings booked on those dates are excluded server-side. The right panel is MapView: it fits the results' bounding box, projects lat/lng into SVG coordinates, and positions HTML price pins over a simplified India coastline. You can drag to pan, zoom with the wheel or the buttons, and "Re-centre on results" resets the view. Hovering a card highlights its pin, and clicking a pin opens a mini card. The filters modal (?filters=1) shows a price histogram computed by the server, a dual range slider, type chips, a bedroom minimum and amenity toggles. Its "Show N places" button asks the API for a live count as you change filters.

Listing detail

Listing page with photo grid, booking card and availability calendar

/rooms/assagao-villa shows the five-photo grid (click any photo or "Show all photos" for a full-screen lightbox at ?photo=N), the Guest favorite block, host, highlights, description and amenities. The sticky booking card computes ₹9,200 × 3 nights + ₹1,100 cleaning + ₹3,864 service fee + ₹3,444 GST = ₹36,008. The availability calendar below shows booked nights struck through. A check-in can't be placed on them, and once a check-in is chosen, every date after the next booked night is disabled. That booked night itself stays selectable as checkout, because the guest leaves that morning. Dates are written into the URL with history.replaceState, so the selection is shareable.

Confirm and pay

Checkout page with demo card form filled with a test card

The checkout page is clearly labelled as a demo, and "Fill test card" fills 4242 4242 4242 4242. Validation covers the Luhn checksum, card brand (Visa, Mastercard, RuPay, Amex), an MM/YY expiry that must be in the future, a 3–4 digit CVV and a 6-digit Indian PIN code. Dates and guests can be edited in modals; the dates modal reuses the blocked calendar. Submitting sends POST /api/bookings with an Idempotency-Key header. If the nights were taken in the meantime, the API returns 409 and the page shows "Dates unavailable" with a link back to choose new dates.

Confirmation and trips

Reservation confirmed page with reservation code and payment summary

On success the booking goes into the local trips store, and the browser lands on /trips/HM…?new=1. The server copy is the source of truth for status, with the local copy as a fallback if the demo server restarted. "Cancel reservation" calls DELETE /api/bookings/:id, which frees those nights on the listing calendar immediately.

Become a host

Host onboarding step with destination chips and a click-to-place map pin

/host/new is a nine-step wizard with a segmented progress bar: type of place, location (click the map to move the pin), basics, amenities, photos (drag-and-drop, resized to ≤1280px JPEG on a canvas, with cover-photo and remove controls), title and description, price (guest price and host earnings shown live), and review. The draft persists to localStorage. Publishing redirects to the new /rooms/host-… page.

Mobile

Mobile home with collapsed search pill and bottom navigation

Below the md breakpoint, the four-field search collapses into a "Start your search" pill that opens a full-screen sheet with Where / When / Who accordion cards. The tabs become icon tabs, carousels show 1.6 cards so the next one peeks in, and a bottom nav (Explore, Wishlists, Trips, Host, Log in) replaces the header menu. On listing pages, a fixed bar with the price and a Reserve button sits at the bottom.

Frontend architecture (Next.js 16 App Router)

The clone runs on Next.js 16 (App Router) with React 19.3 and Tailwind CSS v4, written in JavaScript. cacheComponents is enabled, and that drives most of the routing decisions. Under Cache Components, anything that reads request data (params, searchParams, useSearchParams(), usePathname() or the current time) must sit inside a <Suspense> boundary. Next can then prerender a static shell and stream the dynamic part. Every page here follows one of two shapes:

  1. Server page with an async inner component. app/page.js, rooms/[id], book/[id] and experiences/[id] export a synchronous page that renders <Suspense fallback={<Skeleton/>}><Inner params={params}/></Suspense>. The inner component awaits params, calls await connection() (date-dependent output must not be prerendered at build time), reads the store and passes plain data to a client component.
  2. Client page behind Suspense. /s, /trips, /wishlists and /host/new render a client component that uses useSearchParams() and fetches route handlers. That is the "frontend talks to an API" path.

Routes

RouteRenderingPurpose
/ and /?tab=experiences|servicesPartial prerender + streamed server componentCarousels from lib/db
/sClient + /api/listingsResults, filters, map, cursor pagination
/rooms/[id]Streamed server → RoomClientDetail, calendar, booking card, lightbox
/book/[id]Streamed server → BookClientConfirm and pay
/trips, /trips/[id]ClientTrips list, confirmation, cancellation
/wishlistsClientSaved stays and experiences
/experiences/[id]Streamed server → clientSlot booking with capacity
/host, /host/newClientYour listings, onboarding wizard

Real file structure

app/
  layout.js            Inter font, <MobileNav/> in Suspense, <Toast/>
  page.js              home: Homes / Experiences / Services
  s/page.js, s/SearchResults.js
  rooms/[id]/page.js, RoomClient.js
  book/[id]/page.js, BookClient.js
  trips/…, wishlists/…, experiences/[id]/…, host/…
  api/listings/route.js, api/listings/[id]/route.js
  api/bookings/route.js, api/bookings/[id]/route.js
  api/experiences/route.js
components/
  Header.js            full + compact variants, language/currency modal, menu, login
  SearchBar.js         desktop pill + popovers; MobileSearch sheet; searchHref()
  Calendar.js          two-month range picker with blocked nights
  MapView.js           SVG coastline + HTML pins, pan/zoom/fit/click-to-place
  ListingCard.js       card, HeartButton, CarouselSection
  ExperienceCard.js, Shell.js (bottom nav, footer, toast), ui.js (Modal, Stepper, GuestPicker), Icons.js
data/listings.js       36 stays, 12 experiences, 10 services, destinations
lib/db.js              in-memory store, search + ranking, availability, bookings
lib/dates.js           YYYY-MM-DD keys, half-open ranges, month grids
lib/pricing.js         quote(), experienceQuote(), currencies
lib/client-store.js    localStorage stores via useSyncExternalStore, useJson(), toast bus

State management

There is no global state library. Client state falls into three kinds:

  • Persisted user preferences: wishlist, currency, language, profile, trips and your listings. Each is a tiny store (createStore(key, initial)) read with useSyncExternalStore. The server snapshot is the initial value, so SSR output never depends on localStorage, which rules out hydration mismatches. Every localStorage call sits in try/catch and falls back to memory in private mode or when the quota is full. A storage event listener syncs tabs.
  • URL state: search criteria, filters, sort, tab, ?photo= for the lightbox, ?step= for the host wizard, and selected dates on the listing page. Navigations that need fresh server data use router.push. Cosmetic URL updates use history.replaceState and pushState, which Next 16 integrates with useSearchParams without a server round trip.
  • Local UI state: popovers, hover and so on.

Data fetching goes through useJson(url). It derives loading by comparing the URL a response was fetched for with the current URL, which avoids synchronous setState in effects (the React Compiler lint rules flag that) and keeps showing stale data while new data loads, so the results grid fades instead of flashing empty.

Production frontend notes

In production, keep the home and listing pages server-rendered and edge-cached. Cache listing content with use cache plus cacheTag('listing:'+id) and call updateTag when a host edits. Availability and price are per-date and per-user, so the booking card fetches them client-side from a short-TTL endpoint. Serve images through a CDN in responsive AVIF/WebP sizes. The map should become MapLibre GL with self-hosted vector tiles (Protomaps PMTiles on object storage) to avoid per-load map fees.

Production data model: listings, calendars, bookings, money

A stays marketplace looks simple until you model time. Listings are fairly static. Availability, prices and bookings are per-night facts that change all day, and they are where correctness matters. The schema below is what the clone's in-memory store stands in for. It targets PostgreSQL 17 with PostGIS and btree_gist.

CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE TABLE users (
  id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email         citext UNIQUE NOT NULL,
  phone_e164    text UNIQUE,
  display_name  text NOT NULL,
  kyc_status    text NOT NULL DEFAULT 'none',   -- hosts need KYC for payouts
  created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE listings (
  id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  host_id        bigint NOT NULL REFERENCES users(id),
  status         text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','listed','snoozed','delisted')),
  property_type  text NOT NULL,
  title          text NOT NULL,
  description    text NOT NULL,
  geo            geography(Point, 4326) NOT NULL,
  h3_r7          bigint NOT NULL,                -- H3 cell for sharding / coarse search
  city           text NOT NULL,
  region         text NOT NULL,
  country        char(2) NOT NULL DEFAULT 'IN',
  timezone       text NOT NULL DEFAULT 'Asia/Kolkata',
  max_guests     smallint NOT NULL CHECK (max_guests BETWEEN 1 AND 50),
  bedrooms       smallint NOT NULL,
  beds           smallint NOT NULL,
  baths          numeric(3,1) NOT NULL,
  base_price     integer NOT NULL,               -- whole rupees per night; bookings store paise
  currency       char(3) NOT NULL DEFAULT 'INR',
  cleaning_fee   integer NOT NULL DEFAULT 0,
  min_nights     smallint NOT NULL DEFAULT 1,
  instant_book   boolean NOT NULL DEFAULT false,
  rating_avg     numeric(3,2),
  review_count   integer NOT NULL DEFAULT 0,
  updated_at     timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX listings_geo_gix ON listings USING gist (geo) WHERE status = 'listed';
CREATE INDEX listings_h3_idx ON listings (h3_r7) WHERE status = 'listed';

CREATE TABLE listing_amenities (
  listing_id bigint REFERENCES listings(id) ON DELETE CASCADE,
  amenity    smallint NOT NULL,                  -- FK to an amenity catalogue
  PRIMARY KEY (listing_id, amenity)
);

CREATE TABLE listing_photos (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  listing_id  bigint NOT NULL REFERENCES listings(id) ON DELETE CASCADE,
  position    smallint NOT NULL,
  object_key  text NOT NULL,                     -- originals/…; variants derived
  width int, height int, blurhash text,
  status      text NOT NULL DEFAULT 'processing' -- processing | ready | rejected
);

-- One row per listing-night that differs from the default (price overrides, blocks).
CREATE TABLE calendar_days (
  listing_id  bigint NOT NULL REFERENCES listings(id) ON DELETE CASCADE,
  night       date   NOT NULL,
  price       integer,                           -- NULL = base price
  min_nights  smallint,
  blocked     boolean NOT NULL DEFAULT false,    -- host block or iCal import
  source      text NOT NULL DEFAULT 'host',      -- host | ical:<feed id> | smart_pricing
  PRIMARY KEY (listing_id, night)
) PARTITION BY HASH (listing_id);
-- CREATE TABLE calendar_days_p0 PARTITION OF calendar_days FOR VALUES WITH (MODULUS 32, REMAINDER 0); …

CREATE TABLE bookings (
  id               bigint GENERATED ALWAYS AS IDENTITY,
  code             text NOT NULL,                -- HMxxxx shown to users
  listing_id       bigint NOT NULL REFERENCES listings(id),
  guest_id         bigint NOT NULL REFERENCES users(id),
  stay             daterange NOT NULL CHECK (NOT isempty(stay) AND lower_inc(stay) AND NOT upper_inc(stay)),
  guests           smallint NOT NULL,
  status           text NOT NULL CHECK (status IN ('pending_payment','confirmed','cancelled','completed')),
  total_minor      bigint NOT NULL,              -- paise
  currency         char(3) NOT NULL,
  quote            jsonb NOT NULL,               -- frozen breakdown: nightly, cleaning, fees, taxes
  payment_intent   text,
  created_at       timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (id),
  -- The double-booking guarantee: no two live bookings on one listing may overlap.
  CONSTRAINT no_overlap EXCLUDE USING gist (
    listing_id WITH =, stay WITH &&
  ) WHERE (status IN ('pending_payment','confirmed'))
);
CREATE UNIQUE INDEX bookings_code_uq ON bookings (code);
CREATE INDEX bookings_guest_idx ON bookings (guest_id, created_at DESC);

CREATE TABLE idempotency_keys (
  key          text PRIMARY KEY,
  user_id      bigint NOT NULL,
  request_hash bytea NOT NULL,
  response     jsonb,
  status_code  smallint,
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE reviews (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  booking_id  bigint UNIQUE NOT NULL,            -- one review per stay, per side
  listing_id  bigint NOT NULL,
  author_id   bigint NOT NULL,
  direction   text NOT NULL CHECK (direction IN ('guest_to_host','host_to_guest')),
  ratings     jsonb NOT NULL,                    -- cleanliness, accuracy, check-in …
  body        text,
  published_at timestamptz                       -- revealed when both submit or after 14 days
);

CREATE TABLE ledger_entries (                    -- double-entry money movements
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  booking_id  bigint,
  account     text NOT NULL,                     -- guest_receivable | platform_fees | host_payable:<id> | tax_gst
  amount_minor bigint NOT NULL,                  -- positive debit / negative credit
  currency    char(3) NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);

Design notes

  • Half-open daterange. [checkin, checkout) means the checkout day is free for the next guest. The clone uses the same convention in lib/dates.js (a < d && c < b for overlap). The CHECK constraint forces the canonical form.
  • The exclusion constraint is the source of truth. Application checks are only for UX. Two concurrent transactions inserting overlapping stays cannot both commit. One gets 23P01 exclusion_violation, which the API maps to HTTP 409. pending_payment rows count as live, so a guest who is mid-checkout holds the dates. A sweeper cancels holds older than 15 minutes.
  • Sparse calendar. Storing all 365 nights × 2M listings would be 730M rows of defaults. Only overrides are stored. Search reads a derived availability bitmap (see Domain deep dive).
  • Money is integer paise. The quote JSON is frozen at booking time, so later fee changes never rewrite history. A double-entry ledger makes payouts and refunds auditable.
  • Sharding key is listing_id. Bookings, calendar days, photos and reviews all hang off a listing, so hash-partitioning by listing_id keeps every transaction that matters on one shard, including the exclusion constraint, which only works within a single table/shard. Guest-centric queries ("my trips") use a bookings_by_guest projection, maintained from the outbox, on its own partition key.

API design: search, quotes, idempotent bookings

The clone exposes REST route handlers. Production keeps REST for the public and mobile API. Most screens are one read each, and REST caches well at the CDN, which matters more here than GraphQL's flexibility.

MethodEndpointDescriptionCache
GET/v1/search/stays?bbox=&q=&checkin=&checkout=&guests=&price_min=&price_max=&types=&bedrooms=&amenities=&sort=&cursor=Ranked results + facets + histogram60 s at CDN when dates absent; 15 s per query hash otherwise
GET/v1/listings/{id}Listing content, photos, host summarys-maxage=300, purged on edit
GET/v1/listings/{id}/calendar?from=&to=Per-night availability and priceprivate, max-age=30
POST/v1/listings/{id}/quotePrice quote for dates/guests (signed, 15-min TTL)none
POST/v1/bookingsCreate booking from a quote; requires Idempotency-Keynone
GET/v1/bookings/{code}Booking detail (guest or host only)private
POST/v1/bookings/{code}/cancelCancel with policy-computed refundnone
GET/v1/me/trips?cursor=Guest tripsprivate
POST/v1/listings / PATCH /v1/listings/{id}Host create/editnone
POST/v1/uploadsPresigned PUT URL for a photonone
GET/POST/v1/threads/{id}/messagesHost–guest messagingprivate
POST/v1/webhooks/{psp}Payment provider webhooks (signature verified)none

Cursor pagination

Search results are ranked by a score, not by an id, so offset pagination drifts as prices and availability change between pages. The production cursor is an opaque, signed token holding (score, listing_id) of the last item plus a hash of the query. The next page asks for WHERE (score, id) < (:s, :id), or uses OpenSearch search_after. The clone base64-encodes an offset, which is fine for an in-memory list and is labelled as such in lib/db.js. Trips and messages paginate by (created_at, id).

Quotes and idempotency

Checkout is a two-step contract:

  1. POST /quote returns a breakdown and a quote_id signed with an HMAC over listing, dates, guests, totals and expiry. The UI renders exactly what the server will charge.
  2. POST /bookings takes quote_id plus an Idempotency-Key header (a UUID generated once per checkout page, as BookClient.js does).

The server inserts the key into idempotency_keys in the same transaction as the booking. A retry with the same key and body returns the stored response. The same key with a different body is rejected with 422, and a request still in flight gets 409. Keys expire after 24 hours. The clone implements the replay path in createBooking(input, idempotencyKey).

Status codes that matter

  • 409 Conflict: dates overlap a live booking (exclusion violation), or an experience slot is full. The body includes "reason": "dates_unavailable" so the client can refresh the calendar.
  • 402 Payment Required: the card or authorisation failed. The booking row is rolled back so the dates aren't held.
  • 422: validation, e.g. guests above max_guests, nights below the minimum, or an expired quote.
  • 429: rate limit, with Retry-After.

Rate limits

These are token buckets in Valkey, keyed by user id or by IP for anonymous traffic:

  • Search: 30 req per 10 s per IP. Bots that scrape prices are the main abuse.
  • Quote: 20 per minute per user.
  • Booking create: 5 per minute per user and 3 per listing per user per hour.
  • Messages: 30 per minute, plus a spam classifier on first contact. Guests sharing phone numbers before booking is a known fraud vector, so messages are scanned and masked.

Webhooks are exempt but signature-verified and idempotent by event id.

Scaling to millions of guests and hosts

Capacity model and architecture drafted with Claude Opus 5.5 inside Claude Code. All figures are estimates with the arithmetic shown.

Back-of-envelope capacity (estimates, arithmetic shown)

Assume the Scale stage: 5M monthly active users, 2M active listings, and 15% of MAU active on a given day.

  • DAU = 5,000,000 × 0.15 = 750,000.
  • Reads. A browsing session averages ~120 API calls: searches, map pans, listing views, calendar and quote calls. 750,000 × 120 = 90M requests/day ÷ 86,400 s ≈ 1,040 req/s average. Travel traffic is evening-heavy, so a 4× peak factor gives ~4,200 req/s at peak.
  • Search queries. 12 per DAU → 9M/day ≈ 104/s average, ~420/s peak. These are the expensive reads: geo + date + facets + ranking.
  • Listing page views. 25 per DAU → 18.75M/day ≈ 217/s average.
  • Bookings. At a 0.4% daily booking rate on DAU, 750,000 × 0.004 = 3,000 bookings/day, about 0.035/s on average and maybe 2/s in a flash-sale spike. Writes are tiny in volume but must be strictly correct.
  • Calendar writes. Hosts edit prices and blocks, and iCal sync imports external calendars. If 40% of listings sync every 30 minutes, that is 800,000 × 48 = 38.4M sync jobs/day ≈ 444/s background. Most are no-ops after diffing, so only changed nights are written.
  • Messages. ~10 per booking plus pre-booking enquiries ≈ 60,000/day: trivial for storage, but latency-sensitive.
  • Photo storage. 2M listings × 20 photos × 3 MB originals = 120 TB of originals. Variants (4 sizes, AVIF + WebP, ~150 KB average per variant) add 2M × 20 × 8 × 0.15 MB ≈ 48 TB. That is why originals belong in object storage with lifecycle rules.
  • Image egress. 18.75M listing views × ~1.5 MB of images (grid + first lightbox photos) ≈ 28 TB/day at the edge. With a 95% CDN hit ratio, origin egress is ~1.4 TB/day ≈ 42 TB/month.
  • Database size. About 700 GB hot (listings, bookings, calendar overrides, reviews, messages) after a few years, plus indexes. That fits on one big NVMe primary, which is why the Scale stage doesn't shard yet.

Architecture (request path, top to bottom)

  • DNS + CDN/WAF (Cloudflare or Bunny): caches images, static assets and anonymous search/listing JSON; bot management for price scrapers.
  • Hetzner Load Balancer → stateless Next.js / API pods on k3s. The SSR web app and a separate API service share no in-process state, and sessions live in signed cookies plus Valkey.
  • Search service → OpenSearch (geo_point, facets, BM25 on titles) or Meilisearch at smaller scale, fed by CDC from Postgres. An availability index (bitsets per listing-month) lives in Valkey or a sidecar.
  • Booking service: the only writer to bookings, talking to the PostgreSQL primary via PgBouncer (transaction pooling). Reads go to streaming replicas.
  • Pricing service: computes quotes from base price, overrides, length-of-stay discounts, fees and GST; stateless and cached per listing-date-range.
  • Outbox → NATS JetStream (or Kafka at hyperscale): booking.confirmed, listing.updated and calendar.changed events fan out to search indexing, notifications, analytics, the payout scheduler and iCal export.
  • Workers: image pipeline, iCal import, payout batches, review reveal, email/SMS/push.
  • Realtime gateway (Centrifugo) for messaging and host booking alerts.
  • Object Storage (S3-compatible) for photos and backups.

Stateless app tier

The web tier holds no user data in memory. That rules out exactly what the demo does with globalThis.__nestlyDb, which is acceptable only for a demo. Pods scale on CPU and request latency. A rolling deploy never drops a booking, because the booking call is idempotent and the client retries on 502/503 with the same key.

Caching with Valkey

  • Listing documents (rendered JSON) keyed by listing:{id}:v{version}, invalidated by the listing.updated event.
  • Availability bitsets: avail:{listing}:{yyyymm} as a 31-bit integer. A search for 12–15 March checks bits 11–13 in one BITFIELD call, for thousands of candidates per pipeline round trip.
  • Quotes, signed and cached for 15 minutes.
  • Rate-limit buckets and session data.
  • Search result pages for popular anonymous queries ("Goa, no dates"): cached for 60 s.

Never cache the decision to book. Every booking goes to the primary.

PostgreSQL: primary, replicas, PgBouncer, partitioning

Start with one primary and one synchronous-ish replica (Patroni, synchronous_commit = remote_write for bookings). Put PgBouncer in transaction mode in front, so 400 app connections become 60 server connections. Send listing reads, trips and reviews to async replicas, checking replication lag (pg_last_xact_replay_timestamp) and falling back to the primary when lag exceeds 2 s. Hash-partition calendar_days by listing_id. Range-partition messages and ledger_entries by month, and detach old partitions to cold storage. When one primary is no longer enough (roughly beyond tens of thousands of write TPS, or when the working set outgrows RAM), shard by listing_id with Citus, or by H3 region with application routing. Booking, calendar and listing rows for a listing always live together.

Queues

JetStream gives durable streams with consumer acknowledgements and is operationally light. Kafka earns its keep at hyperscale for analytics fan-out and long retention. Use the transactional outbox: the booking transaction also inserts an outbox row, and a relay publishes it. You never get a confirmed booking with no notification, or a notification for a rolled-back booking.

Multi-region strategy

Booking correctness needs a single writer per listing. Run the primary region in Germany (Falkenstein/Nuremberg) with Helsinki as the DR / replica region. Add read-only edge stacks in Ashburn and Singapore, holding app pods plus Postgres replicas and a search replica, for latency to US and Asian guests. Writes (bookings, edits) are forwarded to the primary. That costs 100–200 ms on a booking, which users accept, while search and browsing stay local. Indian travellers are the core audience, so Singapore is the closest Hetzner region. Pair it with a CDN that has Indian PoPs (Mumbai, Chennai, Delhi). Shard primaries by geography only when one region can't hold the write load, and then keep each listing's shard in one region.

Deploying on Hetzner, stage by stage

Server plan drafted with Claude from Hetzner's own price pages, fetched by Claude Code on 11 November 2026. Verify prices on hetzner.com before ordering.

Hetzner is a good fit for a marketplace at this stage. Compute and bandwidth are cheap, dedicated AX boxes give predictable NVMe performance for Postgres and OpenSearch, and DDoS protection is included on all products. Be honest about the limits:

  • Regions: Falkenstein and Nuremberg (DE), Helsinki (FI), Ashburn VA and Hillsboro OR (US), and Singapore. There is no India region.
  • No managed PostgreSQL. You run it yourself (Patroni + pgBackRest) or buy it from a provider elsewhere.
  • CDN: pair Hetzner with Cloudflare or Bunny for global latency and image delivery.
  • Shared plans: some are marked "currently not available" in some locations, so check availability before planning around a size.

Prices below are EUR/month, excl. VAT, as listed on hetzner.com in Nov 2026. Verify before ordering.

Launch (0–50K MAU): Docker Compose + Ansible

  • 2 × CPX32 (4 vCPU / 8 GB / 160 GB, €35.99 in NBG1) for Next.js + API behind an LB11 (from ~€6/month, check console), with Caddy or Traefik inside.
  • 1 × CCX23 (4 dedicated vCPU / 16 GB, €86.49) for PostgreSQL 17 + PostGIS, on a Volume for WAL archive staging.
  • 1 × CPX32 for Valkey, Meilisearch and background workers (image resize, emails).
  • Object Storage (€6.49 base incl. 1 TB storage + 1 TB egress) for photos and pgBackRest backups.
  • Cloud Network (private 10.0.0.0/16) between all servers. A Cloud Firewall allows only 443 to the LB and SSH from a bastion or Tailscale.

Ansible playbooks provision the boxes, Docker Compose runs the services, and GitHub Actions builds images and rolls them out one app node at a time behind the LB health check.

Growth (~500K MAU): k3s on cloud servers

  • Move to k3s: 3 server nodes with embedded etcd, plus 4 × CCX23 agents for app/API pods with HPA, and 2 × CPX32 for the realtime gateway.
  • Postgres moves to dedicated hardware: 2 × AX102 (Ryzen 9 7950X3D, 128 GB ECC, 2 × 1.92 TB NVMe DC, from €159) as Patroni primary + replica, with etcd on the k3s control plane. Dedicated servers join the cloud network through vSwitch, so app pods reach Postgres over a private link.
  • 2 × CCX13 for Valkey (primary + replica with Sentinel), 2 × CCX33 for OpenSearch or Meilisearch, and 2 × CPX42 for workers.
  • pgBackRest pushes full backups weekly, differentials daily and WAL continuously to Object Storage, with a retention of 4 full backups. Restore drills run monthly into a scratch server.

Scale (~5M MAU): mostly dedicated AX

At this point cloud vCPUs cost more than dedicated cores for steady load. Move the baseline onto AX servers and keep cloud instances for burst capacity and control-plane roles:

  • 8 × AX42 (8-core Ryzen 7 PRO, 64 GB DDR5 ECC, from €79) as k3s agents for web/API.
  • 3 × AX42 for the realtime gateway and NATS, and 3 × AX42 for the Valkey cluster.
  • 3 × AX162 (EPYC 48c, 256 GB+) for Postgres: primary + synchronous replica + async replica, with PgBouncer on each.
  • 3 × AX102 for OpenSearch data nodes.
  • 4 × AX42 for workers (image pipeline, iCal sync, payouts), and 1 × AX102 for Prometheus/Loki/Tempo storage.
  • A 10 Gbit uplink add-on for the database and search racks if replication traffic warrants it.

Setup fees apply: €49 per AX42, €129 per AX102, €304 per AX162.

Hyperscale (10M+ MAU): two EU regions + edges

  • Falkenstein/Nuremberg: primary write region with sharded Postgres (Citus or app-level shards by listing_id) on AX162s.
  • Helsinki: a full warm standby that serves reads and is promoted on regional failure.
  • Ashburn and Singapore: read stacks with app pods, Postgres replicas and search replicas. US cloud plans have lower included traffic (1–5 TB), so put all image egress behind the CDN.
  • Kafka for analytics and a lakehouse (ClickHouse on AX102) for pricing and ranking models.

Networking and security on Hetzner

  • Cloud Networks / vSwitch: private east-west traffic, with Postgres, Valkey and NATS never exposed publicly.
  • Load Balancers: TLS terminated at Cloudflare and re-encrypted to the LB (or Cloudflare Tunnel). Health checks hit /healthz, which checks DB reachability.
  • Firewalls: Hetzner Cloud Firewalls on cloud nodes, nftables via Ansible on AX boxes.
  • Volumes: for stateful cloud workloads (Valkey AOF, Prometheus) at the early stages.
  • Backups: pgBackRest to Object Storage in a different location (e.g. a Helsinki bucket for a Nuremberg primary). The Object Storage bucket for photos is versioned, with lifecycle rules that move originals older than 90 days to the cheapest tier you have.

When to move from cloud to dedicated

Move a role to AX when it has steady utilisation above ~40% around the clock, needs local NVMe IOPS (Postgres, OpenSearch, Kafka), or pushes lots of traffic: AX traffic is unmetered in DE/FI on a 1 Gbit/s port. Keep cloud for anything that scales up and down daily, for control planes, and for anything you want to rebuild in minutes from an image.

Hetzner server plan by growth stage

Prices in EUR per month, excl. VAT, as listed on hetzner.com in November 2026. Verify current pricing and availability before ordering.

Launch· 0–50K MAU

≈ €206.95 / month

Docker Compose + Ansible on Hetzner Cloud (NBG1 prices). One Postgres on dedicated vCPUs, Meilisearch and Valkey on a shared box, photos and pgBackRest in Object Storage, Cloudflare/Bunny CDN in front. LB price is approximate: from ~€6/month, check console.

RoleHetznerSpecCountUnit € / moTotal € / mo
App + API (Next.js)CPX324 vCPU / 8 GB / 160 GB NVMe2€35.99€71.98
PostgreSQL 17 + PostGISCCX234 dedicated vCPU / 16 GB / 160 GB1€86.49€86.49
Valkey + Meilisearch + workersCPX324 vCPU / 8 GB / 160 GB1€35.99€35.99
Load BalancerLB11from ~€6/month, check console1€6.00€6.00
Object Storage (photos, backups)Object Storage€6.49 base incl. 1 TB storage + 1 TB egress1€6.49€6.49

Growth· ~500K MAU

≈ €1,330.76 / month

k3s on cloud nodes; Postgres moves to two dedicated AX102 (Patroni primary + replica) joined via vSwitch. Object Storage: base + ~4 TB extra storage (~€25.40) + ~10 TB extra egress (~€10). Setup fees for AX102 (€129 each) not included in the monthly figure.

RoleHetznerSpecCountUnit € / moTotal € / mo
k3s agents: web + APICCX234 dedicated vCPU / 16 GB4€86.49€345.96
Realtime gateway (Centrifugo) + NATSCPX324 vCPU / 8 GB2€35.99€71.98
PostgreSQL primary + replicaAX102Ryzen 9 7950X3D 16c / 128 GB ECC / 2×1.92 TB NVMe2€159.00€318.00
Valkey primary + replicaCCX132 dedicated vCPU / 8 GB2€43.49€86.98
Search (OpenSearch/Meilisearch)CCX338 dedicated vCPU / 32 GB2€138.99€277.98
Workers: images, iCal, payouts, emailCPX428 vCPU / 16 GB2€69.99€139.98
Monitoring (Prometheus, Loki, Grafana)CPX324 vCPU / 8 GB1€35.99€35.99
Load BalancersLB11from ~€6/month each, check console2€6.00€12.00
Object StorageObject Storagebase + ~4 TB storage + ~10 TB egress (estimate)1€41.89€41.89

Scale· ~5M MAU

≈ €3,313.49 / month

Baseline on dedicated AX servers (unmetered traffic in DE/FI), cloud kept for control plane and burst. Object Storage estimate: base + ~40 TB extra storage (~€254) + ~20 TB extra origin egress (~€20); CDN egress billed separately by the CDN. One-off setup fees (€49 AX42, €129 AX102, €304 AX162) excluded.

RoleHetznerSpecCountUnit € / moTotal € / mo
k3s agents: web + APIAX42Ryzen 7 PRO 8700GE 8c / 64 GB DDR5 ECC / 2×512 GB NVMe8€79.00€632.00
Realtime gateway + NATS JetStreamAX428c / 64 GB3€79.00€237.00
PostgreSQL primary + sync replica + async replicaAX162EPYC 9454P 48c / 256 GB ECC / 2×1.92 TB NVMe DC3€319.00€957.00
Valkey clusterAX428c / 64 GB3€79.00€237.00
OpenSearch data nodesAX10216c / 128 GB / 2×1.92 TB NVMe DC3€159.00€477.00
Workers: image pipeline, iCal sync, payoutsAX428c / 64 GB4€79.00€316.00
Observability storageAX10216c / 128 GB1€159.00€159.00
Load BalancersLB11from ~€6/month each, check console3€6.00€18.00
Object StorageObject Storagebase + ~40 TB storage + ~20 TB egress (estimate)1€280.49€280.49

Hyperscale· 10M+ MAU

≈ €10,442.91 / month

Primary write region Falkenstein/Nuremberg with sharded Postgres, Helsinki warm standby, read stacks in Ashburn (US cloud plans, lower included traffic) and Singapore. Kafka + ClickHouse for analytics and ranking. Object Storage estimate: base + ~150 TB storage (~€952.50) + ~50 TB egress (~€50). Setup fees and CDN excluded.

RoleHetznerSpecCountUnit € / moTotal € / mo
k3s agents: web + API (EU)AX428c / 64 GB20€79.00€1,580.00
Realtime gatewaysAX428c / 64 GB6€79.00€474.00
PostgreSQL shards (4 shards × primary + replica)AX16248c / 256 GB+ / NVMe DC8€319.00€2,552.00
Valkey clusters (EU primary + HEL)AX10216c / 128 GB6€159.00€954.00
OpenSearch (EU + HEL replicas)AX10216c / 128 GB8€159.00€1,272.00
WorkersAX428c / 64 GB10€79.00€790.00
Kafka + ClickHouse analyticsAX10216c / 128 GB5€159.00€795.00
ObservabilityAX10216c / 128 GB3€159.00€477.00
US read edge (Ashburn): app + cacheCPX31 (US)4 vCPU / 8 GB / 160 GB, 1–5 TB traffic8€62.99€503.92
Load BalancersLB11from ~€6/month each, check console6€6.00€36.00
Object StorageObject Storagebase + ~150 TB storage + ~50 TB egress (estimate)1€1,008.99€1,008.99

Realtime: messaging, booking alerts, calendar sync and price alerts

Real-time design and code drafted with Claude Opus 5.5 in Claude Code.

A stays marketplace has less realtime traffic than a social app, but what it has is high-stakes. A host who misses an instant-booking alert annoys a guest. A price-drop alert that arrives a day late is useless. A double-sync from an external calendar can strand a family at the door.

Realtime use cases

  1. Host–guest messaging. Messages, typing indicators, read receipts and attachments such as check-in instructions or photos of the gate.
  2. Instant booking notifications. Hosts get "New reservation: 3 nights, ₹36,008" within seconds on web, mobile push and optionally SMS/WhatsApp. Guests get confirmation, payment capture and check-in reminders.
  3. Live availability. If someone books the dates you are viewing, the booking card should grey them out before you hit Reserve, rather than you discovering a 409 at checkout.
  4. Calendar sync (iCal). Import other platforms' .ics feeds every 15–30 minutes and export a per-listing feed URL. Not realtime in the socket sense, but it feeds the same calendar.changed event stream.
  5. Price alerts. A wishlisted listing drops its price, or dates open up on a sold-out listing.

Transport choice

  • WebSockets for messaging (bidirectional, typing indicators).
  • SSE is a fine alternative for one-way streams such as booking status or live availability on a listing page. It works through most proxies and supports Last-Event-ID natively.
  • WebTransport isn't needed. The payloads are small and head-of-line blocking isn't the bottleneck.

Use one gateway that speaks both: Centrifugo handles WebSocket, SSE and HTTP-streaming with channel history and recovery. That avoids writing connection-state code. Behind the Hetzner LB, run 2–3 gateway pods with NATS (or Valkey) as the broker, so a publish from any backend pod reaches whichever gateway holds the user's connection.

Channels and fanout

  • user:{id}: personal channel for notifications and new-message pings, authorised by a JWT with sub = user id, issued by the API with a 10-minute TTL.
  • thread:{id}: messaging thread. Only the two participants (and support staff) may subscribe.
  • listing:{id}:availability: public, carrying only "nights changed", never who booked.

Fanout is on write and tiny: a booking event publishes to at most three channels (host user, guest user, listing availability). Price alerts are the exception. A popular listing might be wishlisted by 50,000 people, so alerts go through a worker that queries the wishlist index, batches by user and sends digests through Web Push/email rather than live sockets.

Delivery guarantees

The socket is best-effort. The database is the source of truth. Every message is written to Postgres first (messages partitioned by month), then published with a monotonically increasing seq per thread. Clients track the last seq they saw. On reconnect, Centrifugo's history recovery (or a GET /threads/{id}/messages?after_seq=) fills any gap, so delivery is at-least-once and clients dedupe by message id. Booking notifications use the outbox, so they are never lost. Push providers may still drop them, so the app badge is computed from an unread counter in the database, not from push receipts.

Offline delivery

If the user has no live connection, a notification worker sends:

  • Web Push (VAPID keys; web-push library) for browsers.
  • APNs (token-based auth, .p8 key) for iOS, and FCM HTTP v1 for Android.
  • Email for anything with legal weight, such as booking confirmations and receipts with GST details.
  • SMS/WhatsApp for time-critical host alerts in India via an approved provider, respecting DLT template registration rules.

Respect quiet hours in the recipient's time zone. Booking alerts bypass quiet hours only if the host opted in.

Backpressure and reconnection

Centrifugo caps per-connection queue size and disconnects slow consumers. Clients reconnect with exponential backoff plus jitter (0.5 s → 30 s) and resubscribe with the last stream position. During deploys, gateway pods drain over 30 s, sending a reconnect advice frame so 100K sockets don't stampede one pod. A gateway on an AX42 comfortably holds ~100K idle WebSockets (≈ 20–40 KB each including buffers). Peak concurrent connections at 5M MAU are estimated at ~5% of DAU ≈ 37,500, so two pods give large headroom.

iCal sync details

Store each external feed with ETag/Last-Modified, fetch with conditional GET, parse VEVENTs into nightly blocks, diff against existing calendar_days where source = 'ical:<feed>', and write only changes inside a transaction. Imported blocks are inserted as blocked nights and also checked against the bookings exclusion range. If an import overlaps a confirmed nestly booking, raise a host alert instead of silently cancelling. Exported feeds are served from cache and regenerated on calendar.changed.

Deep dive: geo search, availability, double-booking prevention, payments, reviews and images

Deep-dive drafted with Claude Opus 5.5 in Claude Code.

The hard part of a stays product is the combination of search, time-based inventory, money and trust. Get any one wrong and you either show places people can't book, take two payments for one bed, or pay out to fraudsters.

1. Search: geo + dates + ranking

A search for "Goa, 24–27 Nov, 2 guests, under ₹8,000, pool" has four stages:

  1. Geo candidate generation. The map viewport gives a bounding box. In PostGIS: WHERE geo && ST_MakeEnvelope(:w,:s,:e,:n,4326) on a GiST index. In OpenSearch: a geo_bounding_box filter on a geo_point. For "Goa" text queries, resolve the place to a polygon or bbox first using a gazetteer table of destinations and neighbourhoods with aliases ("Panjim" = "Panaji"). H3 cells (resolution 7 ≈ 5 km²) help with coarse precomputation: popularity per cell, price percentiles per cell, and sharding search replicas by region.
  2. Static filters: guests ≤ max, bedrooms ≥ n, property type, amenities (a bitmask or terms filter), instant book. These are indexed fields in the search engine.
  3. Availability and price for the dates. This is the step that kills naive designs. You can't join 50,000 candidates against a calendar table on every query. The usual answer is a precomputed availability bitmap per listing: 1 bit per night for the next 365 days (46 bytes), stored in the search document as an array of month bitsets, or in Valkey. A stay is available if (bits >> start) & mask == mask. Min-nights and check-in-day rules are separate small fields. The nightly price for the range is approximated from a per-listing price vector or a cached quote. The exact quote is computed only for the ~50 results on screen. The clone does the honest small-scale version: isAvailable() checks overlap against booked ranges for every candidate.
  4. Ranking. The clone's score() combines rating, log(review count), a guest-favourite bonus and a new-listing boost. Production ranking is a learning-to-rank model (LightGBM LambdaMART) trained on search→click→book logs. Its features are price relative to the cell median, distance to the viewport centre, photo quality score, host response rate, cancellation rate, historical conversion and personalisation such as preferred property types. Serve it as an OpenSearch LTR rescore over the top 400, or in a ranking service. Add diversity constraints so ten villas from one management company don't dominate a page.

OpenSearch vs Meilisearch. Meilisearch is excellent up to a few hundred thousand listings: typo tolerance, geo radius/bbox, facets and a tiny ops footprint. OpenSearch is the choice when you need custom scoring scripts, LTR plugins, large facet cardinality, and indexing millions of documents with frequent partial updates such as availability changes. Either way, Postgres stays the source of truth, and the index is fed by CDC (Debezium → NATS/Kafka → indexer) with a nightly full rebuild to repair drift.

2. Availability and pricing calendars

Each night has three layers:

  • Default rules: base price, weekend price, min nights, check-in days.
  • Host overrides: a calendar_days row with price, blocked or min_nights.
  • Bookings: daterange rows.

The calendar endpoint merges them for a 2–12 month window. Writes to calendar_days and bookings emit calendar.changed, which recomputes the listing's bitmap and price vector in the search index within seconds. Smart pricing (seasonality, events like New Year in Goa, lead time) writes overrides with source = 'smart_pricing', which the host can lock. Time zones matter: a "night" is a date in the listing's time zone, never a UTC timestamp, so the schema uses date/daterange and not tstzrange. Hourly services and experiences use tstzrange.

3. Preventing double-booking

There are three layers, and only the last is strictly necessary:

  1. UI: the calendar disables booked nights. That is what the clone's Calendar does.
  2. Optimistic check: the quote endpoint checks availability.
  3. Database guarantee: the EXCLUDE USING gist (listing_id WITH =, stay WITH &&) constraint.

Two simultaneous INSERTs for overlapping ranges serialise on the GiST index, and the loser gets SQLSTATE 23P01. No SERIALIZABLE isolation is needed, and nothing races between "check" and "insert", because the check is the insert.

If you also maintain per-night rows (for example a calendar_days row per booked night, so hosts' blocks and bookings share one structure), take a row lock instead: SELECT … FROM listings WHERE id = :id FOR UPDATE at the start of the booking transaction. That serialises bookings per listing, which is fine because per-listing contention is tiny. The lock is per listing, never global.

Experiences have capacity rather than exclusivity: UPDATE slots SET taken = taken + :n WHERE id = :slot AND taken + :n <= capacity RETURNING taken. Zero rows updated means full, which maps to 409. The clone's seatsTaken() check is the in-memory equivalent.

Holds. Insert the booking as pending_payment (which counts in the exclusion constraint), create the payment authorisation, then flip the booking to confirmed. A sweeper deletes holds older than 15 minutes. If the payment webhook arrives after the sweeper ran, the confirm step fails the constraint check (someone else may have booked), and you void the authorisation.

Idempotency protects against the classic double charge: a mobile client times out, retries, and the first request had actually succeeded. The Idempotency-Key is stored inside the same transaction as the booking.

4. Payments and payouts

Use a marketplace product: Stripe Connect (Express accounts) internationally, or Razorpay Route for Indian hosts with INR payouts and UPI. The flow:

  1. At booking: a PaymentIntent with capture_method=manual for an authorisation, or immediate capture where card authorisations expire before check-in. Cards authorise for ~7 days, so long-lead bookings capture at booking time.
  2. Funds sit with the platform in an escrow-like state. Legally, you act as a collection agent: talk to counsel about RBI payment aggregator rules in India.
  3. Payout is scheduled 24 hours after check-in, so issues reported in the first day can stop it, via a Transfer to the host's connected account minus host fees and any tax withholding (TCS/TDS where applicable).
  4. Cancellations compute refunds from the policy snapshot frozen in the booking's quote and create ledger entries.

Webhooks are the backbone. Verify signatures, store event.id in a processed-events table to dedupe, process asynchronously, and treat webhook order as unreliable, so you always re-fetch the object's current state. Reconcile daily against the PSP's balance transactions and alert on any mismatch in the double-entry ledger. GST invoices are generated per booking for the platform fee. The accommodation GST rate depends on the nightly tariff band, so the clone's flat 12% is a simplification.

5. Reviews

Reviews unlock only after checkout, for completed bookings, one per side. Use simultaneous reveal: neither side sees the other's review until both submit or 14 days pass. That removes retaliation and keeps ratings honest. Store category ratings (cleanliness, accuracy, check-in, communication, location, value) as the clone displays them, and recompute rating_avg incrementally. Moderation runs a toxicity classifier plus a rules engine (no phone numbers or URLs) and holds borderline reviews for human review. Ranking uses a Bayesian average, (C × m + Σ ratings) / (C + n), so a single 5.0 review doesn't outrank 400 reviews at 4.92.

6. Image pipeline

Photos sell stays. The clone already resizes in the browser with a canvas to save bandwidth. Production:

  1. POST /uploads returns a presigned PUT to Object Storage under originals/{listing}/{uuid}, with a content-length limit of 25 MB and an allowed MIME list.
  2. The upload completes, triggering an event, and a worker validates the magic bytes, strips EXIF/GPS (privacy: hosts' exact location), scans with ClamAV, and runs an NSFW classifier plus a perceptual hash (pHash) to catch stolen photos reused across listings, a common scam.
  3. The worker generates variants with libvips (sharp): 320, 720, 1200 and 2048 px in AVIF and WebP, plus a BlurHash placeholder. A 12-megapixel photo takes ~150 ms on one core, so 4 AX42 workers process ~400 photos/s, ample for onboarding bursts.
  4. The CDN serves /{variant}/{key} with immutable caching. listing_photos.status flips to ready.

Security, fraud and privacy

Threat model and mitigations drafted with Claude in Claude Code.

Authentication and sessions

  • Passkeys (WebAuthn) as the primary login, with email or phone OTP as fallback. Hosts handling payouts must use a passkey or TOTP as a second factor before changing bank details.
  • OIDC (Google, Apple) for sign-up conversion.
  • Server-side sessions: a __Host- cookie, HttpOnly, Secure, SameSite=Lax, holding an opaque id that maps to a Valkey record. Long-lived JWTs are hard to revoke when an account is taken over, which is a real risk on a platform that moves money. Short-lived JWTs (10 min) are minted only for the realtime gateway and for mobile API calls.

Authorisation

Use resource-level checks on every endpoint, enforced in one policy module, not in UI code:

  • Only the guest, the host and support may read a booking.
  • Only the listing's host (or co-hosts with a scoped role) may edit price or calendar.
  • Messages are visible only to thread participants.

Write tests that try every endpoint with the wrong user. IDOR (insecure direct object reference) on /bookings/{id} is the classic marketplace bug, which is why public references use a random code rather than the sequential id.

OWASP Top 10, made specific

  • Broken access control: the IDOR above, plus host-impersonation in messaging. Covered by the policy module and tests.
  • Injection: parameterised SQL only. Search query strings go to OpenSearch through a typed query builder, never string-concatenated DSL.
  • Cryptographic failures: card data never touches your servers (PSP hosted fields / Payment Element keep you in PCI SAQ-A). Bank details are tokenised by the PSP.
  • Insecure design: a price-manipulation attempt (client sends total: 1) is impossible, because the server recomputes from a signed quote, as the clone does with quote() server-side.
  • SSRF: iCal import fetches arbitrary URLs. Resolve DNS and block private, link-local and metadata ranges, cap response size (2 MB) and time (5 s), and allow only http(s).
  • XSS: listing descriptions and messages render as text. If you allow formatting, sanitise with an allowlist. Use a strict CSP.
  • Vulnerable components: Renovate, npm audit in CI, base images rebuilt weekly.
  • Auth failures: rate limiting and credential-stuffing detection on OTP endpoints.

Headers and CSP

Content-Security-Policy: default-src 'self'; img-src 'self' https://cdn.nestly.example data: blob:; script-src 'self' 'nonce-{r}' https://js.stripe.com; frame-src https://js.stripe.com https://hooks.stripe.com; connect-src 'self' wss://rt.nestly.example https://api.stripe.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(self), camera=(), microphone=()

Fraud, abuse and spam

Marketplace fraud is mostly fake listings (stolen photos, too-good-to-be-true prices), off-platform payment requests in messages, stolen cards for last-minute bookings, and account takeovers of established hosts.

  • Mitigations include host KYC (PAN/Aadhaar-based eKYC via a regulated provider, with bank account penny-drop verification) before the first payout, and pHash matching of photos across listings.
  • Message scanning masks phone numbers and UPI IDs before a booking is confirmed.
  • PSP risk scoring and 3-D Secure on high-risk bookings.
  • Velocity rules: new account + high value + same-day check-in → manual review.
  • New bank-detail changes trigger a 72-hour payout hold.

Uploads and moderation

Covered in the image pipeline: magic-byte checks, ClamAV, EXIF stripping, NSFW and duplicate detection. Reviews and messages pass a toxicity classifier, with an appeal path for hosts.

Secrets and encryption

  • Keep secrets in Kubernetes Secrets encrypted with SOPS + age in Git, or in Vault/OpenBao. Rotate PSP keys and the webhook secret yearly, and immediately on staff departures.
  • TLS 1.3 everywhere: Cloudflare to the origin, and inside the cluster via the private network plus mTLS for Postgres (sslmode=verify-full).
  • At rest: LUKS on AX disks, encrypted pgBackRest repositories (repo1-cipher-type=aes-256-cbc) and Object Storage with server-side encryption.

DDoS and bots

Hetzner's automatic DDoS protection absorbs volumetric attacks on every product. In front of it, the CDN/WAF handles L7: rate rules on /search, bot scoring for price scrapers, and a managed challenge on login and booking endpoints when risk spikes.

Privacy: GDPR and India's DPDP Act

Under India's Digital Personal Data Protection Act (2023) and the GDPR for EU guests:

  • Collect only what a booking needs, and record the purpose of each field.
  • Get consent for marketing.
  • Provide export and deletion. Deletion pseudonymises bookings and ledger rows, which tax law requires you to keep, rather than dropping them.
  • Show the exact address only after booking.
  • Keep processor agreements with the PSP, SMS and email vendors.
  • Keep data in-region where contracts require it.

Audit logging

Write an append-only audit log for payout changes, refunds, price edits, calendar overrides, support impersonation and admin actions: who, when, before and after values, IP and user agent. Ship it to separate, write-once storage (an Object Storage bucket with object lock) so a compromised admin can't erase their tracks.

Observability and operations

Signals

  • Metrics: Prometheus scraping app pods (RED metrics per route), PgBouncer/Postgres (postgres_exporter: replication lag, locks, 23P01 exclusion-violation rate), Valkey, NATS, OpenSearch and node exporters. Long-term storage in VictoriaMetrics or Mimir on an AX102.
  • Logs: structured JSON (pino) shipped by Grafana Alloy to Loki, with request id, user id hash and booking code as labels or fields. Never log card data, OTPs or full addresses.
  • Traces: OpenTelemetry SDK in Next.js (instrumentation.js) and services, exported to Tempo. Search → pricing → availability spans show where the p95 goes.
  • Product and business metrics: search-to-view, view-to-quote, quote-to-booking conversion, payment failure rate by PSP and method (UPI vs cards), payout lag, and cancellation rate.

SLOs

SLOTargetWhy
Search API p95 latency< 400 msBrowsing feel; map panning
Listing page TTFB p75< 600 ms at CDN edgeSEO and LCP
Booking create success (excluding legit 402/409)99.95% / 30 daysRevenue path
Double bookings0 (hard invariant)Trust; enforced by constraint, monitored nightly
Booking notification delivery to host99% within 60 sHost experience
Payout on schedule99.9%Host trust and legal

Alerting and on-call

Alert on SLO burn rates (multi-window: 2% of the monthly budget in 1 h, or 5% in 6 h), not on raw CPU. Page on:

  • Booking error spikes, PSP webhook backlog over 5 minutes, replication lag over 30 s, or the outbox relay stalled.
  • Search index lag over 2 minutes (users see dates as free that aren't).
  • Certificate expiry, or backup jobs missing for 26 h.

Run a weekly rotation with a runbook per alert ("409 rate tripled": check for a stuck pending-payment sweeper). Hold blameless post-incident reviews.

Load testing with k6

Script realistic journeys rather than hammering one URL: search with random bboxes and dates, open 3–5 listings, request quotes, and book 0.5% of sessions against a staging copy with a sandbox PSP. Include a contention test: 200 virtual users booking the same listing and dates at once must produce exactly one 201 and 199 409s. Run it in CI nightly against staging, and before every major sale or holiday season (Diwali, Christmas–New Year in Goa).

Chaos and restore drills

  • Kill a Postgres primary in staging monthly and measure Patroni failover (target < 30 s, zero lost confirmed bookings with remote_write).
  • Kill a gateway pod under load to verify reconnection storms are smoothed by jitter.
  • Restore last night's pgBackRest backup to a scratch AX server every month and run integrity checks: row counts, ledger balance sums to zero, no overlapping live bookings. An untested backup is a hope, not a backup.

CI/CD

GitHub Actions runs lint, unit tests (pricing and date-range logic deserve heavy table-driven tests), Playwright end-to-end tests of search → book → cancel, and builds an OCI image with SBOM and provenance, pushed to a registry. Argo CD syncs k3s from a Git manifests repo. Canary releases route 5% of traffic for 15 minutes with automatic rollback on the error budget. Migrations are expand/contract: add columns and backfill first, switch reads, drop later. Never take an ACCESS EXCLUSIVE lock on bookings during business hours.

Cost breakdown

Cost model calculated by Claude Code from the Hetzner line items above, excl. VAT.

All figures are EUR/month excl. VAT, as listed on hetzner.com in Nov 2026. Verify before ordering. They are estimates built from the server lists in the infrastructure tables. They exclude one-off AX setup fees, the CDN bill, PSP fees (typically ~2% of payment volume in India, by far the largest variable cost of a marketplace) and SMS/email vendors.

StageUsersHetzner infrastructure / monthCost per 1K MAU
Launch0–50K MAU€206.95€4.14 (at 50,000 MAU)
Growth~500K MAU€1,330.76€2.66 (at 500,000 MAU)
Scale~5M MAU€3,313.49€0.66 (at 5,000,000 MAU)
Hyperscale10M+ MAU€10,442.91€1.04 (at 10,000,000 MAU)

What drives the bill

  1. Payment processing. This is not a Hetzner line, but at 3,000 bookings/day × ₹25,000 average ≈ ₹75M/day of payment volume, a ~2% PSP fee dwarfs every server here. Negotiate rates, push UPI (cheaper rails) and keep chargebacks low.
  2. Image egress and storage. ~28 TB/day of image views at the Scale stage (estimate from the capacity section) must hit the CDN, not origin. Serve AVIF (30–50% smaller than JPEG at equal quality), size-matched variants and long immutable cache headers. Origin egress from Object Storage is ~€1/TB beyond the included 1 TB, so a 95% hit ratio keeps it to tens of euros. A 70% hit ratio would multiply it six-fold.
  3. Postgres and search hardware. Postgres and search need fast NVMe and RAM. Dedicated AX162/AX102 servers deliver that at a fraction of equivalent cloud vCPU pricing, because traffic is unmetered in DE/FI and the hardware isn't shared.
  4. People time. Self-running Postgres, OpenSearch and k3s isn't free. Budget one platform engineer per stage of growth, and automate failover and restores early.

How to cut costs

  • Start with Meilisearch on the app box and move to OpenSearch only when ranking or volume demands it.
  • Put anonymous search and listing JSON behind the CDN with 15–60 s TTLs. Most traffic is logged-out browsing.
  • Use the availability bitmap so date-filtered search doesn't hammer Postgres.
  • Move steady workloads from CCX to AX once utilisation is high around the clock. The AX42 at €79 has 8 dedicated cores and 64 GB versus the CCX33's 8 dedicated vCPU and 32 GB at €138.99 (NBG1).
  • Use Server Auction boxes (from €75.70) for stateless workers and CI runners.
  • Apply lifecycle rules: keep only one original plus the variants actually served, and delete abandoned draft uploads after 7 days.

Roadmap: from clone to production launch

This checklist takes the clone to a production launch in one Indian region with a few thousand listings.

Data and correctness

  • Replace lib/db.js with PostgreSQL + PostGIS. Port the schema above, including the no_overlap exclusion constraint and idempotency_keys.
  • Table-driven tests for quote(), date ranges (month and year boundaries, DST-free IST), cancellations and refunds.
  • The k6 contention test from Observability, which must produce exactly one 201.
  • A transactional outbox and a NATS JetStream relay.

Search

  • Gazetteer of destinations and neighbourhoods with aliases. Bbox search from the map viewport (MapLibre + self-hosted PMTiles replacing the schematic SVG).
  • Meilisearch or OpenSearch index fed by CDC, with an availability bitmap per listing and a nightly full rebuild.
  • Ranking v1: Bayesian rating + conversion + price-vs-cell-median. Log impressions for LTR later.

Money

  • Razorpay Route or Stripe Connect onboarding with host KYC and bank verification.
  • Manual-capture or capture-at-booking flows, the payout scheduler (check-in + 24 h), a refund engine with policy snapshots, and GST invoices.
  • Idempotent webhook processor, daily reconciliation and a double-entry ledger.

Trust and safety

  • Passkeys + OTP, server sessions, a policy module with IDOR tests.
  • Image pipeline: presigned uploads, EXIF stripping, ClamAV, NSFW and pHash duplicate checks.
  • Message masking before booking, a review simultaneous-reveal job and an audit log with object lock.

Realtime and notifications

  • Centrifugo with NATS. Messaging with seq-based recovery, live availability on listing pages.
  • Web Push (VAPID), APNs/FCM, transactional email with DKIM/SPF/DMARC, and DLT-registered SMS templates.
  • iCal import (SSRF-safe) and export feeds.

Operations

  • Launch-tier Hetzner stack via Ansible, with pgBackRest to an off-site bucket and a tested restore.
  • Prometheus/Grafana/Loki/Tempo, the SLOs above, and an on-call rotation.
  • CDN in front of images and anonymous pages, with WAF rules for scrapers.

Legal and product

  • Terms, privacy notice (DPDP/GDPR), host agreement, cancellation policies, and local short-term-rental registration fields where states require them.
  • Accessibility pass (calendar keyboard navigation, focus traps in modals). Real translations behind the language switch.

Implementation snippets

Exclusion constraint that makes double-booking impossible

A GiST exclusion constraint on (listing_id, daterange) rejects any overlapping live booking with SQLSTATE 23P01, even under concurrent inserts. Requires btree_gist.

-- Double-booking is impossible at the database level.
CREATE EXTENSION IF NOT EXISTS btree_gist;

ALTER TABLE bookings
  ADD CONSTRAINT no_overlap
  EXCLUDE USING gist (listing_id WITH =, stay WITH &&)
  WHERE (status IN ('pending_payment', 'confirmed'));

-- Two sessions racing for 24–27 Nov on listing 42:
-- session A
INSERT INTO bookings (code, listing_id, guest_id, stay, guests, status, total_minor, currency, quote)
VALUES ('HM8K2Q', 42, 7, daterange('2026-11-24', '2026-11-27', '[)'), 2, 'pending_payment', 3600800, 'INR', '{}');
-- session B (overlaps 26 Nov) fails with:
-- ERROR: conflicting key value violates exclusion constraint "no_overlap"  (SQLSTATE 23P01)

Idempotent booking endpoint mapping 23P01 to HTTP 409

Stores the Idempotency-Key in the same transaction as the booking, replays stored responses, rejects key reuse with a different body, and turns exclusion violations into 409 dates_unavailable.

// POST /v1/bookings — idempotent, maps exclusion violations to 409 (node-postgres)
import { pool } from "./db.js";
import { verifyQuote } from "./quotes.js";

export async function createBooking(req, res) {
  const key = req.get("Idempotency-Key");
  if (!key) return res.status(400).json({ error: "Idempotency-Key required" });
  const quote = verifyQuote(req.body.quoteId); // HMAC-signed, not expired, belongs to user
  const hash = sha256(JSON.stringify(req.body));
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const prior = await client.query(
      `INSERT INTO idempotency_keys (key, user_id, request_hash) VALUES ($1, $2, $3)
       ON CONFLICT (key) DO NOTHING RETURNING key`, [key, req.user.id, hash]);
    if (prior.rowCount === 0) {
      await client.query("ROLLBACK");
      const { rows } = await pool.query("SELECT request_hash, response, status_code FROM idempotency_keys WHERE key = $1", [key]);
      if (!rows[0].request_hash.equals(hash)) return res.status(422).json({ error: "Key reused with a different body" });
      if (!rows[0].response) return res.status(409).json({ error: "Request in progress" });
      return res.status(rows[0].status_code).json(rows[0].response);
    }
    const { rows: [booking] } = await client.query(
      `INSERT INTO bookings (code, listing_id, guest_id, stay, guests, status, total_minor, currency, quote)
       VALUES ($1, $2, $3, daterange($4, $5, '[)'), $6, 'pending_payment', $7, 'INR', $8) RETURNING *`,
      [newCode(), quote.listingId, req.user.id, quote.checkin, quote.checkout, quote.guests, quote.totalMinor, quote]);
    await client.query("INSERT INTO outbox (topic, payload) VALUES ('booking.held', $1)", [booking]);
    const body = { booking };
    await client.query("UPDATE idempotency_keys SET response = $2, status_code = 201 WHERE key = $1", [key, body]);
    await client.query("COMMIT");
    return res.status(201).json(body);
  } catch (e) {
    await client.query("ROLLBACK");
    if (e.code === "23P01") return res.status(409).json({ error: "dates_unavailable" });
    throw e;
  } finally {
    client.release();
  }
}

PostGIS bounding-box search with date availability

The straightforward relational version of stay search: viewport bbox on a GiST index, static filters, NOT EXISTS checks against bookings and host blocks, and a simple quality score. Good up to a few hundred thousand listings.

-- Geo + static filters in PostGIS, availability checked against live bookings.
SELECT l.id, l.title, l.base_price, l.rating_avg
FROM listings l
WHERE l.status = 'listed'
  AND l.geo && ST_MakeEnvelope(73.68, 15.0, 74.1, 15.75, 4326)::geography   -- map viewport (Goa)
  AND l.max_guests >= 2
  AND l.base_price BETWEEN 2000 AND 8000
  AND NOT EXISTS (                                                          -- available 24–27 Nov
    SELECT 1 FROM bookings b
    WHERE b.listing_id = l.id
      AND b.status IN ('pending_payment', 'confirmed')
      AND b.stay && daterange('2026-11-24', '2026-11-27', '[)'))
  AND NOT EXISTS (
    SELECT 1 FROM calendar_days c
    WHERE c.listing_id = l.id AND c.blocked AND c.night >= '2026-11-24' AND c.night < '2026-11-27')
ORDER BY (coalesce(l.rating_avg, 4.6) * 20 + ln(1 + l.review_count) * 8) DESC
LIMIT 24;

Availability bitmaps in Valkey for fast date filtering

One bit per night per listing-month. Search checks candidate availability in a single pipelined round trip instead of joining calendar tables.

// Availability bitmap per listing-month in Valkey: 1 bit per night, set = booked/blocked.
// Checking 3 nights for 5,000 candidates is one pipeline of BITFIELD GETs.
import Redis from "iovalkey";
const r = new Redis(process.env.VALKEY_URL);

const key = (id, d) => `avail:${id}:${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}`;

export async function markNights(listingId, checkin, checkout, booked = true) {
  const p = r.pipeline();
  for (let d = new Date(checkin); d < new Date(checkout); d.setUTCDate(d.getUTCDate() + 1)) {
    p.setbit(key(listingId, d), d.getUTCDate() - 1, booked ? 1 : 0);
  }
  await p.exec();
}

export async function filterAvailable(ids, checkin, checkout) {
  const nights = [];
  for (let d = new Date(checkin); d < new Date(checkout); d.setUTCDate(d.getUTCDate() + 1)) nights.push(new Date(d));
  const p = r.pipeline();
  for (const id of ids) for (const n of nights) p.getbit(key(id, n), n.getUTCDate() - 1);
  const res = await p.exec();
  return ids.filter((_, i) => res.slice(i * nights.length, (i + 1) * nights.length).every(([, bit]) => bit === 0));
}

Payment webhook: signature check, dedupe, confirm or refund

Verifies a Razorpay webhook HMAC over the raw body with a timing-safe compare, dedupes by event id, confirms the held booking or refunds if the hold expired.

// Razorpay webhook: verify signature, dedupe by event id, confirm the held booking.
import crypto from "node:crypto";
import { pool } from "./db.js";

export async function razorpayWebhook(req, res) {
  const raw = req.rawBody; // exact bytes, before JSON parsing
  const expected = crypto.createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET).update(raw).digest("hex");
  const got = req.get("X-Razorpay-Signature") || "";
  if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
    return res.status(400).end();
  }
  const event = JSON.parse(raw);
  const eventId = req.get("X-Razorpay-Event-Id");
  const { rowCount } = await pool.query(
    "INSERT INTO processed_events (provider, event_id) VALUES ('razorpay', $1) ON CONFLICT DO NOTHING", [eventId]);
  if (rowCount === 0) return res.status(200).end(); // already handled

  if (event.event === "payment.captured") {
    const bookingCode = event.payload.payment.entity.notes.booking_code;
    // Still a live hold? Confirm it. If the hold expired and the dates were taken, refund.
    const { rowCount: ok } = await pool.query(
      `UPDATE bookings SET status = 'confirmed', payment_intent = $2
       WHERE code = $1 AND status = 'pending_payment'`, [bookingCode, event.payload.payment.entity.id]);
    if (!ok) await enqueue("payments.refund", { paymentId: event.payload.payment.entity.id, reason: "hold_expired" });
    else await enqueue("booking.confirmed", { bookingCode });
  }
  res.status(200).end();
}

Centrifugo channels for messaging and booking alerts

Namespaces for thread, user and listing channels with history and forced recovery, so clients resume from their last offset after reconnecting. Illustrative: check key names against your Centrifugo version.

# Centrifugo v6 config: WebSocket + SSE, NATS broker, history for recovery
client:
  token:
    hmac_secret_key: "${CENTRIFUGO_TOKEN_SECRET}"
  allowed_origins: ["https://nestly.example"]
engine:
  type: redis   # Valkey-compatible; or use the nats broker below for pub/sub only
broker:
  enabled: true
  type: nats
  nats:
    url: nats://nats.internal:4222
channel:
  namespaces:
    - name: thread
      history_size: 200
      history_ttl: 720h
      force_recovery: true        # clients resume from last offset after reconnect
      allow_subscribe_for_client: false   # backend issues subscription tokens per participant
    - name: user
      history_size: 50
      history_ttl: 72h
      force_recovery: true
    - name: listing
      allow_subscribe_for_anonymous: true  # public availability pings
http_stream:
  enabled: true
sse:
  enabled: true

pgBackRest to Hetzner Object Storage

Encrypted, zstd-compressed backups and continuous WAL archiving from the Postgres primary to an S3-compatible Hetzner bucket in another location, with weekly full and daily differential backups.

# /etc/pgbackrest/pgbackrest.conf on the Postgres primary (AX102), repo in Hetzner Object Storage
[global]
repo1-type=s3
repo1-s3-endpoint=hel1.your-objectstorage.com
repo1-s3-region=hel1
repo1-s3-bucket=nestly-pg-backups
repo1-s3-key=${S3_KEY}
repo1-s3-key-secret=${S3_SECRET}
repo1-path=/pgbackrest
repo1-retention-full=4
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=${BACKUP_PASSPHRASE}
process-max=4
compress-type=zst
start-fast=y

[nestly]
pg1-path=/var/lib/postgresql/17/main

# postgresql.conf: archive_command = 'pgbackrest --stanza=nestly archive-push %p'
# cron: 0 2 * * 0 pgbackrest --stanza=nestly --type=full backup
#       0 2 * * 1-6 pgbackrest --stanza=nestly --type=diff backup

k6 contention test: exactly one winner

200 virtual users race for the same nights. The threshold fails the run unless exactly one booking is created. It runs as-is against the clone's /api/bookings.

// k6 contention test: 200 users try to book the same nights; exactly one may win.
import http from "k6/http";
import { check } from "k6";
import { Counter } from "k6/metrics";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";

const created = new Counter("bookings_created");
const conflicts = new Counter("bookings_conflict");

export const options = {
  scenarios: { race: { executor: "per-vu-iterations", vus: 200, iterations: 1, maxDuration: "30s" } },
  thresholds: { bookings_created: ["count==1"], http_req_failed: ["rate<0.995"] },
};

export default function () {
  const res = http.post(
    `${__ENV.BASE}/api/bookings`,
    JSON.stringify({ kind: "stay", listingId: "assagao-villa", checkin: "2027-01-10", checkout: "2027-01-13", guests: 2, payment: { number: "4242424242424242" } }),
    { headers: { "Content-Type": "application/json", "Idempotency-Key": uuidv4() } }
  );
  if (res.status === 201) created.add(1);
  if (res.status === 409) conflicts.add(1);
  check(res, { "201 or 409": (r) => r.status === 201 || r.status === 409 });
}

Related videos from The Bhairav Show

Frequently asked questions

How did you build the Airbnb clone with Claude Code?

I designed nestly with Claude on a design canvas, then gave Claude Code the design and a written brief covering the stack, the fidelity bar, what "fully working" means and the proof required. A Claude Code sub-agent running Claude Opus 5.5 built the app in about 33 minutes: it wrote the Next.js app and API routes, got build and lint passing, ran the app, took and reviewed screenshots, and drafted the scaling plan.

Can Claude Code build a production-ready Airbnb clone?

Claude Code can build a complete, working frontend with API routes quickly, as nestly shows. Production readiness also needs the backend described in this guide: a real database, authentication, a real-time gateway, media or payment pipelines, security hardening and operations. Claude Code helps write that too, but the architecture and risk decisions stay with the engineer.

Which Claude tools were used to build this clone?

Claude's design canvas for the screens, claude Code and its sub-agents for coding, testing and screenshots, and Claude Opus 5.5 for the scalability, Hetzner infrastructure, real-time and security analysis.

How do you build an Airbnb clone that scales to millions of users?

Keep the web tier stateless on Next.js, keep PostgreSQL with PostGIS as the source of truth, and enforce availability with an exclusion constraint so double-booking can't happen. Serve search from OpenSearch or Meilisearch fed by change data capture, put images behind a CDN, and handle money with a marketplace payment product such as Stripe Connect or Razorpay Route. Scale reads with replicas and caching long before you shard.

How do I prevent double bookings in a booking system?

Use a PostgreSQL exclusion constraint: EXCLUDE USING gist (listing_id WITH =, stay WITH &&) on a half-open daterange, with btree_gist installed. Concurrent overlapping inserts can't both commit, and the loser gets SQLSTATE 23P01, which you return as HTTP 409. Add idempotency keys so client retries never create duplicates.

Should I use daterange or tstzrange for hotel and vacation rental bookings?

Use daterange for nightly stays. A night is a calendar date in the listing's time zone, and a half-open [checkin, checkout) range leaves checkout day free for the next guest. Use tstzrange for hourly products such as experiences, services or meeting rooms.

How does geo search work on a vacation rental site?

The map viewport becomes a bounding box, queried with a GiST index in PostGIS or a geo_bounding_box filter in OpenSearch. Text queries like 'Goa' resolve to a polygon through a gazetteer first. H3 cells help with precomputed signals such as price percentiles per area and with regional sharding.

How do you filter search results by available dates quickly?

Don't join a calendar table per query. Keep a per-listing availability bitmap (one bit per night) in the search document or in Valkey, and test the requested nights with a bit mask. Compute exact prices only for the results on screen.

Is OpenSearch or Meilisearch better for an Airbnb clone?

Meilisearch is simpler and great for up to a few hundred thousand listings with geo filters, facets and typo tolerance. OpenSearch fits better when you need learning-to-rank, custom scoring scripts, very high facet cardinality, and frequent partial updates to millions of documents.

How do marketplace payments and host payouts work?

The guest pays the platform through a marketplace product (Stripe Connect or Razorpay Route). Funds are held, then transferred to the host's connected account after check-in, usually 24 hours later, minus fees and taxes. Webhooks drive state changes and must be signature-verified and deduplicated by event id.

How much does it cost to host an Airbnb clone on Hetzner?

Using Nov 2026 list prices excl. VAT, a launch stack of two CPX32 app servers, a CCX23 database, a CPX32 for cache, search and workers, a load balancer and Object Storage comes to roughly €207 per month. A ~5M MAU stack on dedicated AX servers is estimated at about €3,310 per month before CDN, setup fees and payment processing.

Does Hetzner offer managed PostgreSQL?

No. You run PostgreSQL yourself, typically with Patroni for failover, PgBouncer for pooling and pgBackRest backing up to Hetzner Object Storage, or you use a managed provider elsewhere. Hetzner has no India region, so pair it with a CDN that has Indian points of presence.

How do host–guest messaging and booking notifications work in real time?

Messages are written to Postgres first, then published through a WebSocket/SSE gateway such as Centrifugo backed by NATS or Valkey. Each message has a per-thread sequence number so clients recover gaps on reconnect. Offline users get Web Push, APNs or FCM, and confirmations always go by email.

How does iCal calendar sync work for vacation rentals?

Each listing exports an .ics feed of booked and blocked nights and imports other platforms' feeds every 15–30 minutes using conditional GET. Imported events become blocked nights. Treat import URLs as untrusted: block private IP ranges and cap size and time to prevent SSRF.

How should reviews work on a stays marketplace?

Allow one review per completed stay per side, and reveal both reviews only when both are submitted or after 14 days, which prevents retaliation. Store category ratings, moderate for abuse and contact details, and rank with a Bayesian average so a few 5-star reviews don't outrank hundreds of 4.9s.

Is this nestly clone production-ready?

No. It is a fully clickable demo. Server data lives in memory and resets on restart, payments are a validated but fake card form, and auth is a local display name. The guide lists every system you need to replace, from PostgreSQL exclusion constraints to the payment webhooks.

More SaaS clones