TravelOffer: a multi-brand travel booking platform
Next.js 16.1 + MongoDB booking flow with trilingual RTL/LTR support, state-machine order flow, and Stripe payments
Overview
Moon Holidays needed a booking platform that could serve multiple brands under the group umbrella, speak three languages (English, Arabic, Hebrew) with full RTL/LTR handling, and cover every step of the travel booking flow from search to payment. TravelOffer is what I designed and built, sitting on top of the Travel Panel core platform that powers every Moon Holidays product.
What it does
End-to-end travel booking. The customer lands on /start to pick an offer, navigates /location/[from]/[to]/[slug]/[item] to explore hotels, attractions, and transportation, selects a flight at /flight/[slug], compares alternatives at /select-offer, and moves through the checkout state machine: /order/confirm → /order/payment → /order/completed. Authentication supports SMS, WhatsApp, Email, and Google OAuth. The same codebase serves every brand in the group through cookie-based brand switching and a dynamic theming system, so a new brand spins up without forking the app.
Architecture
TravelOffer uses a deliberate three-layer architecture that every feature must respect:
UI Layer (Pages & Components)
↓
Action Layer (_actions) — Thin wrappers for client access
↓
Server Layer (_server) — Validation and business logic
↓
Data Layer (_data) — Database and API access
↓
Model Layer (_models) — Mongoose schemas
↓
MongoDB Database
Each layer communicates only with adjacent layers. No cross-cutting shortcuts. The discipline comes from learning what happens in a large codebase when layers bleed into each other: every bug hides in the seams, every change ripples unpredictably, every refactor requires rewriting things twice.
Key patterns in production
- Repository pattern for data-layer access. Every Mongoose model has a repository that owns reads and writes.
- Server Actions as thin client-accessible wrappers for server-side mutations, following the Next.js 16 pattern.
- Customer status state machine:
pending → viewing → confirmed → paid. Transitions are explicit and enforced at the server layer. - Multi-brand data model: brand is a first-class concept, not a runtime config toggle. Cookie-based switching, environment-variable fallback, and configuration in
_data/brand.ts.
Next.js 16 session management
Session handling uses the Next.js 16 proxy.ts deny-by-default pattern. Routes are protected unless explicitly marked public. Authentication generates OTP codes for SMS/WhatsApp/Email flows, hashes credentials with bcrypt, integrates Google OAuth, and logs every access.
Internationalization that works
next-intl powers the i18n layer. The app ships in English, Arabic, and Hebrew. RTL and LTR are not just a CSS flip: every component has dir="auto" handling for mixed-language content, CSS truncate for text overflow direction awareness, and locale-specific routing. This is the part most multi-language projects underestimate. I ended up writing small wrapper components that imposed consistent direction handling, which saved us every time a new feature arrived.
Multi-currency at the UI level
Per-service currency handling with 30+ currency symbols. The groupServicesByCurrency() utility and GroupedCurrencyDisplay component render single or stacked price displays when an itinerary spans multiple currencies (flights in USD, hotels in THB, attractions in EUR). The fallback currency is configurable per brand.
Payments and security
Stripe for payment processing with webhook verification and idempotency. bcrypt for password hashing. DOMPurify for XSS sanitization on user-generated content. Comprehensive Zod validation at every API route. Error Boundaries with graceful custom fallback UI on all major routes.
Performance optimizations
Suspense boundaries, memoization, debounced validation, dynamic imports, image optimization via Next.js with remote CDN wildcard hostname, and a 4 MB body size limit on Server Actions for file uploads. Bundle analyzer wired into npm run analyze so every release checks its own weight.
UI details that took time to get right
Four modal transition types (fade, zoom, slide, grow) with fixed or floating close buttons. Animated modals are a detail most apps skip, but a high-friction booking flow benefits from momentum in the small interactions. Mobile-first responsive design with separate mobile and desktop layouts for complex components like flight selectors and hotel galleries.
Testing
Playwright for end-to-end testing. The booking flow has enough edge cases — expiring sessions, partial form state, payment failures, upstream API outages — that unit tests alone give false confidence.
Tech stack
Next.js 16.1.2 with App Router and Turbopack, React 19.2.3, TypeScript (strict mode), Tailwind CSS, Material-UI 7.3.7, MongoDB, Mongoose ODM, Jose (JWT), Google OAuth, Stripe (server + client SDKs), next-intl, React Hook Form, Zod, bcrypt, DOMPurify, Playwright, Pino, Framer Motion, @mui/material, @emotion/react, mui-tel-input, google-libphonenumber, react-hot-toast, react-photo-view, react-signature-canvas.
Takeaway
Multi-brand theming sounds simple on paper: swap colors and logos per tenant. In practice it touches routing, metadata, analytics, emails, payment receipts, and error pages. Getting it right meant treating the brand as a first-class concept in the data model instead of a runtime config toggle, and writing direction-aware wrappers around every component the moment we hit the first RTL bug.
For a simpler open-source starter that uses many of the same patterns (Next.js frontend, FastAPI backend, JWT auth, role-based access), see PyNextStack, which I maintain as a reference implementation you can fork and ship.
