Booking Platform

Fly Fishing Guide Booking Platform

React Router 7NestJSDirectusStripeJotaiReact EmailDocker

Replaced text-message scheduling with a cross-filtering booking wizard

The Problem

A Sacramento-area fly fishing guide had been managing his entire booking pipeline over text messages — losing track of dates, double-booking trips, and spending evenings juggling schedule threads instead of tying flies. He had no website, no calendar system, and no way for clients to understand what was even available without calling him.

The constraint was that fly fishing availability is genuinely complex: it depends on season, target species, water body, and trip type, all of which interact. A standard date-picker booking flow would either oversimplify it or require the guide to manually maintain a matrix he didn't have time for.

My Role

Solo developer, full ownership — from initial discovery conversations through architecture, implementation, deployment, and ongoing content and infrastructure support. I made all technical decisions: stack selection, data modeling, booking flow design, payment architecture, and deployment topology. The guide's input was domain expertise (what fish are where, when, and how trips actually work); everything else was mine.

The guide is non-technical — he runs a one-person operation from his truck and his phone. He had no website, no booking system, and no interest in learning software. I built around that: a CMS he can update from his phone between trips, a booking wizard that absorbs the domain complexity his clients would otherwise need a phone call to navigate, and automated emails so he never has to manually confirm a booking again. The system had to run his business without him running the system.

Approach

Monorepo with four workspaces (pnpm):

  • Site — React Router 7 with SSR, Tailwind v4, shadcn/ui, Jotai for atomic state management, Stripe Elements for payment, React Email for transactional templates, and Plausible Analytics (self-hosted, cookieless).
  • API — NestJS 11 BFF (Backend for Frontend) proxying Directus, Medusa, Twenty CRM, and Stripe. The frontend never talks to any backing service directly — credentials stay server-side, CORS is simplified, and business logic lives in one place.
  • Ecom — Medusa v2 headless commerce engine for a gear shop (rods, flies, accessories).
  • CMS — Directus 11 managing trip types, guide profiles, pages, articles, and a 16-block-type page builder the guide operates himself.

I chose the BFF pattern because the site needs to coordinate data from five services (Directus, Medusa, Stripe, Twenty CRM, Resend) and I wanted a single API surface with consistent error handling, validation, and auth. Docker Compose orchestrates PostgreSQL 16 and Redis 7 locally; production runs on Coolify (self-hosted CI/CD) across two servers (site on Hetzner for edge performance, backend services on a Nextron box).

The Booking Problem

Fly fishing trips aren't like restaurant reservations or therapist appointments. You can't just pick a date and a time slot. Availability depends on four interdependent dimensions:

  • Season — rainbow trout peaks March-May and October-November; steelhead runs December-February; American shad appears only in May-June. Each species has a different window.
  • Species — the guide covers 9 species across 14 water bodies in the Sacramento region. Not every fish lives in every river.
  • Water body — some rivers are wade-only, others support drift boats. Some have seasonal access restrictions. The Lower Sacramento is a year-round tailwater; Hat Creek is technical dry-fly water with a narrow window.
  • Trip type — wade vs. float fishing, half-day vs. full-day. Float trips require drift-boat-compatible water. Wade trips open up smaller creeks.

A standard calendar picker would either hide this complexity (forcing the guide to manually triage every booking request anyway) or present an overwhelming matrix. I needed a UI that let the user start from any dimension and have the other three narrow automatically.

Key Decisions

Jotai atomic state for the booking wizard over React context or a reducer. The cross-filtering logic requires derived state — when a user selects a month, available species must update; when they select a species, compatible waters must update; when they select wade or float, waters filter again. Jotai's derived atoms (filtersAtom) compute these intersections reactively without manual dependency tracking. I considered useReducer but the cascading derivations would have meant a lot of imperative logic. Context would have caused unnecessary re-renders across unrelated wizard steps. Jotai gave me fine-grained reactivity with no persistence overhead — each booking session starts fresh.

Server-side price calculation, not client-submitted. The frontend displays prices for transparency, but the create-payment-intent endpoint independently calculates the correct price from trip type and duration. The client never sends a price to the server. This is a simple decision but an important one — it means a modified client can't manipulate the payment amount. The deposit percentage (DEPOSIT_PERCENTAGE = 0.2) is a server-side constant, not a Stripe configuration.

Normalized relational data in Directus over flat JSON. The original trip data was a flat JSON blob with string arrays for species and waters. I normalized it into three Directus collections (fish_species, waters, fish_water_availability junction) with M2M relations to trips. This let the guide add a new water body or species without touching code, and it gave the booking wizard a proper data model to query against. The alternative was hardcoding the availability matrix in the frontend, which would have been faster to build but impossible for the guide to maintain.

20% deposit at booking, remainder day-of. This was the guide's existing business model and I kept it. Stripe creates a PaymentIntent for the deposit only. The remaining balance is collected in person. I considered full prepayment (simpler) but the guide's clients expect to pay day-of and the guide didn't want to handle refund logistics for weather cancellations through Stripe.

"Guide's Pick" as a filter escape hatch. Both the fish and water selection steps include a "Guide's Pick" option that breaks the filter chain. When selected, it tells the guide to choose based on real-time conditions (water levels, hatch reports, weather). This was essential because the most experienced clients don't want to constrain the guide — they trust his judgment. Without it, every booking would force a specificity the domain doesn't require.

What Was Hard

Modeling the availability matrix accurately. The fish-water-season relationships aren't simple lookups. Rainbow trout on the Lower Sacramento is a year-round tailwater fishery, but rainbow trout on the Upper Sacramento is a seasonal freestone fishery with a completely different window. The same species on different water has different availability. I had to model availability at the junction level (fish + water + month), not at the species level. This tripled the data entry for the guide, so I pre-seeded the matrix from his existing knowledge and built the Directus UI to make updates manageable.

Making the wizard feel simple despite the complexity. Eight steps sounds like a lot. Early versions felt like a form interrogation. I iterated on the step ordering (month first, then party size, then trip type, then species/water) so that the heaviest filtering happens after the user has already committed to the easy choices. The "Guide's Pick" escape on species and water means most casual bookers only make 4 real decisions. Power users who know exactly what they want can be specific.

Deploying across two servers. The site runs on Hetzner (better edge performance for a consumer-facing app) while the API, CMS, ecom, and database run on a separate Nextron box. Coolify (self-hosted CI/CD) manages both, but coordinating Docker Compose deployments across two hosts with different production compose files required careful environment separation. Each service has its own docker-compose.production.yml and its own Coolify resource. I'd consolidate to one server if I were starting over — the performance gain wasn't worth the operational complexity for a site this size.

The Result

The platform is live at fatherandsonflyfishing.com. The guide manages all content — trip descriptions, seasonal articles, guide profiles, photo galleries, FAQs — through Directus without touching code. Bookings flow through the wizard with Stripe deposit collection and automated email confirmations to both the client (with trip details, deposit receipt, what-to-bring checklist) and the guide (with client contact info and preferences).

The guide stopped losing bookings to forgotten text threads. Clients can see what's actually available for their dates without calling first. The cross-filtering wizard handles the domain complexity that would otherwise require a phone conversation — season, species, water, trip type all narrow in real time. SEO infrastructure is in place (SSR, structured data, sitemap, meta tags) with a content strategy built around fishing reports and location-specific articles to drive organic traffic over time.

Current status: production, ongoing maintenance and content support. The ecom shop (Medusa) and Google Calendar sync are in the backlog but not yet live.

Want to work together?

I'm looking for a full-stack engineering role. Let's talk.

Get in touch