← Case Studies
Crystal Lake Resort icon

Crystal Lake Resort

A real client - a Michigan lakefront resort with motel rooms and private weekly cottages, live and taking bookings on a platform I designed and built solo. Stripe deposit checkout, automated emails, and a no-code admin dashboard - built to eliminate OTA commissions entirely.

Node.js · Express · Supabase · Stripe · Resend · Vercel · Live client
Role
Solo build
Status
Live — 60+ reservations across 15+ rooms through fall 2026
Key result
$1,935 in under a week at 0% commission
Onboarding
New property = 1 database row

The Problem

Boutique hotels and inns pay 15–25% commission on every booking made through Booking.com, Expedia, or Airbnb - platforms that own the guest relationship and push the property to the bottom of results whenever a larger hotel bids higher. Dedicated hotel software like Cloudbeds or Little Hotelier runs $150–400/month and is designed for large properties, not independent boutique owners. The goal was a direct booking platform the owner controls outright, with no per-booking commission and no ongoing platform fee.

What the Real Client Changed

Several assumptions from the spec phase didn't survive contact with how Crystal Lake Resort actually operates.

Rooms aren't all booked the same way. The spec modeled everything as nightly stays - pick a check-in date, pick a check-out date, book. Crystal Lake Resort runs two fundamentally different accommodation types: motel rooms that guests book nightly, and private cottages that rent by the week only. A single booking model couldn't express both. I extended the room configuration to support a per-room booking cadence - nightly or weekly - with the booking form adapting its date picker and minimum-stay enforcement based on whichever type the guest selects. Cottages surface a week-at-a-time calendar; motel rooms show the standard night-by-night picker.

Seasonal context matters to the guest. The spec treated every date the same - a room was available or it wasn't. Crystal Lake Resort's business is fundamentally seasonal; guests aren't just booking a room, they're booking their summer. The hero, the content, and the booking flow needed to speak to that context directly. The owner controls the hero copy, the promotional banner, and all site content from the admin dashboard - no code change, no developer call - so they can update messaging as the season progresses.

Pricing is room-specific, not property-wide. The spec used a single nightly rate per room. Crystal Lake Resort's actual pricing is more granular: lake view rooms carry a premium, rooms without a lake view sit lower, and rates drop Sun–Wed across the board. The motel and cottages each carry their own ranges — not a percentage multiplier but specific dollar increments per unit. I updated the pricing engine to support per-room rates with day-of-week adjustments in dollar terms rather than percentages, giving the owner precise control without having to think in fractions.

Peak season changes how guests book. During the busiest stretch — late June through mid-August — availability is tight enough that the owner manages motel rooms by phone rather than running the booking form unattended. After August 15, cottages shift to a 2-night minimum instead of weekly-only. The booking flow had to reflect these rules without confusing guests who hit the site outside peak season.

Discovery caught things the spec missed. Frankfort — the nearest town — is 7 miles away, not 20 as initially documented. Room 9 doesn't have a lake view. Check-in is at 3pm. None of these would have surfaced from a brief — they came from sitting with the owner and going through the property room by room. Getting these right matters because guests arriving with wrong expectations is a worse outcome than not taking the booking at all.

Try It

crystallakeresortmi.com

Live production site - active and taking bookings. Open full screen ↗

You're on mobile - open the live site ↗. Any display issues in the embed are caused by the iframe, not the site itself.

Admin Dashboard

The owner manages every aspect of the property from a protected dashboard. No code, no developer on call - every change reflects on the live site immediately. Live read-only demo below - no login needed.

Full admin feature list

Reservations

  • Booking history - guest name, room, dates, status, and special requests; 15 most recent by default with a "Show older" control
  • Returning guest badge - automatically flags any guest whose email appears on 2+ confirmed stays
  • Search - find any reservation by guest name, email, or phone number; filter by status
  • Resend confirmation - re-send the booking confirmation email to any guest in one click
  • Status management - mark bookings confirmed, completed, cancelled, or no-show
  • Message guest - send a direct email to any guest from within the dashboard
  • Section nav - sticky jump bar links directly to Today, Calendar, Reservations, Record Booking, Alerts, and all settings sections

Overview & Revenue

  • Monthly stats - bookings, revenue, occupancy rate, and average nightly rate for the current month
  • Revenue by room - all-time bar chart showing which rooms generate the most revenue
  • Deposits & Balances - per-booking deposit status; mark balance paid at check-in with one click

Property & Settings

  • Room listings - add, edit, and delete rooms with name, slug, price, and description
  • Minimum stay - per-room minimum night requirement enforced at booking before payment
  • Booking alerts - email notification on every new reservation, visible in the main dashboard
  • Settings collapsed by default - Rooms, Pricing, Emails, and Integrations grouped under one accordion so daily-use controls stay front and center

