CarTrade is a full-stack MERN (MongoDB, Express, React, Node.js) used-car marketplace. Anyone can browse and filter available cars without logging in, registered users can save favourites and contact dealers, and dealers can manage their own vehicle listings through a dedicated dashboard.
- Overview
- Features
- Tech Stack
- Architecture
- Database Design
- Authentication & Authorization
- Image Uploads
- API Reference
- Getting Started
- Environment Variables
- Project Structure
- Known Limitations & Roadmap
- Learnings
CarTrade provides a centralized marketplace where buyers can compare used vehicles while dealers can publish and manage listings.
| Role | Capabilities |
|---|---|
| Guest | Browse cars, search / filter / sort, view car details |
| Registered User | Everything a guest can do, plus favourites and contacting dealers |
| Dealer | Browse cars, add listings, edit/delete own listings, manage inquiries |
Note: Login is not required to browse cars. There is no admin role — only
useranddealer.
Open CarTrade → Browse Cars → Search/Filter/Sort → View Car Details
→ Want an account? → Register → Choose Role
/ \
User Dealer
| |
Favourites / Contact Dealer Dashboard (Add/Edit/Delete Own Cars)
- 🔍 Search, filter, and sort car listings (make, colour, price, etc.)
- 🧾 OEM specification data normalized and linked to individual listings
- 🔐 JWT-based authentication with bcrypt password hashing
- 🛡️ Role-based access control (RBAC) for dealer-only actions
- 🖼️ Vehicle image uploads to AWS S3 via Multer memory storage
- ❤️ Favourites for registered users
- ✉️ Contact-dealer / inquiry system
- ⚙️ Redux + Redux Thunk for shared app state (auth, inventory)
Frontend: React, Redux, Redux Thunk, Axios Backend: Node.js, Express, Mongoose Database: MongoDB (Atlas) Storage: AWS S3 (via AWS SDK) Auth: bcrypt, JSON Web Tokens (JWT) Security middleware: Helmet, CORS, Multer (upload limits)
CarTrade follows a classic three-tier architecture:
Presentation Layer (React)
|
Application Layer (Node.js + Express)
|
Data Layer (MongoDB)
Express -----> AWS S3 (Images)
This gives separation of concerns — the UI has no database logic, and MongoDB is never exposed directly to the browser — which keeps the system easier to maintain, secure, test, and scale.
Request flow:
React → Axios HTTP request → Express REST API → Mongoose → MongoDB
← Express returns JSON ← React updates UI
Using JavaScript across both client and server reduces context switching during development:
- MongoDB — stores data
- Express.js — exposes REST APIs, runs on Node.js
- React — frontend
- Node.js — runs JS on the server
| Method | Purpose | Example |
|---|---|---|
GET |
Retrieve data | GET /api/cars |
POST |
Create a resource | POST /api/cars |
PATCH |
Update a resource | PATCH /api/cars/:id |
DELETE |
Delete a resource | DELETE /api/cars/:id |
Factory (OEM) information and individual used-car information are modeled as separate, normalized collections.
- OEM Specs (factory data): make, model, year, engine size, horsepower, transmission, fuel type
- Marketplace Listing (per-car data): price, mileage, colour, accident history, condition notes, image URL, dealer — linked to OEM via
ObjectId
// Inventory (listing)
{ price: 12000000, oemSpecs: "689abc123..." }
// OEM document
{ _id: "689abc123...", make: "Toyota", model: "Corolla", horsepower: 140 }.populate("oemSpecs") resolves the reference into the full nested OEM object — conceptually similar to a join in a relational database.
Trade-off: normalizing OEM data avoids duplicating static factory specs across listings, at the cost of more complex read queries (populate / aggregation) when a view needs joined data.
- MongoDB — the database itself
- Mongoose — an ODM sitting between Express and MongoDB, providing schema definitions, validation,
ObjectIdreferences,populate(), and CRUD queries
MongoDB's flexible document model suits varied vehicle attributes, while Mongoose still enforces schemas and relationships where needed.
Name, Email, Password, Role (User/Dealer)
→ POST /api/auth/register → Validate → Check duplicate email
→ bcrypt.hash(password) → Store user → MongoDB
Email + Password → POST /api/auth/login → MongoDB finds user
→ bcrypt.compare() → Match? → No: 401 | Yes: Generate JWT → React
Passwords are never stored as plaintext — only the bcrypt hash. Login uses bcrypt.compare(); passwords are never decrypted.
The backend issues a JWT after successful login. The frontend stores it and sends it with protected requests via Authorization: Bearer <token>.
{ id: user._id, role: user.role } // payload, expiresIn: "1d"| Question | Mechanism | |
|---|---|---|
| Authentication | "Who are you?" | Email + password → JWT |
| Authorization | "What are you allowed to do?" | role: "user" vs role: "dealer" |
router.post("/cars", authenticate, requireRole("dealer"), createCar);Hiding an "Add Car" button in React is only a UX affordance — it does not stop a direct POST /api/cars call via a tool like Postman. Authorization must be enforced server-side.
Role checking alone isn't enough to stop Dealer A from editing Dealer B's car. Each listing stores { owner: dealerId }; edit/delete requests compare the JWT user ID against the listing owner and return 403 Forbidden on mismatch.
Ownership (and any user-identifying field) is always derived from the verified JWT — never trusted from the request body:
// ❌ Bad — client-controlled
{ owner: req.body.owner }
// ✅ Good — derived from verified token
{ owner: req.user.id }User selects image → React → FormData → POST /upload/image → Express
→ Multer (memoryStorage) → in-memory buffer → AWS SDK → S3 bucket
→ Image URL → React → Car listing → MongoDB
- FormData is used instead of JSON because binary files need
multipart/form-data. - Multer (
memoryStorage()) keeps the image as an in-memory buffer rather than writing a temporary file to disk, keeping the server stateless. An upload size limit (5 MB) guards against excessive memory use under concurrent uploads. - AWS S3 stores the actual image object; MongoDB stores only the resulting URL, keeping database records small and separating structured data from binary storage.
- Filenames use
crypto.randomUUID()to avoid collisions (e.g.cars/a3c8...-car.jpg).
S3 was chosen over a managed media service like Cloudinary to get direct control over validation, authentication, naming, access policy, and future processing — and hands-on experience with the AWS SDK, S3, and IAM.
Base URL:
/api
| Method | Endpoint | Description |
|---|---|---|
POST |
/auth/register |
Register a new user or dealer |
POST |
/auth/login |
Authenticate and receive a JWT |
| Method | Endpoint | Description | Access |
|---|---|---|---|
GET |
/cars |
List/search/filter/sort cars | Public |
GET |
/cars/:id |
Get car details | Public |
POST |
/cars |
Create a listing | Dealer |
PATCH |
/cars/:id |
Update own listing | Dealer (owner) |
DELETE |
/cars/:id |
Delete own listing | Dealer (owner) |
Example filter query:
GET /api/cars?make=Toyota&color=Black&sortPrice=asc
| Method | Endpoint | Description | Access |
|---|---|---|---|
POST |
/favourites/:carId |
Save a car to favourites | Registered user |
POST |
/inquiries |
Contact a dealer about a car | Registered user |
| Method | Endpoint | Description | Access |
|---|---|---|---|
POST |
/upload/image |
Upload a vehicle image to S3 | Dealer |
| Code | Meaning |
|---|---|
200 |
Successful request |
201 |
Resource created |
400 |
Bad input |
401 |
Not authenticated |
403 |
Authenticated but not permitted |
404 |
Resource not found |
409 |
Conflict (e.g. duplicate email) |
500 |
Server error |
- Node.js
- MongoDB (local or Atlas)
- An AWS account with an S3 bucket configured
# Clone the repo
git clone https://github.com/<your-username>/cartrade.git
cd cartrade
# Install backend dependencies
cd server
npm install
# Install frontend dependencies
cd ../client
npm install# Start the backend (default: localhost:5000)
cd server
npm run dev
# Start the frontend (default: localhost:3000)
cd client
npm startCreate a .env file in the server directory:
PORT=5000
MONGO_URI=your_mongodb_connection_string
JWT_SECRET=your_jwt_secret
JWT_EXPIRES_IN=1d
AWS_ACCESS_KEY_ID=your_aws_access_key
AWS_SECRET_ACCESS_KEY=your_aws_secret_key
AWS_REGION=your_aws_region
AWS_S3_BUCKET=your_bucket_name
CLIENT_ORIGIN=http://localhost:3000cartrade/
├── client/ # React frontend
│ ├── src/
│ │ ├── components/
│ │ ├── pages/
│ │ ├── redux/ # store, actions, reducers, thunks
│ │ └── services/ # axios instances / API calls
│ └── package.json
├── server/ # Express backend
│ ├── models/ # Mongoose schemas (User, Car, OEMSpec, Inquiry...)
│ ├── routes/
│ ├── controllers/
│ ├── middleware/ # authenticate, requireRole, upload (multer)
│ ├── config/ # db connection, S3 client
│ └── package.json
└── README.md
CarTrade has several scalable characteristics: images are offloaded to S3, authentication is stateless (JWT), MongoDB Atlas can scale independently, and the frontend/backend are cleanly separated. That said, before this is production-grade, the following are planned or in progress:
- Centralized request validation (reject invalid payloads, e.g. negative price/mileage, with
400) - Refactor the current branching filter logic into a dynamic aggregation pipeline
- Database indexes and pagination for listing queries
- Caching where appropriate
- Stronger token management (refresh tokens, revocation strategy)
- Rate limiting
- Automated tests
- CI/CD and deployment automation
- Monitoring / logging in production
Design trade-off — JWT vs sessions: JWT enables stateless authentication, so the backend doesn't need a central session store, which simplifies horizontal scaling. The trade-off is that token revocation is harder, making expiry and refresh-token strategy important.
Building CarTrade involved full-stack data flow, REST API design, MongoDB relationships via Mongoose (ObjectId references, populate(), aggregation with $lookup / $unwind / $match / $sort), JWT authentication, authorization/ownership concerns, cloud object storage with S3, file handling with Multer, and the trade-offs between normalized data and query complexity.
One of the more challenging areas was filtering and sorting listings when some attributes lived on the inventory collection while others came from referenced OEM specs — solved using Mongoose populate combined with MongoDB aggregation.