Inside Travel-Booking-Web-Application-MERN: One Booking Model, Two Travel Worlds

A clean MERN clone that uses polymorphic Mongoose references, role-based middleware, and a thin React client to make flights and hotels feel like one system.

7-8 min read • View on GitHub • More from DesuRaghavendra

A split travel ledger on a white desk. On the left are flight tickets, boarding passes, and a departure board tab. On the right are hotel keycards, room tags, and a front-desk register. In the center sits one stamped Booking folder with two arrows branching to both sides. It explains how one data model can power two different travel products.
One booking record, two inventory worlds. The repo’s architecture turns that into a simple mental model before any code appears.
Key Takeaways

The Clever Part Is the Data Model

Most travel apps begin with the surface: search, filter, book, repeat. This repo is more interesting because the real decision happens underneath the UI. It treats flights and hotels as different inventory types, but only one kind of booking.

The center of gravity is the Mongoose refPath setup in Booking.js. One itemId field can point to either a Flight or a Hotel document, depending on itemType. That means the application does not need separate booking collections, separate booking controllers, or separate history pages just to preserve domain differences.

A single booking record carries just enough metadata for MongoDB to resolve the right collection later.

ApproachData model complexityController complexityAdmin maintenanceExtensibilityRisk of duplication
Separate FlightBooking and HotelBooking collectionsHighHighTwo admin flows to maintainLowHigh
One Booking collection with refPathLowLowOne shared booking layerHighLow
A close-up of a single paper booking tag with two interchangeable hooks. One hook snaps onto a tiny airplane icon, the other onto a hotel bed icon. Above it, a hand-written refPath label points to the switching mechanism. It explains how one booking document can attach to different inventory types without separate schemas.
This is the technical trick in one object. The same booking record can resolve to different collections based on its type.

A Travel App With Two Audiences

The repo is really two products sharing one backend contract. For travelers, it is a place to browse flights and hotels, search with a little fuzziness, and submit bookings. For admins, it is an inventory console that creates, edits, and deletes the same entities the user sees.

AudiencePrimary jobVisible surfaceHidden dependency
TravelerSearch and book tripsFlights and hotel listingsAuth, booking writes, booking history
AdminManage inventory and bookingsAdmin dashboard and formsRBAC, CRUD routes, shared models
// server/models/Booking.js
const bookingSchema = new mongoose.Schema({
  itemType: { type: String, required: true, enum: ['Flight', 'Hotel'] },
  itemId: {
    type: mongoose.Schema.Types.ObjectId,
    required: true,
    refPath: 'itemType'
  }
});

// later
Booking.find({ userId }).populate('itemId');

That last line is the elegance test. A single populate() call can bring back the right document type because the schema already told Mongoose where to look. The code stays small. The mental model stays coherent.

Why RBAC Matters More Than It Looks

The security layer is doing architectural work, not just gatekeeping. authMiddleware verifies the JWT and attaches the user. roleMiddleware decides whether the request can mutate inventory. Public reads stay public. Admin writes stay fenced off.

// server/middleware/authMiddleware.js
const authMiddleware = async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ message: 'No token' });
  req.user = jwt.verify(token, process.env.JWT_SECRET);
  next();
};

const roleMiddleware = (...roles) => (req, res, next) => {
  if (!roles.includes(req.user.role)) {
    return res.status(403).json({ message: 'Forbidden' });
  }
  next();
};

That split is what makes the rest of the app feel safe. Without it, the shared booking model would be easy to read and hard to trust.

The Frontend Stays Thin on Purpose

The React side does not try to become a second source of truth. Axios interceptors inject the JWT automatically, so individual components do not need to know how auth headers work. React Context carries user state across the app without dragging in Redux-level ceremony.

// client/src/services/api.js
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

That choice keeps the client readable. The pages can focus on rendering forms, tabs, and booking summaries instead of rebuilding networking logic in every view.

The Admin Dashboard Is the Pressure Test

If the abstraction is real, it survives the admin dashboard. If it is fake, this is where it breaks. AdminDashboard.jsx has to switch between flights, hotels, and bookings while still feeling like one interface.

TraitWhat it buysWhat it costs
Single admin surfaceLess context switchingMore conditional rendering
Reusable item formFewer duplicate formsMore branching inside one component
Unified booking viewCleaner oversightA bigger component to reason about

The tradeoff is obvious and acceptable. Yes, it can become a God Component. But it is also a practical proof that the data model can hold the product together.

Why This Feels Like a Modern Boilerplate, Not a Tutorial Fossil

The stack choices are pragmatic. Vite replaces older bootstrap defaults. React Context stands in for heavier global state. JWTs live in localStorage for a consumer-friendly session experience. Search uses regex matching to feel forgiving without introducing another service.

ChoiceWhy it matters
ViteFast feedback loops and a modern starter experience
Context over ReduxLess boilerplate for a small-to-mid scale app
JWT in localStorageSimple persistent auth for a booking flow
Regex searchGood-enough fuzzy discovery without extra infrastructure

None of that is flashy. That is the point. The repo wants to be easy to clone, easy to understand, and easy to extend.

What This Repo Is Really For

This is a strong reference implementation and portfolio piece. It is not pretending to be a production travel platform with payments, real inventory feeds, or enterprise-grade search. Its value is narrower and more useful: it shows how far a disciplined MERN stack can go when one smart data model keeps the whole app aligned.

A full stack travel booking web application built using MERN stack.

DesuRaghavendra, Project Creator and Maintainer · GitHub Repository Description