Desktop

crystallakeresortmi.com/admin

Open full screen ↗

Room Listings

Each suite has its own page with photos, description, pricing, and a direct booking button. Guests arriving from search land on a dedicated room page and can book in one click — the booking form pre-selects their room automatically. The owner manages all room listings from the admin dashboard without touching code.

Room listings - individual suite pages with photos and booking

Availability Calendar

The admin includes a tape-chart style occupancy grid — every room across every night of the month in a single view. Navigate by month to spot gaps, identify fully-booked nights, and block dates for maintenance or external bookings. No spreadsheet, no third-party channel manager.

Admin availability calendar - tape-chart occupancy grid by room and date

After launch, the client flagged that the grid was hard to read — the day columns and room rows didn't have enough visual separation to track easily. I made three targeted changes: darkened cell borders from near-invisible #f0eeee to distinct values (columns lighter, rows heavier, so the eye tracks horizontally first); added alternating row shading so each room stripes consistently across the full horizontal scroll; and added a position tooltip to every cell — including empty ones — so hovering any cell shows "Room Name · Wed, Aug 26" instead of nothing. Small change, immediate clarity improvement. Shipping is not finishing.

Calendar hover tooltip showing room name and date for any cell

The calendar also doubles as a booking entry point. Clicking any cell opens a form to log a reservation directly — room and date pre-filled from the cell — so the owner can record phone-in bookings or OTA reservations from Expedia or Hotels.com without leaving the occupancy view. The grid stays the source of truth: book it here, it blocks immediately.

Create booking form opened from calendar cell with room and date pre-filled

Booking Confirmation

After payment, the confirmation page fetches live booking details using the Stripe session ID and displays the confirmed room, dates, and total paid — no polling, no waiting. Guests also receive a confirmation email immediately with a cancel link tied to their unique reservation token.

Booking confirmation page showing room, dates, nights, and total paid

Reviews

Live Google and TripAdvisor review widgets pulled directly onto the homepage — guests see real ratings without leaving the site, and the owner doesn't maintain anything manually.

Google and TripAdvisor review widgets on the homepage

Explore the Inn

A clear navigation section that gives first-time visitors an immediate path forward — browse rooms, visit The Dining Room (open to guests and the public), or learn about the property. Designed so a guest can orient themselves and get to booking without hunting through the nav.

Explore section - rooms, menu, and about the inn

FAQ

Three organized sections — Booking, Arrival, and Food — covering the questions guests actually ask before they show up. Structured around the guest's journey rather than a generic catch-all list.

FAQ - Booking, Arrival, and Food sections

Find Us

An embedded map section so guests can orient themselves before arrival — neighborhood, parking, and nearby landmarks at a glance without leaving the site.

Find Us - embedded map with inn location

Explore the Area

A Google Street View embed lets guests virtually walk the resort grounds and surrounding area before they arrive — click the arrows to move through the neighborhood, see the lake, and get a feel for the property. No screenshot could show this; it's interactive and live on the site.

Find Your Reservation

Guests look up any booking with just their email and check-in date - no account required.

Mobile Experience

9:41
Technical Implementation

Booking & Payment Flow

The booking confirmation flow is webhook-driven - Stripe fires a checkout.session.completed event after payment clears, which writes the reservation to Supabase and triggers the confirmation email via Resend. A failed or abandoned payment never creates a booking record. After payment, the success page fetches live booking details using the Stripe session_id from the redirect URL and displays the confirmed room, dates, nights, and total paid - no polling, no waiting. Five email flows run through Resend: booking confirmation, cancellation receipt with refund status, pre-arrival reminder sent 2 days before check-in, post-stay review request sent the day after checkout, and contact form notifications routed to the owner.

Deposits & Balance Tracking

Guests pay a $100 deposit per room at booking through Stripe Checkout. The balance owed is collected by the owner at the front desk before check-in. After checkout.session.completed fires, the webhook records the deposit_amount and payment_intent_id on the reservation row. The admin dashboard shows a Deposits & Balances view per booking - the owner marks each balance paid with one click via a PATCH /api/admin/reservations/:id/balance-paid endpoint. Reservations added manually by phone have no payment_intent_id and display "No deposit" with the full amount due at front desk. The deposit status is derived from the getDeposit() helper: uses deposit_amount if set, falls back to $100 only when a payment_intent_id is present, otherwise $0.

Custom Email Templates

