- ✓Basic understanding of Next.js 14 App Router and React Server Components
- ✓A free MongoDB Atlas account with an active M0 cluster
- ✓A free Cloudinary account for property photo storage
🚀 What You Will Learn & Build
- ▸Implement MongoDB 2dsphere indexing and $near geospatial queries for Kathmandu valley coordinates
- ▸Handle server-side image compression and direct upload to Cloudinary without local disk dependency
- ▸Design accessible search filters for Nepali rent budgets (Under 10k, 10k-20k, 20k+)
- ▸Confidently defend architectural decisions during college final-year project viva examinations
1. Kathmandu Rental Problem & Features
2. MongoDB Atlas Geospatial Schema (2dsphere)
import mongoose, { Schema, Document, Model } from "mongoose";
export interface IListing extends Document {
title: string;
description: string;
category: "Single Room" | "Double Room" | "Flat 1BHK" | "Flat 2BHK";
rentNpr: number;
area: string; // e.g. "New Baneshwor, Kathmandu"
location: {
type: "Point";
coordinates: [number, number]; // [longitude, latitude] -> NOTE: Longitude FIRST in GeoJSON!
};
amenities: {
waterSupply: "Melamchi" | "Boring" | "Jar Water Only";
parking: "Bike Only" | "Car and Bike" | "None";
wifiIncluded: boolean;
separateSubMeter: boolean;
};
photos: string[];
landlord: {
name: string;
phone: string;
isVerified: boolean;
};
isAvailable: boolean;
createdAt: Date;
}
const ListingSchema: Schema = new Schema<IListing>(
{
title: { type: String, required: true, trim: true },
description: { type: String, required: true },
category: {
type: String,
enum: ["Single Room", "Double Room", "Flat 1BHK", "Flat 2BHK"],
required: true,
},
rentNpr: { type: Number, required: true, min: 1000 },
area: { type: String, required: true },
location: {
type: {
type: String,
enum: ["Point"],
default: "Point",
required: true,
},
coordinates: {
type: [Number], // [longitude, latitude]
required: true,
},
},
amenities: {
waterSupply: { type: String, default: "Melamchi" },
parking: { type: String, default: "Bike Only" },
wifiIncluded: { type: Boolean, default: false },
separateSubMeter: { type: Boolean, default: true },
},
photos: [{ type: String }],
landlord: {
name: { type: String, required: true },
phone: { type: String, required: true },
isVerified: { type: Boolean, default: false },
},
isAvailable: { type: Boolean, default: true },
},
{ timestamps: true }
);
// CRITICAL: 2dsphere index for geospatial proximity queries
ListingSchema.index({ location: "2dsphere" });
ListingSchema.index({ rentNpr: 1 });
ListingSchema.index({ area: "text", title: "text" });
export const Listing: Model<IListing> =
mongoose.models.Listing || mongoose.model<IListing>("Listing", ListingSchema);3. Location-Based Query API (Baneshwor, Kirtipur, Patan)
import { NextRequest, NextResponse } from "next/server";
import { connectToDatabase } from "@/lib/mongodb";
import { Listing } from "@/models/Listing";
export async function GET(req: NextRequest) {
try {
await connectToDatabase();
const { searchParams } = new URL(req.url);
const lng = searchParams.get("lng");
const lat = searchParams.get("lat");
const maxDistanceKm = Number(searchParams.get("radiusKm")) || 5;
const maxRent = searchParams.get("maxRent");
const category = searchParams.get("category");
const search = searchParams.get("search");
const query: any = { isAvailable: true };
// Price filter
if (maxRent) {
query.rentNpr = { $lte: Number(maxRent) };
}
// Category filter
if (category && category !== "All") {
query.category = category;
}
// Text keyword filter
if (search) {
query.$text = { $search: search };
}
// Geospatial filter: $near requires [longitude, latitude]
if (lng && lat) {
query.location = {
$near: {
$geometry: {
type: "Point",
coordinates: [parseFloat(lng), parseFloat(lat)],
},
$maxDistance: maxDistanceKm * 1000, // converted to meters
},
};
}
const listings = await Listing.find(query).limit(50).lean();
return NextResponse.json({ success: true, count: listings.length, data: listings });
} catch (error: any) {
console.error("Listing query error:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}4. Next.js Filter & Map Listing Component
import Image from "next/image";
import { MapPin, Phone, Droplet, Bike, Wifi } from "lucide-react";
interface ListingProps {
listing: {
_id: string;
title: string;
category: string;
rentNpr: number;
area: string;
photos: string[];
amenities: {
waterSupply: string;
parking: string;
wifiIncluded: boolean;
separateSubMeter: boolean;
};
landlord: {
name: string;
phone: string;
isVerified: boolean;
};
};
}
export function RoomCard({ listing }: ListingProps) {
const whatsappUrl = `https://wa.me/977${listing.landlord.phone.replace(/\D/g, "")}?text=Namaste, I saw your room listing for ${encodeURIComponent(listing.title)} in ${encodeURIComponent(listing.area)}. Is it still available?`;
return (
<div className="bg-[#F4F4F5] border border-[#E4E4E7] rounded-2xl overflow-hidden hover:border-sky-500/50 transition-all flex flex-col group">
<div className="relative h-48 w-full bg-slate-800">
<Image
src={listing.photos[0] || "/placeholder-room.jpg"}
alt={listing.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="(max-width: 768px) 100vw, 33vw"
/>
<div className="absolute top-3 left-3 bg-slate-900/80 backdrop-blur-md px-2.5 py-1 rounded-md text-xs font-semibold text-sky-300">
{listing.category}
</div>
<div className="absolute bottom-3 right-3 bg-sky-500 text-slate-950 px-3 py-1 rounded-full text-xs font-bold">
Rs. {listing.rentNpr.toLocaleString("en-NP")} / month
</div>
</div>
<div className="p-5 flex-1 flex flex-col justify-between">
<div>
<div className="flex items-center gap-1.5 text-xs text-slate-400 mb-1">
<MapPin className="w-3.5 h-3.5 text-sky-400" />
<span>{listing.area}</span>
</div>
<h3 className="text-base font-bold text-slate-100 line-clamp-1 mb-2">{listing.title}</h3>
<div className="flex flex-wrap gap-2 text-xs text-slate-300 mb-4">
<span className="flex items-center gap-1 bg-[#E4E4E7] px-2 py-0.5 rounded">
<Droplet className="w-3 h-3 text-cyan-400" /> {listing.amenities.waterSupply}
</span>
<span className="flex items-center gap-1 bg-[#E4E4E7] px-2 py-0.5 rounded">
<Bike className="w-3 h-3 text-emerald-400" /> {listing.amenities.parking}
</span>
{listing.amenities.wifiIncluded && (
<span className="flex items-center gap-1 bg-[#E4E4E7] px-2 py-0.5 rounded">
<Wifi className="w-3 h-3 text-amber-400" /> Free WiFi
</span>
)}
</div>
</div>
<div className="border-t border-[#E4E4E7] pt-4 flex items-center justify-between">
<div className="text-xs">
<span className="text-slate-400 block">Owner</span>
<span className="font-semibold text-slate-200">{listing.landlord.name}</span>
</div>
<a
href={whatsappUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold px-3 py-2 rounded-xl transition-colors"
>
<Phone className="w-3.5 h-3.5" />
<span>WhatsApp Call</span>
</a>
</div>
</div>
</div>
);
}✨ Expected Output & Verification
A deployable real estate portal specifically tailored to Kathmandu valley renting conditions (Melamchi water supply, sub-meters, neighborhood coordinates).
🧪 How to Test the Implementation
- 1Create a listing in Baneshwor with coordinates [85.3412, 27.6915]
- 2Query listings within 3km of Koteshwor [85.3475, 27.6781] and verify distance ranking
- 3Upload test photos and verify optimization as WebP in Cloudinary dashboard
- 4Test mobile layout and WhatsApp direct dialing button on smartphone
⚠️ Common Errors & How to Fix Them
Root Cause: Passing latitude first or providing values outside valid bounds (-180 to 180 for longitude, -90 to 90 for latitude).
Fix: Ensure coordinates are strictly formatted as [longitude, latitude]. For Kathmandu, longitude is ~85.32 and latitude is ~27.71.
Root Cause: Attempting to save multipart file uploads to a local folder like ./public/uploads on serverless Vercel functions.
Fix: Use memory buffers to stream uploads directly to Cloudinary or AWS S3 instead of local disk.
🛡️ Security Considerations & Best Practices
- !Rate-limit landlord contact lookups to prevent web scrapers from harvesting phone numbers for telemarketing.
- !Validate uploaded image mime types on the server side (allow only image/jpeg, image/png, image/webp).
- !Sanitize property descriptions to eliminate Cross-Site Scripting (XSS) vectors.
❓ Frequently Asked Questions (FAQ)
Yes! OpenStreetMap with React-Leaflet is 100% free, requires no credit card, and has comprehensive building footprints across Kathmandu, Patan, and Bhaktapur.
📌 Summary of Key Takeaways
A localized Kathmandu Room & Flat Finder built with Next.js 14, MongoDB Atlas geospatial indexing, and Cloudinary provides an impactful real-world solution and an outstanding portfolio/viva project for Nepali students.
Official References & Further Reading
Written by Manoj Neupane
AuthorSoftware developer and programming instructor, Nepal
Building software systems and SaaS platforms with Laravel, Next.js, and Node.js. Passionate about helping students and developers in Nepal master practical, real-world development skills.