A production-style hotel booking platform for learning full-stack TypeScript: guests search and book with Stripe, owners manage hotels with Cloudinary images, and everyone can explore a Business Insights dashboard. Built as a MERN monorepo (MongoDB · Express · React · Node) with a Vite SPA frontend.
- Frontend (live): https://hotel-mern-booking.vercel.app/
- Backend (live): https://hotel-booking-backend.arnobmahmud.com
- Security: please report vulnerabilities privately — see SECURITY.md
- What you will learn
- Features
- Tech stack & keywords
- Architecture overview
- Monorepo structure
- Prerequisites
- Environment variables (.env)
- How to run locally
- Frontend walkthrough
- Backend walkthrough
- API reference
- Authentication flow
- Booking & Stripe flow
- Components & reuse guide
- Shared types
- Testing (Playwright)
- Deployment
- Learning path (suggested)
- Troubleshooting
- Related docs
- Keywords
- Conclusion
- License
- Happy Coding!
This repository is designed as a teaching project. By reading and running it you practice:
| Topic | Where it shows up |
|---|---|
| REST API design with Express + TypeScript | hotel-booking-backend/src/routes/ |
| JWT auth + optional Google OAuth | routes/auth.ts, middleware/auth.ts |
| Mongoose schemas & relations | models/ |
| React SPA routing & layouts | App.tsx, layouts/ |
| Global state with Context + React Query | contexts/, api-client.ts |
| File uploads → Cloudinary | routes/my-hotels.ts |
| Card payments with Stripe PaymentIntents | booking routes + BookingForm |
| Tailwind + shadcn/ui-style primitives | components/ui/ |
| Monorepo shared contracts | shared/types.ts |
| Docker / Coolify + Vercel deploy | backend Dockerfile, frontend vercel.json |
- Browse featured hotels on the home page
- Search by destination, dates, guests, price, stars, types, facilities
- Open hotel detail pages with images and amenities
- Register / sign in (email+password or Google)
- Book rooms with Stripe card payment (GBP)
- View My Bookings
- Add / edit hotels (multipart form, up to 6 images)
- List My Hotels and inspect booking logs per property
- Track revenue-related totals stored on hotel/user documents
- Interactive Swagger API docs (
/api-docs) - API status health UI (
/api-status) - Business Insights dashboard (
/business-insights) with Recharts - Rate limiting, Helmet, compression, Morgan logging on the API
Note:
ReviewandAnalyticsMongoose models exist for future work; live insights currently aggregate fromHotel,User, andBooking.
| Layer | Technology | Role (beginner view) |
|---|---|---|
| Database | MongoDB + Mongoose | Stores users, hotels, bookings as documents |
| API | Node.js + Express | HTTP server; routes = endpoints |
| UI | React 18 + Vite | SPA — fast HMR in development |
| Language | TypeScript | Types catch bugs before runtime |
| Styling | Tailwind CSS + Radix / shadcn-style UI | Utility CSS + accessible primitives |
| Data fetching | React Query (v3) + Axios | Cache server data; attach JWT |
| Auth | JWT + bcryptjs + optional Google OAuth | Stateless login; hashed passwords |
| Payments | Stripe | PaymentIntents; confirm then create booking |
| Media | Cloudinary + multer | Image CDN after upload |
| Charts | Recharts | Business insights graphs |
| E2E | Playwright | Browser tests in e2e-tests/ |
- JWT (JSON Web Token): signed string proving “this user is logged in.” Frontend stores it in
localStorage.session_idand sendsAuthorization: Bearer …. - CORS: browser rule; API must allow your frontend origin (
FRONTEND_URL, Vercel, etc.). - PaymentIntent: Stripe object that represents a payment attempt; booking is created only if status is
succeeded. - React Query key: string/array identity for a cached request (e.g.
validateToken). Invalidate after login/logout. - SPA rewrite: host (Vercel) serves
index.htmlfor all routes so/searchworks on refresh.
Browser (React SPA :5174)
│ HTTPS JSON + JWT Bearer
▼
Express API (:5001)
├── MongoDB Atlas
├── Stripe
├── Cloudinary
└── Google OAuth (optional)
Typical request path
- React page calls a function in
api-client.ts - Axios (
lib/api-client.ts) adds the Bearer token - Express route (+ optional
verifyToken) runs - Mongoose reads/writes MongoDB
- JSON returns → React Query caches / invalidates
Deeper A–Z notes: docs/PROJECT_WALKTHROUGH.md
hotel-booking-1/ (or your clone folder)
├── hotel-booking-backend/ # Express API
│ ├── src/
│ │ ├── index.ts # boot, middleware, mounts
│ │ ├── middleware/auth.ts # JWT verify
│ │ ├── models/ # User, Hotel, Booking, Review*, Analytics*
│ │ ├── routes/ # REST handlers
│ │ └── swagger.ts
│ ├── Dockerfile
│ ├── .env.example
│ └── .env.local.example
├── hotel-booking-frontend/ # Vite React SPA
│ ├── src/
│ │ ├── main.tsx # providers
│ │ ├── App.tsx # routes
│ │ ├── api-client.ts # named API helpers
│ │ ├── lib/api-client.ts # Axios + base URL
│ │ ├── contexts/ # App + Search
│ │ ├── pages/ # screens
│ │ ├── components/ # UI building blocks
│ │ ├── forms/ # booking + hotel forms
│ │ └── layouts/
│ ├── vercel.json
│ └── .env.local.example
├── shared/types.ts # shared TypeScript contracts
├── e2e-tests/ # Playwright
├── data/ # fixtures (test users/hotel)
├── docs/ # guides + walkthrough
├── SECURITY.md # private vulnerability reports
└── README.md # this file
- Node.js 18+ and npm
- MongoDB — local or free MongoDB Atlas cluster
- (Recommended) Stripe test keys, Cloudinary free account
- (Optional) Google Cloud OAuth client for “Continue with Google”
| Package | Required to start? | Notes |
|---|---|---|
| Backend | Yes | App exits if required vars are missing (index.ts fail-fast). Copy .env.example → .env. |
| Frontend | Mostly optional | SPA boots without a file, but set .env.local so API + Stripe point to the right places. |
Never commit real .env / .env.local files (they are gitignored). Examples are safe templates.
Copy:
cd hotel-booking-backend
cp .env.example .env
# optional local overrides:
cp .env.local.example .env.local| Variable | What it is | How to get it |
|---|---|---|
MONGODB_CONNECTION_STRING |
Mongo connection URI | Atlas → Connect → Drivers → copy URI; replace password |
JWT_SECRET_KEY |
Secret to sign JWTs | openssl rand -base64 64 (long random string) |
CLOUDINARY_CLOUD_NAME |
Cloudinary cloud name | cloudinary.com dashboard |
CLOUDINARY_API_KEY |
Cloudinary API key | same dashboard |
CLOUDINARY_API_SECRET |
Cloudinary API secret | same dashboard |
STRIPE_API_KEY |
Stripe secret key | dashboard.stripe.com/apikeys → sk_test_… |
| Variable | Purpose | Local example |
|---|---|---|
PORT |
API port | 5001 (macOS often blocks 5000 via AirPlay) |
NODE_ENV |
development / production |
development |
FRONTEND_URL |
CORS + OAuth redirects | http://localhost:5174 |
BACKEND_URL |
Public API origin for Google redirect_uri |
local: omit (defaults to http://localhost:PORT) |
GOOGLE_ID |
Google OAuth client id | Google Cloud Console → Credentials |
GOOGLE_SECRET |
Google OAuth client secret | same |
Google redirect URI to register (when using Google sign-in locally):
http://localhost:5001/api/auth/callback/google
Production example: {BACKEND_URL}/api/auth/callback/google
Copy:
cd hotel-booking-frontend
cp .env.local.example .env.local| Variable | Required? | Purpose |
|---|---|---|
VITE_API_BASE_URL |
Recommended locally | Backend origin, e.g. http://localhost:5001 |
VITE_STRIPE_PUB_KEY |
For payments | Stripe publishable key pk_test_… (used in AppContext) |
Vite only exposes variables prefixed with
VITE_to the browser. Never put secret keys (sk_…, Mongo URI, JWT secret) in frontend env.
If VITE_API_BASE_URL is unset, getApiBaseUrl() falls back by host (localhost → :5001, production defaults documented in walkthrough).
- Atlas DB created → paste URI into backend
.env - Generate
JWT_SECRET_KEY - Create Cloudinary + Stripe test accounts → paste keys
- Frontend:
VITE_API_BASE_URL=http://localhost:5001andVITE_STRIPE_PUB_KEY=pk_test_… - Start backend, then frontend
You can explore UI/API docs without Google OAuth. You need Stripe keys for real booking payments.
Use two terminals.
cd hotel-booking-backend
npm install
cp .env.example .env # then edit values
npm run dev # → http://localhost:5001Smoke checks:
- Health:
http://localhost:5001/api/health - Swagger:
http://localhost:5001/api-docs
cd hotel-booking-backend
npm run seed # destroys User/Hotel/Booking/Review/Analytics then reseedsLogin after seed: test@user.com / 12345678 (admin — opens /admin). Also: owner@hotel.com, guest@user.com (same password). This stack uses MongoDB + Mongoose, not Prisma.
Optional AI assist (draft-and-approve only): set AI_ASSIST_ENABLED=true in backend .env (optional OPENAI_API_KEY; stub drafts when unset).
cd hotel-booking-frontend
npm install
cp .env.local.example .env.local # edit VITE_* as needed
npm run dev # → http://localhost:5174cd hotel-booking-backend && npm run build
cd hotel-booking-frontend && npm run build && npm run lintQueryClientProvider
└── AppContextProvider (auth, Stripe, toasts, global loading)
└── SearchContextProvider (search criteria → sessionStorage)
└── App (React Router)
React Query caches API responses. After login/logout the app invalidates the validateToken query so isLoggedIn updates.
| Path | Page | Notes |
|---|---|---|
/ |
Home | Featured hotels |
/search |
Search | Filters + pagination |
/detail/:hotelId |
Detail | Public |
/business-insights |
AnalyticsDashboard | Charts |
/api-docs, /api-status |
Docs / health UI | Public |
/register, /sign-in |
Auth forms | AuthLayout |
/auth/callback |
OAuth return | Writes token to localStorage |
/my-bookings, /my-hotels |
Lists | API still needs JWT |
/hotel/:hotelId/booking |
Booking | UI requires login |
/add-hotel, /edit-hotel/:hotelId |
Owner forms | UI requires login |
Layouts: Layout (chrome) · AuthLayout (centered auth).
| File | Job |
|---|---|
lib/api-client.ts |
Axios instance, base URL, Bearer interceptor, 401 cleanup |
api-client.ts |
Named helpers: signIn, searchHotels, createPaymentIntent, … |
Example pattern you can reuse in other projects:
// Conceptual — see real helpers in api-client.ts
export const fetchMyHotels = async () => {
const response = await apiClient.get("/api/my-hotels");
return response.data;
};- Load
dotenv - Fail fast if required env vars missing
- Configure Cloudinary
- Connect MongoDB
- Apply middleware (Helmet, rate limit, compression, Morgan, CORS, cookies, JSON)
- Mount routes
- Listen on
PORT(default 5001)
helmet— security headersexpress-rate-limit— general/api/*+ stricter payment pathcors— allowsFRONTEND_URL, localhost Vite ports,*.vercel.app,*.netlify.appverifyToken— reads JWT fromAuthorization: Beareror cookiesession_id
| Model | File | Idea |
|---|---|---|
| User | models/user.ts |
email, hashed password, profile, totals |
| Hotel | models/hotel.ts |
owner userId, pricing, images, policies |
| Booking | models/booking.ts |
separate collection (not embedded in Hotel) |
| Review / Analytics | schemas only | future REST |
Base URL local: http://localhost:5001
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /google |
— | Start Google OAuth |
| GET | /callback/google |
— | OAuth callback → redirect frontend + token |
| POST | /login |
— | Email/password → JWT |
| GET | /validate-token |
JWT | { userId } |
| POST | /logout |
— | Clear session cookie |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /register |
— | Create user |
| GET | /me |
JWT | Current profile |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | / |
— | All hotels |
| GET | /search |
— | Filters + pagination (page size 5) |
| GET | /:id |
— | Hotel by id |
| POST | /:hotelId/bookings/payment-intent |
JWT | Stripe PaymentIntent |
| POST | /:hotelId/bookings |
JWT | Confirm booking after payment |
Search query params: destination, adultCount, childCount, facilities[], types[], stars[], maxPrice, sortOption, page
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | / |
JWT | Create + images (Cloudinary) |
| GET | / |
JWT | Owner list |
| GET | /:id |
JWT | One owned hotel |
| PUT | /:hotelId |
JWT | Update |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | / |
JWT | Guest bookings (hotel populated) |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | / |
JWT | List |
| GET | /hotel/:hotelId |
JWT | By hotel |
| GET | /:id |
JWT | By id |
| PATCH | /:id/status |
JWT | Status |
| PATCH | /:id/payment |
JWT | Payment status |
| DELETE | /:id |
JWT | Delete + adjust analytics fields |
| Method | Path | Description |
|---|---|---|
| GET | / |
Public liveness (status, timestamp, database.status only) |
| GET | /detailed |
JWT — rounded memory MB + uptime (no host/PID/Node) |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /dashboard · /dashboard/public |
JWT / — | Metrics |
| GET | /forecast · /forecast/public |
JWT / — | Forecast |
| GET | /system-stats · /system-stats/public |
JWT / — | System stats |
Interactive docs: /api-docs (Swagger UI).
POST /api/auth/loginwith{ email, password }- Backend verifies with bcrypt, returns JWT
- Frontend stores
localStorage.session_id(+ user fields) - Axios sends Bearer token on later calls
GET /api/auth/validate-tokendrivesisLoggedIn
- Browser goes to
{API}/api/auth/google - Google →
{BACKEND}/api/auth/callback/google - Backend creates/finds user, redirects to
{FRONTEND}/auth/callback?token=… AuthCallbacksaves token and invalidates React Query
POST /api/auth/logout + clear localStorage + invalidate validateToken.
Detail → /hotel/:id/booking
→ POST .../payment-intent (server creates PaymentIntent, GBP)
→ BookingForm: stripe.confirmCardPayment(clientSecret)
→ POST .../bookings (only if PaymentIntent succeeded)
→ /my-bookings
Amount ≈ pricePerNight × nights × 100 (pence). Use Stripe test cards in development.
| Component | Path | Reuse idea |
|---|---|---|
SearchBar / AdvancedSearch |
components/ |
Copy pattern: form → context → navigate with query |
Filters (PriceFilter, StarRatingFilter, …) |
components/ |
Controlled checkboxes/sliders → parent search params |
SearchResultsCard |
components/ |
Presentational card: props in, Link out |
Pagination |
components/ |
Page number UI for any list API |
MainNav / MobileNav |
components/ |
Auth-aware nav; swap brand links |
ManageHotelForm sections |
forms/ManageHotelForm/ |
Multi-section RHF form + file input |
BookingForm |
forms/BookingForm/ |
Stripe Elements wrapper pattern |
ui/* (Button, Dialog, …) |
components/ui/ |
shadcn-style primitives — drop into other Tailwind apps |
AppContext — “am I logged in?”, toasts, Stripe promise, global loading spinner.
SearchContext — persist search criteria in sessionStorage so detail/booking keep dates/guests.
// lib/api-client.ts (simplified idea)
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem("session_id");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});Use the same pattern in any Axios/React project.
queryClient.invalidateQueries("validateToken");
queryClient.refetchQueries("validateToken");shared/types.ts exports UserType, HotelType, BookingType, search/payment response shapes.
Why it matters: frontend and backend stay aligned. Docker backend build copies shared/ into the image.
import { HotelType } from "../../shared/types";# Terminals: backend + frontend already running
cd e2e-tests
npm install
npx playwright test| Spec | Covers |
|---|---|
tests/auth.spec.ts |
Sign-in / register |
tests/search-hotels.spec.ts |
Search |
tests/manage-hotels.spec.ts |
Add hotel |
Fixtures: data/test-users.json, data/test-hotel.json. Target UI: http://localhost:5174.
Spin up a cloud environment from this public repo with no local install:
- Click the button to create a Diploi deployment.
- When it finishes, open the preview URL from the Diploi dashboard.
More info: https://diploi.com/
Diploi is optional for trying the project. Production demos for this repo use Coolify + Vercel below.
- Build from repo root context so
shared/is included (seehotel-booking-backend/Dockerfile). - Set all required backend env vars in Coolify (same names as
.env.example). - Set
FRONTEND_URLto your Vercel URL; setBACKEND_URLto the public API origin. - Expose
PORTas configured in Coolify; health check:GET /api/health. - Redeploy when
mainchanges.
cd hotel-booking-backend
npm run build- Config:
hotel-booking-frontend/vercel.json(SPA rewrite →index.html) - Build:
npm run build→dist/ - Env in host dashboard:
VITE_API_BASE_URL= production API originVITE_STRIPE_PUB_KEY= live or test publishable key
cd hotel-booking-frontend
npm run build- Secrets only in host dashboards (never in git)
- CORS /
FRONTEND_URLmatch the live SPA - Google OAuth redirect URIs match production
BACKEND_URL - Stripe keys match mode (test vs live)
- SSL + health endpoint OK
- Run backend health + open Swagger
- Register a user, call
/api/users/mewith Bearer token - Trace
Searchpage →GET /api/hotels/search - Add a hotel (watch Cloudinary URLs appear)
- Complete one Stripe test booking
- Read React Query invalidation on login/logout
- Skim
docs/PROJECT_WALKTHROUGH.mdfor A–Z
| Symptom | Likely fix |
|---|---|
| Backend exits immediately | Missing required env var — check console list |
| Frontend calls wrong host | Set VITE_API_BASE_URL=http://localhost:5001 |
| CORS errors | Set backend FRONTEND_URL=http://localhost:5174 |
| Port in use / weird 5000 issues | Use PORT=5001 |
| Google OAuth fails | Register exact callback URI; set BACKEND_URL / FRONTEND_URL |
| Payments fail | Matching sk_test + pk_test; Booking page needs VITE_STRIPE_PUB_KEY |
| Images fail on create hotel | Cloudinary trio in backend .env |
| File | Topic |
|---|---|
| docs/PROJECT_WALKTHROUGH.md | Architecture A–Z |
| SECURITY.md | Private vulnerability reporting |
| docs/AUTH_UI_IMPLEMENTATION_GUIDE.md | Auth UI patterns |
| docs/DROPDOWN_TEST_CREDENTIALS_DOCS.md | Test credential dropdown |
| docs/CLERK_AUTH_COMPLETE_IMPLEMENTATION_GUIDE.md | Clerk guide (portable; not this app’s live stack) |
MERN · MongoDB · Express · React · Node.js · TypeScript · Vite · Tailwind CSS · React Query · Axios · JWT · OAuth · bcrypt · Stripe PaymentIntent · Cloudinary · multer · Mongoose · Swagger · Helmet · rate limiting · Recharts · Playwright · Vercel · Coolify · Docker · hotel booking · full-stack · open source
This project is a complete, production-flavored hotel booking codebase you can study end-to-end: typed API, JWT auth, media uploads, payments, analytics UI, and real deploy paths. Start with the env templates, run both apps locally, then follow one user journey (search → book) while reading the matching route and page files. Reuse the API-client, auth interceptor, form sections, and UI primitives in your own TypeScript React projects.
Questions or want to share what you built? Reach out via GitHub or https://www.arnobmahmud.com.
This project is licensed under the MIT License. Feel free to use, modify, and distribute the code as per the terms of the license.
This is an open-source project — feel free to use, enhance, and extend this project further!
If you have any questions or want to share your work, reach out via GitHub or my portfolio at https://www.arnobmahmud.com.