Email templates (booking confirmation, pre-arrival, post-checkout) are stored as a JSON string in the tenant_settings table. The admin dashboard exposes a full editing UI for subject and body, with live preview using sample data. At send time, a shared applyEmailVars() utility substitutes all {{variable}} placeholders with real reservation data. The cron jobs and webhook handler both read from the stored template first and fall back to hardcoded defaults if no custom template is saved, so the system works out of the box without requiring any configuration.

Automated Email Scheduling

Pre-arrival reminders and post-stay review requests run on node-cron jobs inside the Express server - no external scheduler required. The pre-arrival job fires daily at 10 AM, queries all reservations checking in N days out (configurable per tenant, default 2), and sends each guest a reminder using their custom or default template. The post-stay job fires at 11 AM, finds all recent checkouts, and sends a thank-you with a review link. Both jobs are scoped per-tenant and use boolean flags (reminder_sent, review_email_sent) on each reservation row to guarantee exactly-once delivery even if the server restarts mid-run.

Guest Experience

Each suite has its own SEO-optimized URL (/rooms/coastal-suite, /rooms/garden-room, etc.) served by a single dynamic Express route. Room data, meta tags, the Open Graph image, and the canonical URL are all populated client-side from a JavaScript data map. Guests can look up any reservation at /my-booking using just their email and check-in date - no account required. A homepage availability widget accepts check-in and check-out dates and redirects to the rooms page with the dates as URL params - the rooms page reads those params, calls the availability API, and overlays an unavailability state on any booked rooms before the guest picks one. Clicking through pre-fills the booking form with both the room and the dates. A Local Guide page - filterable by category (Arts, Food, Coffee, Music, Outdoors) - gives guests curated neighborhood recommendations from the owner's perspective.

Demo Mode

A read-only demo mode makes the admin dashboard accessible from this portfolio without exposing write endpoints or requiring a login. The page detects a ?demo=true URL param and renders pre-seeded reservation and room data client-side, skipping all API calls for data loading. Every mutating action - status changes, form submissions, adding blocks, deleting rooms, saving API keys - is intercepted before it reaches the server and replaced with a toast notification. No demo session ever touches the database.

Cancellation & Refunds

Each reservation is issued a UUID cancel token at insert time. The confirmation email carries that token in a link - no guest account required, and the URL cannot be guessed or enumerated. When a guest clicks the link, the server checks the check-in date: cancellations made more than 48 hours before check-in receive a full refund of their $100 deposit, processed automatically through the Stripe API. Cancellations within 48 hours of check-in forfeit the deposit - the booking is marked cancelled and no refund is issued. The balance owed (collected at the front desk) is never charged through Stripe and is unaffected in either case.

Auth & Multi-Tenancy

The admin dashboard is protected by Supabase Auth with a rate-limited login endpoint and JWT validation on every protected route. The architecture is multi-tenant from day one - a single middleware reads an X-Tenant-Slug request header and scopes every database query to that property. When an owner saves a Stripe or Resend key via the dashboard, it syncs to both the tenant's local settings table and the shared platform API so checkout sessions and webhooks both use the client's own keys. Onboarding a second hotel means inserting a row and deploying a new Vercel project - no schema changes, no code forks.

SEO

Schema.org Hotel JSON-LD structured data on the homepage enables Google rich results. GA4 is wired to all public pages. Individual room pages have unique <title>, meta description, Open Graph, and canonical tags set dynamically per room. Verified passing in Google's Rich Results Test.

The Outcome

A direct booking platform the owner controls outright - live at crystallakeresortmi.com ↗, capturing bookings at zero commission versus 15–25% on OTA platforms. $1,935 processed through the platform in under a week of launch, with 7 bookings and 3 months of pre-existing reservations blocked to prevent double-booking from prior OTA channels. Guests pay a $100 deposit at booking, get an immediate confirmation, look up their reservation anytime, and cancel themselves. Pre-arrival reminders go out 2 days before check-in using templates the owner writes. Post-stay emails fire the morning after checkout with a direct Google review link — a flywheel that compounds the property's local search ranking with every booking the platform handles.

A non-technical client can connect Stripe and Resend, configure rooms, set lodging tax and seasonal pricing, and start taking bookings entirely through the dashboard - no developer needed after handoff. The multi-tenant architecture means white-labeling the platform for another property costs one database row and a Vercel deploy. Designed and built solo as part of the Small Business Suite.

Live Client - Crystal Lake Resort

Crystal Lake Resort
crystallakeresortmi.com

Crystal Lake Resort | Michigan

Motel rooms with lake views and private weekly cottages on Michigan's most beautiful inland lake.

QR code - scan to visit Crystal Lake Resort Scan or click to visit the live client site

Need a website for your business? I take on select freelance web projects.

Let's talk →