Project 2: Swiggy Clone — Restaurant Listing API
Scale up your API! Build a Swiggy-style restaurant listing with nested schemas, geospatial search, and populated relationships.
In Project 1, we built a simple Biryani API. Now let's scale up! Project 2 is a Swiggy Clone Backend for restaurant listing — the core feature of any food delivery app.
What we'll build:
- Restaurant schema with nested menu items (subdocument array)
- Search by pincode — find restaurants near you
- Mongoose populate — join reviews to restaurants
- Database indexing — make search lightning fast
- Filtering and sorting — by rating, cuisine, price range
New concepts introduced:
- Nested schemas — Menu items as embedded subdocuments inside Restaurant
- Database indexes — Speed up search queries with MongoDB indexes
- $near operator — Geospatial queries for "near me" functionality
- Mongoose populate — Like SQL JOINs for MongoDB references
By the end of this project, you'll have a restaurant API that can find biryani near your pincode — just like Swiggy!
For our Swiggy clone, a restaurant has menu items. We have two choices: store menu items in a SEPARATE collection (referenced) or embed them INSIDE the restaurant document (embedded/nested).
Embedded vs Referenced:
| Approach | Description | Use Case |
|---|---|---|
| Embedded | Menu items inside restaurant document | Items always loaded with restaurant, < 50 items |
| Referenced | Separate MenuItem collection with restaurantId | Items queried independently, thousands of items |
Nested schema approach (embedded subdocuments):
// models/Restaurant.js — Restaurant with Nested Menu Items
import mongoose from 'mongoose';
// Subdocument schema (embedded inside Restaurant)
const menuItemSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Menu item ka name daalo bhai!'],
trim: true
},
price: {
type: Number,
required: [true, 'Price daalo bhai!'],
min: [5, 'Itna sasta? Min Rs.5']
},
category: {
type: String,
enum: ['Biryani', 'Curry', 'Bread', 'Dessert', 'Drink', 'Starter'],
required: true
},
description: { type: String, default: '' },
isVegetarian: { type: Boolean, default: false },
isAvailable: { type: Boolean, default: true },
photoUrl: { type: String, default: '' }
});
const restaurantSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Restaurant ka name daalo bhai!'],
trim: true,
index: 'text' // Text index for full-text search
},
cuisine: [{
type: String,
enum: ['Hyderabadi', 'North Indian', 'South Indian', 'Chinese', 'Continental', 'Fast Food']
}],
address: {
street: { type: String, required: true },
city: { type: String, required: true },
pincode: {
type: String,
required: [true, 'Pincode daalo bhai!'],
match: [/^[1-9][0-9]{5}$/, 'Invalid pincode format']
},
location: {
type: { type: String, enum: ['Point'], default: 'Point' },
coordinates: { type: [Number], default: [0, 0] } // [longitude, latitude]
}
},
contact: {
phone: { type: String },
email: { type: String }
},
rating: { type: Number, default: 4.0, min: 1, max: 5 },
deliveryTime: { type: String, default: '30-40 min' },
deliveryFee: { type: Number, default: 20 },
minOrder: { type: Number, default: 100 },
isOpen: { type: Boolean, default: true },
// Embedded menu items (subdocuments)
menu: [menuItemSchema],
// Reference to reviews collection
reviews: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Review' }]
}, { timestamps: true });
// Compound index for common search queries
restaurantSchema.index({ 'address.pincode': 1, rating: -1 });
restaurantSchema.index({ 'address.location': '2dsphere' }); // Geospatial index
export default mongoose.model('Restaurant', restaurantSchema);
Why nested menu items? When a user views a restaurant page, they ALWAYS need the menu. With embedded subdocuments, Mongoose loads both the restaurant AND its menu in ONE query — no separate database call needed. This is faster and simpler than a separate collection.
Swiggy's most important feature is finding restaurants near you. We'll implement pincode-based search using MongoDB indexes.
Pincode search without index (slow — scans every document):
// ❌ SLOW — Full collection scan!
// MongoDB checks EVERY document one by one
const restaurants = await Restaurant.find({ 'address.pincode': '500001' });
// With 1 million restaurants, this checks all 1 million! 😭
Pincode search WITH index (fast):
// ✅ FAST — Uses B-tree index!
// MongoDB uses the index to jump directly to matching documents
// Add index: restaurantSchema.index({ 'address.pincode': 1 });
const restaurants = await Restaurant.find({ 'address.pincode': '500001' });
// With 1 million restaurants, this finds matches in MILLISECONDS! 🚀
Complete search controller:
// controllers/restaurant.controller.js — Search & Listing
import Restaurant from '../models/Restaurant.js';
import AppError from '../utils/AppError.js';
import asyncHandler from '../utils/asyncHandler.js';
// GET /api/restaurants?pincode=500001&cuisine=Biryani&rating=4
export const searchRestaurants = asyncHandler(async (req, res) => {
const { pincode, cuisine, rating, search, page = 1, limit = 10, sort = '-rating' } = req.query;
// Build query filter
const query = {};
// Text search on restaurant name
if (search) {
query.$text = { $search: search };
}
// Filter by pincode (with index!)
if (pincode) {
query['address.pincode'] = pincode;
}
// Filter by cuisine (array contains)
if (cuisine) {
query.cuisine = { $in: [cuisine] };
}
// Filter by minimum rating
if (rating) {
query.rating = { $gte: parseFloat(rating) };
}
// Only open restaurants
query.isOpen = true;
const restaurants = await Restaurant.find(query)
.populate('reviews') // Populate reviews
.skip((page - 1) * limit)
.limit(parseInt(limit))
.sort(sort);
const total = await Restaurant.countDocuments(query);
res.json({
success: true,
count: restaurants.length,
total,
page: parseInt(page),
totalPages: Math.ceil(total / parseInt(limit)),
data: restaurants
});
});
// GET /api/restaurants/nearby?lng=78.46&lat=17.38&maxDistance=5000
export const getNearbyRestaurants = asyncHandler(async (req, res) => {
const { lng, lat, maxDistance = 5000 } = req.query;
if (!lng || !lat) {
throw new AppError('Longitude and latitude required! Coordinates daalo bhai! 📍', 400);
}
const restaurants = await Restaurant.find({
'address.location': {
$near: {
$geometry: {
type: 'Point',
coordinates: [parseFloat(lng), parseFloat(lat)]
},
$maxDistance: parseInt(maxDistance) // In meters
}
},
isOpen: true
}).populate('reviews');
res.json({
success: true,
count: restaurants.length,
data: restaurants
});
});
How indexes work: An index is like the index of a book. Without an index, you read EVERY page to find what you need. With an index, you jump directly to the right page. MongoDB uses B-tree indexes which find data in O(log n) time — even with millions of documents!
In our Restaurant schema, reviews are stored as ObjectId references (not embedded). This is because reviews are separate documents that grow independently. We use Mongoose's .populate() to fetch the actual review data.
Review model (separate collection):
// models/Review.js — Review Schema
import mongoose from 'mongoose';
const reviewSchema = new mongoose.Schema({
restaurant: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Restaurant',
required: true
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
rating: {
type: Number,
required: true,
min: 1,
max: 5
},
comment: {
type: String,
required: [true, 'Review comment daalo bhai!'],
maxlength: [500, 'Itna lamba review? 500 chars mein samjha do!']
}
}, { timestamps: true });
// Prevent duplicate review from same user for same restaurant
reviewSchema.index({ restaurant: 1, user: 1 }, { unique: true });
export default mongoose.model('Review', reviewSchema);
Populating reviews in restaurant queries:
// controllers/restaurant.controller.js — With Population
// GET /api/restaurants/:id — Single restaurant with reviews
export const getRestaurant = asyncHandler(async (req, res, next) => {
const restaurant = await Restaurant.findById(req.params.id)
.populate({
path: 'reviews',
populate: {
path: 'user',
select: 'name email'
},
options: { sort: '-createdAt', limit: 10 }
});
if (!restaurant) {
throw new AppError('Restaurant not found! Yeh dukaan exist nahi karti! 🏪', 404);
}
res.json({ success: true, data: restaurant });
});
// Adding a review to a restaurant
export const addReview = asyncHandler(async (req, res, next) => {
const { rating, comment } = req.body;
const restaurant = await Restaurant.findById(req.params.id);
if (!restaurant) {
throw new AppError('Restaurant not found!', 404);
}
// Create review
const review = await Review.create({
restaurant: req.params.id,
user: req.user.id,
rating,
comment
});
// Push review reference to restaurant
restaurant.reviews.push(review._id);
await restaurant.save();
res.status(201).json({
success: true,
message: 'Review added! Thank you for your feedback! ⭐',
data: review
});
});
Populate vs Embedded — When to use which?
| Scenario | Use | Why |
|---|---|---|
| Menu items (always loaded with restaurant) | Embedded subdocument | One query, always needed together |
| Reviews (separate feature, paginated) | Referenced with populate | Grows independently, needs pagination |
| User profile (shared across features) | Referenced with populate | Used by many documents, avoid duplication |
Let's put it all together — the complete restaurant API with search, filters, and population.
// routes/restaurant.routes.js — Complete Restaurant Routes
import express from 'express';
import {
searchRestaurants,
getNearbyRestaurants,
getRestaurant,
addReview
} from '../controllers/restaurant.controller.js';
import { protect } from '../middleware/auth.middleware.js';
const router = express.Router();
// Public routes
router.get('/search', searchRestaurants); // GET /api/restaurants/search?pincode=500001
router.get('/nearby', getNearbyRestaurants); // GET /api/restaurants/nearby?lng=78.46&lat=17.38
router.get('/:id', getRestaurant); // GET /api/restaurants/:id
// Protected routes
router.post('/:id/reviews', protect, addReview); // POST /api/restaurants/:id/reviews
export default router;
Test the complete search flow:
# Search by pincode
curl 'http://localhost:3000/api/restaurants/search?pincode=500001'
# Search by cuisine + rating
curl 'http://localhost:3000/api/restaurants/search?cuisine=Biryani&rating=4&sort=-rating'
# Full-text search by name
curl 'http://localhost:3000/api/restaurants/search?search=Hyderabad%20Biryani'
# Nearby restaurants (geospatial)
curl 'http://localhost:3000/api/restaurants/nearby?lng=78.47&lat=17.38&maxDistance=3000'
# Paginated results
curl 'http://localhost:3000/api/restaurants/search?pincode=500001&page=1&limit=5'
# Single restaurant with reviews populated
curl 'http://localhost:3000/api/restaurants/abc123'
Key takeaway: Our API now supports the three main ways Swiggy users find restaurants:
- By pincode — "Show me restaurants near my area" (indexed search)
- By cuisine — "I want Biryani!" (filtered search)
- Nearby — "What's near me right now?" (geospatial query)
Key Takeaways
- ✅ Nested schemas (subdocuments) are embedded inside parent — good for data always loaded together
- ✅ MongoDB indexes speed up queries dramatically — use index() on frequently searched fields
- ✅ .populate() fetches referenced documents — like SQL JOINs for MongoDB
- ✅ Geospatial indexes (2dsphere) enable "near me" queries with $near operator
- ✅ Compound indexes (multiple fields) optimize complex queries: { pincode: 1, rating: -1 }
- ✅ Use .select() to limit returned fields and prevent over-fetching
- ✅ Always check for null after findById and throw appropriate 404 errors
Want to track your progress?
Log in to save your place and pick up where you left off.
Progress track karna chahte ho?
Login karo apni progress save karne ke liye aur jahan chhoda tha wahan se shuru karo.
Login