Work → Case study
Lunazul Villa & Cottage
Two vacation rentals on Bordeaux Mountain, above Coral Bay in St. John, USVI. One site, one header toggle, and a calendar that can't show a booked week as open.
What it had to do
Two separate homes, next door to each other, rented separately. Most rental sites solve that by building two sites or by making the visitor pick from a menu before they see anything. Neither is good. A guest looking at the Villa should be one tap from seeing the Cottage, with the photos, the calendar, the rates, and the booking form all following them across.
On top of that, the owners live between St. John and southern Utah and list on Airbnb. Two things follow from that. The site can never contradict the Airbnb calendar, and the owners need to change their own rates and content without emailing me first.
What I built
A static site with no build step, served off Cloudflare's edge, plus a single worker.js handling nine API routes. Every page is plain HTML you can open in a browser. The Worker only wakes up for /api/*.
A single PROPERTIES object drives the property switcher. Flipping the toggle re-renders the hero image, the photo stacks, the calendar, the reviews, the rate card, the estimator, and the booking form's default. The choice is written into the URL as ?home=villa or ?home=cottage, so a link someone texts a friend opens on the right home, and the selection carries onto the gallery and the island guide.
The hard parts
Airbnb's calendar can't be read from the browser
Airbnb publishes availability as an iCal feed, and it sends no CORS headers, so the browser can't fetch it. The Worker does it at the edge instead and returns same-origin JSON. It takes a comma-separated list of feed URLs per home, so an Airbnb feed and a VRBO feed merge into one view.
Airbnb refreshes those exports a few times a day, so hammering them on every page view is pointless. The response is cached at the edge for an hour.
The checkout day is not a booked night
In an iCal feed, DTEND is exclusive. It's the morning the guest leaves, not their last night. Read it literally and the site shows every stay ending a day late, which means turning away a guest for a night that's actually free. Occupied nights run from DTSTART to DTEND minus one.
That leads straight into the more interesting problem. When one guest checks out on a morning and another checks in that afternoon, that day is half booked and half free. Painting it solid red is wrong and costs the owners a night. So the calendar draws changeover days as a diagonal split, and the merge step only combines genuinely overlapping ranges, the same dates reported by both Airbnb and VRBO. Back-to-back bookings stay separate on purpose, because merging them would erase the changeover tile.
Dates that refuse to behave
Calendar bugs are almost always timezone bugs. Two rules fixed it. On the front end, dates get built from local year, month, and day components, never from toISOString(), which is UTC and slides everything a cell sideways for half the world. On the server, "today" is computed in Atlantic Standard Time, which is UTC minus four with no daylight saving, because that's where the houses are. That's what decides whether a stay is still upcoming and whether a seasonal rate has expired.
Rates that change with the season, edited by the owners
Nightly rates aren't one number. They move with the season, and the owners are the ones who know when. Pricing lives in a Supabase table as dated ranges, and the owner dashboard writes the complete desired state back rather than patching individual rows, which is much harder to get subtly wrong. Ranges that have fallen into the past clean themselves up, and only when something newly expires, so a read doesn't trigger a write every time someone loads the page.
The same pattern covers reviews, island recommendations, the packing list, and the map pin. Each one is public-read and filtered, owner-write and key-gated. It gives a non-technical owner real control with no CMS involved.
Booking requests that don't get lost
A booking request stores to Supabase first, then tries to email the owners. That order matters. The email is wrapped so any failure gets logged and swallowed, and the request still succeeds. A notification problem should never turn into a lost booking.
The form has two spam gates and no captcha. There's a honeypot field positioned off-screen that a real person never sees, and a minimum fill time stamped when the page renders. Anything submitted in under three seconds is a bot. Both gates fake-accept, so whatever is on the other end learns nothing from the response.
Every feature degrades instead of breaking
Each integration checks for its own credentials and falls back to labelled sample data if they're missing. No calendar feed and you get sample bookings generated relative to today, marked as a demo, so it never looks stale. No database and the booking form says plainly that nothing was stored. That's how the site was pitchable and deployable before a single credential existed, and how each feature got turned on one at a time without touching anything else.
Where it stands
Live, with real Airbnb sync running and the owners editing their own pricing and content. Deploys run through GitHub Actions on every push to main.
Two things I'd call demo-grade rather than finished. The owner dashboard is gated by a key in a query string, which is fine for the two people using it but wants Cloudflare Access for anything more serious. And the spam gates are cheap by design; if real spam ever shows up, Turnstile goes in.
Renting out a place of your own?
If you're paying a platform 15 percent and still can't change your own rates, that's worth a conversation.
Start a project