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.
- Source code
- github.com/suhasbhairav/airbnb-cloneMIT license
- 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








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/listingsfilters 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 Conflictif 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-Keyper 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 byuseSyncExternalStoreover 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.
| Stage | Claude tool | What it did |
|---|---|---|
| Design | Claude design canvas | The app screens were designed with Claude on a design canvas, one board per product. |
| Coding | Claude Code | A Claude Code sub-agent wrote nestly: 41 files, 10 pages, 5 API routes, in about 33 minutes. |
| Parallel agents | Claude Code sub-agents | Six clones were built at the same time, one sub-agent per app. |
| Testing & screenshots | Claude Code | Build, lint, a running server and 8 screenshots that Claude reviewed and fixed. |
| Scalability & infrastructure analysis | Claude Opus 5.5 | Capacity 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-appscaffold (Next.js 16, React 19, Tailwind CSS v4). Next 16 has breaking changes, so Claude was told to read the docs bundled innode_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:
- Read the design file and the Next.js 16 docs it needed.
- 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]). - Ran
npm run buildandnpm run lintuntil both passed cleanly. - 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-sizeflag won't lay out narrower than about 500px. - 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
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
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
/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
/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
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
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/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
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:
- Server page with an async inner component.
app/page.js,rooms/[id],book/[id]andexperiences/[id]export a synchronous page that renders<Suspense fallback={<Skeleton/>}><Inner params={params}/></Suspense>. The inner component awaitsparams, callsawait connection()(date-dependent output must not be prerendered at build time), reads the store and passes plain data to a client component. - Client page behind Suspense.
/s,/trips,/wishlistsand/host/newrender a client component that usesuseSearchParams()and fetches route handlers. That is the "frontend talks to an API" path.
Routes
| Route | Rendering | Purpose |
|---|---|---|
/ and /?tab=experiences|services | Partial prerender + streamed server component | Carousels from lib/db |
/s | Client + /api/listings | Results, filters, map, cursor pagination |
/rooms/[id] | Streamed server → RoomClient | Detail, calendar, booking card, lightbox |
/book/[id] | Streamed server → BookClient | Confirm and pay |
/trips, /trips/[id] | Client | Trips list, confirmation, cancellation |
/wishlists | Client | Saved stays and experiences |
/experiences/[id] | Streamed server → client | Slot booking with capacity |
/host, /host/new | Client | Your 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 withuseSyncExternalStore. The server snapshot is the initial value, so SSR output never depends on localStorage, which rules out hydration mismatches. EverylocalStoragecall sits intry/catchand falls back to memory in private mode or when the quota is full. Astorageevent 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 userouter.push. Cosmetic URL updates usehistory.replaceStateandpushState, which Next 16 integrates withuseSearchParamswithout 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 inlib/dates.js(a < d && c < bfor 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_paymentrows 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
quoteJSON 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 bylisting_idkeeps 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 abookings_by_guestprojection, 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.
| Method | Endpoint | Description | Cache |
|---|---|---|---|
| GET | /v1/search/stays?bbox=&q=&checkin=&checkout=&guests=&price_min=&price_max=&types=&bedrooms=&amenities=&sort=&cursor= | Ranked results + facets + histogram | 60 s at CDN when dates absent; 15 s per query hash otherwise |
| GET | /v1/listings/{id} | Listing content, photos, host summary | s-maxage=300, purged on edit |
| GET | /v1/listings/{id}/calendar?from=&to= | Per-night availability and price | private, max-age=30 |
| POST | /v1/listings/{id}/quote | Price quote for dates/guests (signed, 15-min TTL) | none |
| POST | /v1/bookings | Create booking from a quote; requires Idempotency-Key | none |
| GET | /v1/bookings/{code} | Booking detail (guest or host only) | private |
| POST | /v1/bookings/{code}/cancel | Cancel with policy-computed refund | none |
| GET | /v1/me/trips?cursor= | Guest trips | private |
| POST | /v1/listings / PATCH /v1/listings/{id} | Host create/edit | none |
| POST | /v1/uploads | Presigned PUT URL for a photo | none |
| GET/POST | /v1/threads/{id}/messages | Host–guest messaging | private |
| 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:
POST /quotereturns a breakdown and aquote_idsigned with an HMAC over listing, dates, guests, totals and expiry. The UI renders exactly what the server will charge.POST /bookingstakesquote_idplus anIdempotency-Keyheader (a UUID generated once per checkout page, asBookClient.jsdoes).
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 abovemax_guests, nights below the minimum, or an expired quote.429: rate limit, withRetry-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.updatedandcalendar.changedevents 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 thelisting.updatedevent. - Availability bitsets:
avail:{listing}:{yyyymm}as a 31-bit integer. A search for 12–15 March checks bits 11–13 in oneBITFIELDcall, 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 / monthDocker 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.
| Role | Hetzner | Spec | Count | Unit € / mo | Total € / mo |
|---|---|---|---|---|---|
| App + API (Next.js) | CPX32 | 4 vCPU / 8 GB / 160 GB NVMe | 2 | €35.99 | €71.98 |
| PostgreSQL 17 + PostGIS | CCX23 | 4 dedicated vCPU / 16 GB / 160 GB | 1 | €86.49 | €86.49 |
| Valkey + Meilisearch + workers | CPX32 | 4 vCPU / 8 GB / 160 GB | 1 | €35.99 | €35.99 |
| Load Balancer | LB11 | from ~€6/month, check console | 1 | €6.00 | €6.00 |
| Object Storage (photos, backups) | Object Storage | €6.49 base incl. 1 TB storage + 1 TB egress | 1 | €6.49 | €6.49 |
Growth· ~500K MAU
≈ €1,330.76 / monthk3s 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.
| Role | Hetzner | Spec | Count | Unit € / mo | Total € / mo |
|---|---|---|---|---|---|
| k3s agents: web + API | CCX23 | 4 dedicated vCPU / 16 GB | 4 | €86.49 | €345.96 |
| Realtime gateway (Centrifugo) + NATS | CPX32 | 4 vCPU / 8 GB | 2 | €35.99 | €71.98 |
| PostgreSQL primary + replica | AX102 | Ryzen 9 7950X3D 16c / 128 GB ECC / 2×1.92 TB NVMe | 2 | €159.00 | €318.00 |
| Valkey primary + replica | CCX13 | 2 dedicated vCPU / 8 GB | 2 | €43.49 | €86.98 |
| Search (OpenSearch/Meilisearch) | CCX33 | 8 dedicated vCPU / 32 GB | 2 | €138.99 | €277.98 |
| Workers: images, iCal, payouts, email | CPX42 | 8 vCPU / 16 GB | 2 | €69.99 | €139.98 |
| Monitoring (Prometheus, Loki, Grafana) | CPX32 | 4 vCPU / 8 GB | 1 | €35.99 | €35.99 |
| Load Balancers | LB11 | from ~€6/month each, check console | 2 | €6.00 | €12.00 |
| Object Storage | Object Storage | base + ~4 TB storage + ~10 TB egress (estimate) | 1 | €41.89 | €41.89 |
Scale· ~5M MAU
≈ €3,313.49 / monthBaseline 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.
| Role | Hetzner | Spec | Count | Unit € / mo | Total € / mo |
|---|---|---|---|---|---|
| k3s agents: web + API | AX42 | Ryzen 7 PRO 8700GE 8c / 64 GB DDR5 ECC / 2×512 GB NVMe | 8 | €79.00 | €632.00 |
| Realtime gateway + NATS JetStream | AX42 | 8c / 64 GB | 3 | €79.00 | €237.00 |
| PostgreSQL primary + sync replica + async replica | AX162 | EPYC 9454P 48c / 256 GB ECC / 2×1.92 TB NVMe DC | 3 | €319.00 | €957.00 |
| Valkey cluster | AX42 | 8c / 64 GB | 3 | €79.00 | €237.00 |
| OpenSearch data nodes | AX102 | 16c / 128 GB / 2×1.92 TB NVMe DC | 3 | €159.00 | €477.00 |
| Workers: image pipeline, iCal sync, payouts | AX42 | 8c / 64 GB | 4 | €79.00 | €316.00 |
| Observability storage | AX102 | 16c / 128 GB | 1 | €159.00 | €159.00 |
| Load Balancers | LB11 | from ~€6/month each, check console | 3 | €6.00 | €18.00 |
| Object Storage | Object Storage | base + ~40 TB storage + ~20 TB egress (estimate) | 1 | €280.49 | €280.49 |
Hyperscale· 10M+ MAU
≈ €10,442.91 / monthPrimary 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.
| Role | Hetzner | Spec | Count | Unit € / mo | Total € / mo |
|---|---|---|---|---|---|
| k3s agents: web + API (EU) | AX42 | 8c / 64 GB | 20 | €79.00 | €1,580.00 |
| Realtime gateways | AX42 | 8c / 64 GB | 6 | €79.00 | €474.00 |
| PostgreSQL shards (4 shards × primary + replica) | AX162 | 48c / 256 GB+ / NVMe DC | 8 | €319.00 | €2,552.00 |
| Valkey clusters (EU primary + HEL) | AX102 | 16c / 128 GB | 6 | €159.00 | €954.00 |
| OpenSearch (EU + HEL replicas) | AX102 | 16c / 128 GB | 8 | €159.00 | €1,272.00 |
| Workers | AX42 | 8c / 64 GB | 10 | €79.00 | €790.00 |
| Kafka + ClickHouse analytics | AX102 | 16c / 128 GB | 5 | €159.00 | €795.00 |
| Observability | AX102 | 16c / 128 GB | 3 | €159.00 | €477.00 |
| US read edge (Ashburn): app + cache | CPX31 (US) | 4 vCPU / 8 GB / 160 GB, 1–5 TB traffic | 8 | €62.99 | €503.92 |
| Load Balancers | LB11 | from ~€6/month each, check console | 6 | €6.00 | €36.00 |
| Object Storage | Object Storage | base + ~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
- Host–guest messaging. Messages, typing indicators, read receipts and attachments such as check-in instructions or photos of the gate.
- 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.
- 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.
- Calendar sync (iCal). Import other platforms'
.icsfeeds every 15–30 minutes and export a per-listing feed URL. Not realtime in the socket sense, but it feeds the samecalendar.changedevent stream. - 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-IDnatively. - 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 withsub= 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-pushlibrary) for browsers. - APNs (token-based auth,
.p8key) 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:
- 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: ageo_bounding_boxfilter on ageo_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. - Static filters: guests ≤ max, bedrooms ≥ n, property type, amenities (a bitmask or
termsfilter), instant book. These are indexed fields in the search engine. - 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. - 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_daysrow withprice,blockedormin_nights. - Bookings:
daterangerows.
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:
- UI: the calendar disables booked nights. That is what the clone's
Calendardoes. - Optimistic check: the quote endpoint checks availability.
- 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:
- At booking: a PaymentIntent with
capture_method=manualfor 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. - 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.
- 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).
- Cancellations compute refunds from the policy snapshot frozen in the booking's
quoteand 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:
POST /uploadsreturns a presigned PUT to Object Storage underoriginals/{listing}/{uuid}, with a content-length limit of 25 MB and an allowed MIME list.- 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.
- 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. - The CDN serves
/{variant}/{key}with immutable caching.listing_photos.statusflips toready.
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 withquote()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 auditin 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,23P01exclusion-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
| SLO | Target | Why |
|---|---|---|
| Search API p95 latency | < 400 ms | Browsing feel; map panning |
| Listing page TTFB p75 | < 600 ms at CDN edge | SEO and LCP |
| Booking create success (excluding legit 402/409) | 99.95% / 30 days | Revenue path |
| Double bookings | 0 (hard invariant) | Trust; enforced by constraint, monitored nightly |
| Booking notification delivery to host | 99% within 60 s | Host experience |
| Payout on schedule | 99.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.
| Stage | Users | Hetzner infrastructure / month | Cost per 1K MAU |
|---|---|---|---|
| Launch | 0–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) |
| Hyperscale | 10M+ MAU | €10,442.91 | €1.04 (at 10,000,000 MAU) |
What drives the bill
- 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.
- 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.
- 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.
- 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.jswith PostgreSQL + PostGIS. Port the schema above, including theno_overlapexclusion constraint andidempotency_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: truepgBackRest 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 backupk6 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
- YouTube cloneHow I Built a YouTube Clone Using Claude Code: Viewly
- ChatGPT cloneHow I Built a ChatGPT Clone Using Claude Code: Assist
- Facebook cloneHow I Built a Facebook Clone Using Claude Code: Kinfolk
- TikTok cloneHow I Built a TikTok Clone Using Claude Code: Loop
- Snapchat cloneHow I Built a Snapchat Clone Using Claude Code: Glint







