What I built
Off the Grid is a full-stack booking system I built for a Singapore-based travel company that specialises in unique expeditions across Asia. The platform handles everything from browsing trips and filling out booking forms to processing payments, sending confirmation emails, and giving the company an internal admin tool to manage their offerings.
The stack ended up being Next.js and TypeScript on the frontend, Supabase for the database, file storage, and edge functions, Stripe for payments, Clerk for authentication on the admin side, Resend for transactional emails, PostHog for analytics, and Vercel for deployment.
This post walks through how I actually built it, including the parts that took me longer than I expected.
Starting with the requirements
Before writing any code, I sat down with the client to figure out what the system actually needed to do. The most important thing I learned early on was that they didn't run one type of trip — they ran several, and each one had slightly different booking requirements. Some needed group sizes, some had specific dietary or fitness questions, others had add-on options. A single generic booking form was not going to cut it.
This shaped pretty much every decision that followed. I knew I needed a flexible form journey that could branch based on the trip type, a data model that could accommodate different field shapes, and a UI that still felt cohesive across all of them.
Designing the website flow
I started with the customer-facing flow because that was the part that needed to feel right. The journey is roughly: browse trips, view a trip detail page, start a booking, fill out a multi-step form, pay via Stripe, and receive a confirmation.
For the form journey, I used React Hook Form together with Zod for schema validation, and shadcn/ui for the components. This combination is genuinely a joy to work with — Zod gives you typed schemas that match your form, React Hook Form handles all the state and validation logic without unnecessary re-renders, and shadcn gives you accessible, well-built components that you actually own and can customise.
For data fetching on the client, I used SWR. It was quick to drop in, gave me caching and revalidation out of the box, and handled the loading and error states cleanly enough for what I needed at the time.
I iterated on the design as I went rather than trying to nail it perfectly upfront. Some flows looked good in Figma and felt awkward in the browser, so I adjusted them once I could actually click through them.
Setting up Supabase
Once the UI flow was in a reasonable place, I moved on to the backend. I created a Supabase project for both the database and file storage (trip images, mostly), and decided to try out Supabase Edge Functions for the API layer. I'd been wanting an excuse to use them and this project was a good fit.
For the data models, I built them out using the Supabase UI initially. This let me iterate on the schema quickly while I was still figuring out the shape of things — adding columns, changing types, dropping tables I didn't end up needing. Once the schema stabilised, I moved everything into proper migrations so the database state was version-controlled and reproducible.
I then hooked the UI flow up to the edge functions. The customer app talks to the edge functions for everything: fetching trips, creating bookings, validating promo codes, kicking off the payment flow.
Integrating Stripe (the part I underestimated)
Stripe was the part I was most nervous about because it was my first time using it properly. The docs are comprehensive but that's almost the problem — there are several different ways to take a payment, and figuring out which one fit my use case took longer than the actual implementation.
After reading through the options, I went with Stripe Checkout sessions for the redirect-based flow, which kept the integration simple and offloaded the PCI compliance heavy lifting to Stripe.
The flow I ended up with looks like this:
- As the user fills out the booking form, the data is saved to Supabase as a pending booking, so nothing is lost if they drop off.
- When they hit pay, the edge function takes that booking data, calculates the final amount (including any promo code discounts), and creates a Stripe Checkout session with the line items and metadata pointing back to the booking.
- The user gets redirected to Stripe's hosted checkout page, completes payment, and gets redirected back to a success page.
- A Stripe webhook fires on successful payment, which the edge function listens for. This is where the actual confirmation work happens.
On a successful payment, the webhook handler does three things: sends a confirmation email to the user via Resend, sends a notification email to the admin team, and updates the booking row in the database to mark it as confirmed.
Doing the confirmation work in the webhook (rather than the success page) is important because the user might close their browser before the redirect lands, but the webhook will fire regardless. This was something I learned the hard way after a couple of test bookings where the success redirect failed but the payment had gone through.
Building the admin app
Once the customer flow was solid, I built a separate admin app for the travel company themselves. This is where they create new trips, edit existing ones, manage prices, upload imagery, and view incoming bookings.
For authentication I used Clerk, with the access list locked down to only specific email addresses. Clerk made this part trivial — adding gated access took maybe an hour from start to finish, including the middleware to protect the admin routes.
The admin app talks to the same edge functions and the same database tables as the customer app. Sharing the backend was the right call: it meant a single source of truth for trips, bookings, and pricing, and it meant any business logic only had to be written once.
One feature the admin team specifically wanted was promo codes. I built this so that codes created in the admin app are pushed into Stripe as Coupons and Promotion Codes, then linked to specific trips on our side. When a customer enters a code at checkout, the edge function validates it against the trip and applies the discount through Stripe, so the totals always match what Stripe charges.
Emails, analytics, deployment
For transactional emails I used Resend, which has a clean API and pleasant React-based templates. Both the customer confirmation and the admin notification go through Resend, triggered from the same webhook handler.
For analytics I added PostHog. I'm tracking the funnel from trip page view through to booking completion, which has already surfaced a few drop-off points worth investigating. The plan is to use these insights to iterate on the form flow over the coming months — small tweaks to wording, field order, and the like — and actually measure whether they improve conversion rather than guessing.
Deployment is on Vercel, which pairs nicely with Next.js. Preview deployments on every PR made it easy to share work with the client during the build.
What I'd do differently
Migrations from day one
If I were starting over, I'd set up the Supabase migrations from day one rather than building in the UI first. The convenience of clicking through the schema upfront wasn't worth the small amount of cleanup I had to do later. I'd also probably write the Stripe webhook handler before the success page next time, since the webhook is the real source of truth and building it first would have saved me some debugging.
A different API layer
The bigger architectural change I'd make is the API layer. Supabase Edge Functions run on Deno, which is great in isolation but introduced friction every time I wanted to share code or types with the Next.js side, or pull in an npm package that hadn't been ported. Next time I'd either spin up a dedicated Node backend or just lean on Next.js route handlers and server actions. Less context-switching, one runtime, and the type safety carries through end-to-end.
Better caching and invalidation
I'd also invest more in caching from the start. This is a media-heavy site — trip galleries, hero imagery, video embeds — and while Vercel handles a lot of this automatically, I didn't put enough thought into cache invalidation when admins update trips. A proper strategy using revalidateTag with tagged fetches, paired with on-demand revalidation triggered from the admin app whenever content changes, would have been cleaner than what I have now.
TanStack Query over SWR
SWR did the job, but if I were rebuilding the data layer I'd reach for TanStack Query instead. It's more powerful where it matters — better support for mutations and optimistic updates, query invalidation that pairs nicely with the caching strategy above, and devtools that make debugging fetches genuinely pleasant. SWR is great for simple cases, but as soon as you start needing coordinated invalidations across views, TanStack pays for itself.
Server components where they belong
I leaned on client components more than I should have. Some of that was habit, some of it was wanting interactivity that didn't actually need to be client-side. Next time I'd be more deliberate about pushing data fetching and static rendering into server components, and only dropping into client components for the genuinely interactive bits like the form journey or anything that needs hooks. The result is less JavaScript shipped to the browser, faster first loads, and better Core Web Vitals — all of which matter more on a media-heavy site.
A real sitemap
I also didn't set up a proper sitemap, which is an obvious miss for SEO. Next.js makes this trivial with the sitemap.ts convention — you generate the entries dynamically from your trips and any blog posts, and search engines have a clean map of everything worth indexing. Pair that with a robots.ts file and you've covered the basics that should never have been skipped in the first place.
SEO, AEO, and a blog
The other area I'd push harder on is SEO and discoverability. The basics are there, but I'd add proper JSON-LD structured data (Trip and Product schemas for each expedition, Organization schema for the company itself, BreadcrumbList for navigation) so search engines and AI answer engines have a richer understanding of what's on each page. Linking out to the company's Instagram and other social profiles via sameAs in the Organization schema also helps consolidate their entity graph. And I'd add a blog that the admin team can update themselves — partly for SEO, partly because expedition companies have great stories to tell, and a steady stream of content gives them a reason for people to come back to the site between trips.
Wrapping up
Other than that, I'm happy with how this one came together. The combination of Stripe Checkout, a shared backend between customer and admin apps, and a flexible form journey kept the architecture simple and the feature surface manageable for a project of this size.
