← All posts

What I Learned Building a Lake House Booking System

  • architecture
  • postgres
  • stripe

I built a custom reservation app for a family lake house, Brookes Bayou. Guests pick dates on a live calendar, fills out a form, accept a waiver and submits a payments. The owner gets a private dashboard to fully manage reservations. The stack is Next.js (App Router) on Vercel, Supabase/Postgres, Stripe, and Resend.

It was not a straight path from idea to a working product. Planning took longer than writing the code. The business rules were the real challenge: preventing double bookings, issuing the right refund tier so payouts were correct and actually went through, and keeping every payment secure and idempotent. This blog is the approach I settled on, and the things I learned along the way.

A modular monolith, on purpose

I chose one deployable Next.js app because the work had a simple shape: one property, one product, and one developer. Splitting it into services would have added more complexity and cost than needed.

Reservations, payments, and emails are tightly coupled. Checkout has to claim the dates before Stripe runs, payment success has to turn that hold into a pending reservation, and that same moment is what emails the guest and owner. Approval, decline, and cancel later reuse those same modules. I kept them in one app so those steps can call each other in-process instead of across network boundaries.

Inside that app the modules stay distinct: booking, payments, auth, and notifications. The folders keep each concern readable. They share one deploy because the relationship between them is one business action.

Postgres was a product requirement in disguise

The core rule is that no two stays may overlap. That's a relational problem.

I have a date_holds table with an exclusion constraint on a date range:

alter table date_holds
  add constraint no_overlapping_holds
  exclude using gist (daterange(check_in, check_out) with &&);

If two overlapping holds are inserted, even at the same millisecond, the database rejects the second one. The guarantee doesn't depend on my app code being right, and it can't be skipped by a code path I forgot.

Checkout uses the same table. When a guest starts paying, the app creates a tentative hold, so the dates are claimed before any money moves. That closes the race where two people both pay for the same weekend. In a NoSQL store I'd have had to hand-build the transaction and locking logic to get the same guarantee.

Row Level Security: the public key isn't the secret

The Supabase anon key ships to the browser by design. RLS policies are the real gate, and they define your attack surface.

I learned this from a mistake. I opened a public read on the table the calendar used for unavailable dates. That table also held identifiers that were only meant to stay in private guest emails. Anyone who could query it could reach actions those identifiers were supposed to gate.

The fix was to lock that table down and serve calendar reads through the server, returning only dates. The takeaway: the public key being public is fine, but only if every exposed table assumes the caller is hostile.

Idempotency vs. deduplication

I used to treat these as the same thing. They aren't.

Idempotency means the same logical operation can run again safely. Finalizing a checkout converts a hold into a reservation. Both the browser and the webhook can call that path, and if the reservation already exists, a UNIQUE conflict is treated as "already done" and returns success. The result is never two reservations.

Deduplication means recognizing a duplicate message from an external system and not acting on it twice. Stripe can deliver the same event more than once. I use refund idempotency keys and treat UNIQUE violations as duplicates, so a repeated event doesn't cause a second side effect.

Idempotency is about safe retries of your action. Dedup is about collapsing duplicate messages from someone else's system. You need both around payments.

Securing payments without the theater

  • I never touch card data. I use Stripe's Payment Element and Checkout Sessions. My database stores only Stripe IDs: payment intents, customers, and refunds.
  • Secrets stay on the server. The secret key and webhook signing secret are server-only. The publishable key is NEXT_PUBLIC_ on purpose.
  • There are two paths to a reservation. After payment, the client calls a finalizeCheckoutAction. Stripe's checkout.session.completed webhook calls the same logic. The browser path is fast, and the webhook is the safety net for a guest who closes the tab. Because the operation is idempotent, whichever arrives first wins and the other is a no-op.

What I'd tell another builder

  1. Put invariants in the database. If a rule must never break, don't enforce it only in application code.
  2. Assume every public surface is read by an attacker, and write RLS like it.
  3. Make every payment-adjacent action safe to run twice.
  4. Pick the boring architecture. A modular monolith let me spend my time on the parts that could actually fail.

None of this is exotic. It's the unglamorous work that makes a booking app trustworthy when real money and real weekends are involved.