Case study
Onta Trips: one codebase for travellers and travel agencies
A production travel platform where consumers search and book flights, and agencies manage bookings, staff and clients from a dashboard, both served by a single Next.js application.
- Role
- Lead Frontend Developer
- Timeframe
- Dec 2025 – Present
- Product
- B2C booking site + B2B agency dashboard
- Stack
- Next.js 16, React 19, TypeScript, Tailwind CSS v4
My part
I lead the frontend on Onta Trips, an independent engagement alongside my role at Gurzu. I built the B2C and B2B agency booking experience from a single shared Next.js codebase with multi-tenant configuration, using App Router route groups, JWT-based role access and feature-level permission gating.
Within that architecture I own the flight booking workflow end to end: search, fare selection, reservation and ticketing.
One app, two portals
Consumers and agencies use the same Next.js App Router codebase. B2C users book for themselves. B2B accounts have sub-roles (admin, agent and client) with different levels of access to the agency dashboard.
Sharing one codebase means shared types, services and UI, with the tenant type deciding which shell, navigation and permissions apply.
Search and booking flows
Flight search and booking are form-heavy: passenger details, itineraries and validation rules that change with the trip. Forms are built with React Hook Form and Zod schemas, so validation is declarative and typed end to end. Cross-step state lives in Zustand stores.
Authentication without exposed tokens
The JWT is stored in an HttpOnly cookie and is never kept in localStorage or anywhere else client-side JavaScript can read. Cookies are set and cleared on the server, and the client asks a session endpoint for its auth state.
Every API call goes through a Next.js route handler that reads the cookie and adds the Bearer header before forwarding to the backend. Middleware only does coarse redirects; access checks happen in the app shell.
Data fetching and caching
Server data is managed with TanStack Query. A shared Axios instance handles the auth header and converts the backend’s snake_case responses to camelCase, so components only ever see one naming convention.
I resolved server-state issues involving request deduplication, cache invalidation and refetch correctness across the shared multi-tenant codebase.
Centralised permissions
B2B page access is defined in a single page-access map and checked in the dashboard shell, not with guards scattered across pages. Adding a route means adding one entry. In-page controls use a usePermissions() hook to show or hide actions based on full or view-only access.
Uploads, realtime and errors
File uploads use presigned URLs and go straight to Cloudflare R2, bypassing the backend. Notifications arrive in real time over ActionCable, and errors are reported to Sentry.
Conventions and code review
The codebase follows a strict layering: types, services, hooks, components, pages. Components never call services directly. Husky and lint-staged run ESLint, Prettier and the TypeScript compiler on every commit, with zero lint warnings allowed, and shared components are documented in Storybook.