B2C Trekking Booking Platform
A full-stack booking platform for guided treks across the Western Ghats — handling real-time availability, payments, and confirmations end to end.
A trekking company operating in Karnataka's Western Ghats had no online presence. Booking was done entirely over the phone — no availability checks, no payments, no confirmations. They were losing customers who expected to book instantly, and operators had no way to prevent double-bookings.
I built a full B2C booking platform: browse treks, check real-time slot availability per date, complete a multi-step booking form, pay via Stripe, and receive a confirmation email with a downloadable PDF receipt. Operators see bookings immediately with no manual overhead.
The trickiest part wasn't the UI — it was booking integrity. Two users could both see '3 slots left', both submit a booking at the same second, and the server could create both even though only one slot existed. I needed to solve this before writing a single component.
I also knew the user's mental model of booking a trek is different from buying a product: they're committing to a date, a group size, and sharing health/emergency info. That multi-step form isn't just UX polish — it mirrors how a real booking conversation flows.
PostgreSQL transactions (BEGIN / COMMIT / ROLLBACK) around every booking write
Prevents double-booking race conditions. The availability check and slot decrement happen inside a single atomic transaction — if either fails, the whole thing rolls back. No booking is created without a slot actually being reserved.
Trade-off
Slightly more verbose code than a simple INSERT, but the correctness guarantee is non-negotiable for a booking system.
The system splits into two clear halves. The public trek pages — listings, trail details, location guides — are Server Components using ISR with 1-hour revalidation. They're pre-rendered at build and served from Vercel's CDN edge, loading from cache in under 300ms regardless of concurrent traffic. The ISR window is long because trek content (routes, difficulty, descriptions) rarely changes mid-season.
The booking side is the opposite: fully dynamic. The booking modal is a client component because it manages 20+ fields of form state, coordinates a three-step flow, and interacts with Stripe Elements — all requiring a browser context. The availability calendar inside it reads from a Zustand store that stays live via Ably WebSocket messages. When another user completes a booking, the API broadcasts the updated slot count, the Zustand store updates, and every open calendar re-renders without a page refresh or polling.
Three API routes handle the backend: POST /api/create-payment-intent (creates the Stripe PaymentIntent), POST /api/bookings (runs the SQL transaction, generates the PDF, sends the email, broadcasts to Ably), and GET /api/generate-receipt-pdf (streams a PDFKit receipt for download). All three are Vercel serverless functions.
The database has four core tables: treks (catalogue), trek_available_dates (per-trek, per-date slot counts), bookings (one row per completed booking), and trek_details (itinerary, inclusions, FAQs). No ORM — all queries are tagged template literals via Neon's serverless driver, keeping transactions explicit and readable.
A user lands on a trek detail page served from CDN and clicks "Book Now" — the booking modal mounts as a client component. Step 1 shows a date availability calendar reading from the Zustand store, collects personal details (name, phone, nationality), emergency contact information, and basic health/fitness info. React Hook Form validates per-field, and the Continue button only enables once the current step is valid.
Step 2 mounts Stripe Elements — Stripe's card input iframe. The client sends a request to POST /api/create-payment-intent which creates a PaymentIntent for the total amount (group size × price per person) and returns a client secret. The user enters card details and clicks Pay. The client calls stripe.confirmCardPayment() with the client secret. If the card is declined, Stripe returns an error object — the message surfaces inline, the booking form data is preserved, and the user can retry without starting over.
Step 3 only renders after paymentIntent.status === "succeeded". A second API call goes to POST /api/bookings, which: re-checks slot availability inside a transaction to catch the race condition; inserts the booking row; decrements booked_slots on trek_available_dates; commits; generates a PDF receipt with PDFKit; sends a confirmation email with the PDF attached via Resend; and publishes a slot-update message to Ably. The success screen shows the booking reference and a PDF download button.
Users can look up past bookings by entering their email or booking reference on /bookings/lookup — this hits GET /api/bookings which returns booking details joined with the trek name.
Next.js 15 with App Router: the ability to mix Server Components (trek listings, SEO pages) and client components (booking modal, real-time calendar) in the same route without an API boundary was the deciding factor. ISR at the page level means trek listing pages are edge-cached with just one line of config.
Neon over managed Postgres (Supabase, Railway): I didn't want to manage a connection pool or pay for an always-on instance for a project with bursty, seasonal traffic. Neon's serverless driver handles connection branching per-request and scales to zero — ideal for a trekking site that peaks on weekends and is quiet mid-week.
Ably over DIY WebSockets: Vercel serverless functions don't hold persistent connections. Ably abstracts the entire real-time layer — connection management, reconnection, channel-based pub/sub. A slot update arrives at every connected browser in under 100ms.
Stripe over Razorpay or PayU: Stripe's Payment Intent API enforces the correct payment sequence, the test mode is comprehensive, and the Stripe.js SDK handles PCI compliance. For a project where I needed to move fast and trust the payment layer completely, Stripe was the clear choice.
Resend over Nodemailer or SendGrid: Resend has a React-based email API, handles DKIM signing and retry logic, and has a free tier that easily covers the booking volume. One function call with the PDF buffer attached, and it handles deliverability.
PDFKit over Puppeteer or jsPDF: receipts need to look identical on every device. Server-side generation with PDFKit means no browser font differences or layout shifts. The streaming API builds the PDF incrementally rather than holding it all in memory.
Zustand over React Context for slot state: the Ably store needs subscribeWithSelector middleware so individual components can subscribe to specific channel + message name combinations without re-rendering the entire tree. Zustand handles this in three lines.
Email delivery is currently synchronous inside the booking API route — after the Postgres commit, the route calls Resend and waits before returning to the client. If Resend is slow or temporarily down, the booking request blocks or fails. The fix: queue the email as a background job after commit, return the booking reference to the client immediately, and retry email delivery independently. On Vercel this would be a call to background functions or an external queue like Inngest.
The slot counter in the booking modal reads from the Zustand store, which is updated by Ably messages in real time. This works for tabs that are already open. But if you open a fresh tab after a booking has been made, the server renders the ISR-cached value — Ably only pushes deltas to connected clients, it doesn't replay history. The fix: fetch the current slot count client-side on mount to reconcile with whatever the server returned.
An admin dashboard is the most obvious missing piece. Currently the operator views bookings by querying Postgres directly. A simple read-only view showing upcoming bookings per trek, group sizes, and payment status would make the platform usable without database access.