A full-stack e-commerce application with a React storefront, an admin dashboard, and a Node.js/Express API backed by MongoDB. Payments are handled by Stripe, product images by Cloudinary, and transactional email by Gmail/nodemailer.
This repository contains two apps:
| Folder | Description | Stack |
|---|---|---|
backend/ |
REST API, authentication, orders, payments, admin operations | Node.js, Express, Mongoose, JWT, Stripe, Cloudinary, Joi |
frontend/ |
Customer storefront and admin dashboard | React 18, Vite, Redux Toolkit, React Router, Tailwind CSS, Stripe.js |
The two apps are independent packages that run side by side.
Live app: https://letshoppin.netlify.app/
Originally built in 2024 as a learning project, then refactored with security hardening, validation, and automated tests.
- Email/password registration with email verification
- JWT authentication and role-based authorization (
customer/admin) - Product, category, banner, review, cart, and order management
- Stock validation and inventory adjustments on order place / cancel / activate
- Stripe PaymentIntent flow with server-side payment verification
- Admin dashboard for customers, products, categories, banners, orders, and reviews
- Optional read-only demo admin and demo customer logins with automatic expiry
- Cloudinary image hosting
- Contact form and password reset emails
- Starter categories, products, and banners seeded on first run
- Input validation (Joi), rate limiting, Helmet, and CORS allow-listing
- Automated API tests using the Node.js built-in test runner
letshopping/
├── backend/
│ ├── app.js # Express app (middleware + routes, no listening)
│ ├── server.js # Connects to MongoDB and starts the HTTP server
│ ├── config/ # DB connection, seeding, demo account cleanup
│ ├── controllers/ # Request handlers
│ ├── middleware/ # Auth, admin, validation, rate limiting, errors
│ ├── models/ # Mongoose schemas
│ ├── requests/ # Joi validation schemas
│ ├── routes/ # Express routers
│ ├── scripts/ # Standalone seed scripts
│ ├── services/ # Cloudinary / multer configuration
│ ├── utils/ # Email helper
│ └── tests/ # node:test API tests
└── frontend/
├── src/
│ ├── App-Routing/ # Route definitions and guards
│ ├── Home/ # Home page
│ ├── assets/ # Images and brand assets
│ ├── components/ # Pages and UI components (customer + admin)
│ ├── redux/ # Redux Toolkit slices
│ ├── service/ # Client-side services
│ └── styles/ # Global styles
├── vite.config.js
└── vercel.json
- Node.js 20+ and npm
- MongoDB 7+ running locally, or a MongoDB Atlas cluster
- Stripe account (test keys are fine)
- Cloudinary account
- Gmail account with an App Password for sending email
cd backend
npm install
cp .env.example .env
# edit .env with your values
npm run devThe API starts on http://localhost:4000 by default.
Health check:
curl http://localhost:4000/api/statusIn a second terminal:
cd frontend
npm install
cp .env.example .env
# set VITE_APP_API_URL and VITE_STRIPE_PUBLISHABLE_KEY
npm run devThe storefront runs on http://localhost:5173.
Make sure
VITE_APP_API_URLuseshttp://and matches the backend origin/port. Usinghttps://localhost:...against a plain HTTP server causes browserFailed to fetcherrors.
Local MongoDB (default):
LOCAL_MONGODB_URL=mongodb://127.0.0.1:27017/letshopMongoDB Atlas:
CLOUD_MONGODB_URL=mongodb+srv://<user>:<password>@<cluster>/<database>If you use Atlas, add your current public IP under Atlas → Network Access. An un-whitelisted IP fails during the TLS handshake with SSL alert number 80.
In development/test the backend connects to LOCAL_MONGODB_URL first and falls back to CLOUD_MONGODB_URL. In production (NODE_ENV=production) it prefers CLOUD_MONGODB_URL and falls back to LOCAL_MONGODB_URL. The common Atlas name MONGODB_URI is accepted as an alias for CLOUD_MONGODB_URL.
| Variable | Required | Description |
|---|---|---|
NODE_ENV |
No | development, production, or test |
PORT |
No | API port (default 5000, project default 4000) |
LOCAL_MONGODB_URL |
One of | Local MongoDB connection string |
CLOUD_MONGODB_URL |
One of | MongoDB Atlas connection string (MONGODB_URI also accepted) |
JWT_SECRET |
Yes | Secret used to sign JWTs. Use a long random value |
BASE_URL |
No | Backend base URL (fallback for email links) |
FRONTEND_URL |
Yes | Frontend URL used in verification and reset links |
ALLOWED_ORIGINS |
Yes in production | Comma-separated list of allowed browser origins |
ADMIN_NAME / ADMIN_EMAIL / ADMIN_PWD |
No | Admin account auto-created on startup if it does not exist |
DEMO_ADMIN_NAME / DEMO_ADMIN_EMAIL / DEMO_ADMIN_PWD |
No | Read-only demo admin login. Created on first demo login |
DEMO_CUSTOMER_NAME / DEMO_CUSTOMER_EMAIL / DEMO_CUSTOMER_PWD |
No | Demo customer login. Created on first demo login |
DEMO_TTL_MINUTES |
No | Minutes before a demo account and its cart, orders, and reviews are deleted (default 60) |
SEED_CATEGORIES |
No | Comma-separated category names seeded when the categories collection is empty (defaults to a built-in starter set) |
EMAIL_USER |
Yes | Gmail address used as the sender |
EMAIL_PASS |
Yes | Gmail App Password |
CONTACT_EMAIL |
No | Recipient for contact form messages (defaults to EMAIL_USER) |
CLOUDINARY_NAME / CLOUDINARY_API_KEY / CLOUDINARY_API_SECRET |
Yes for uploads | Cloudinary credentials |
STRIPE_SECRET_KEY |
Yes for payments | Stripe secret key |
| Variable | Required | Description |
|---|---|---|
VITE_APP_API_URL |
Yes | Backend base URL, e.g. http://localhost:4000 |
VITE_STRIPE_PUBLISHABLE_KEY |
Yes | Stripe publishable key |
VITE_CLOUDINARY_CLOUD_NAME |
Yes for uploads | Cloudinary cloud name |
VITE_CLOUDINARY_UPLOAD_PRESET |
Yes for uploads | Unsigned upload preset |
Never commit real .env files. Use .env.example as the template.
The recommended free-tier setup is Render (backend), Netlify (frontend), and MongoDB Atlas (database).
- Create a free
M0cluster and a database user. - Under Network Access, add
0.0.0.0/0. Render's free tier uses dynamic egress IPs, so a fixed IP allow-list will block connections. - Copy the driver connection string into
CLOUD_MONGODB_URL(include the database name, e.g./letshop).
The repository includes render.yaml, so you can use New → Blueprint and point Render at this repo. It configures the backend/ root directory, npm ci, npm start, and the /api/status health check. Otherwise create a Web Service manually with the same settings.
Set these environment variables in the Render dashboard (the blueprint marks them as secrets):
| Variable | Example |
|---|---|
NODE_ENV |
production |
CLOUD_MONGODB_URL |
mongodb+srv://.../letshop |
JWT_SECRET |
a long random string (do not reuse a dev value) |
BASE_URL |
https://letshopping-api.onrender.com |
FRONTEND_URL |
https://letshoppin.netlify.app |
ALLOWED_ORIGINS |
https://letshoppin.netlify.app (comma-separate extra origins) |
EMAIL_USER / EMAIL_PASS |
Gmail address + App Password |
CONTACT_EMAIL |
contact recipient |
CLOUDINARY_NAME / CLOUDINARY_API_KEY / CLOUDINARY_API_SECRET |
Cloudinary credentials |
STRIPE_SECRET_KEY |
sk_live_... for real payments |
ADMIN_NAME / ADMIN_EMAIL / ADMIN_PWD |
admin account created automatically on startup |
DEMO_ADMIN_NAME / DEMO_ADMIN_EMAIL / DEMO_ADMIN_PWD |
optional read-only demo admin (login page shows a button when set) |
DEMO_CUSTOMER_NAME / DEMO_CUSTOMER_EMAIL / DEMO_CUSTOMER_PWD |
optional demo customer (login page shows a button when set) |
DEMO_TTL_MINUTES |
optional demo lifetime in minutes (default 60) |
SEED_CATEGORIES |
optional comma-separated category names (only used when none exist) |
Do not set PORT; Render injects it. render.yaml sets NODE_VERSION=22, and package.json requires Node >=20 <23.
The admin account is seeded on every backend start. If ADMIN_EMAIL already exists as an admin, nothing happens; if it exists as a customer, seeding is skipped with a warning (change the email to create the admin).
Product categories are seeded once, only when the categories collection is empty. Set SEED_CATEGORIES to override the built-in starter set; seeded images use placeholder URLs that you can replace from the admin dashboard.
Products are seeded once, only when the products collection is empty. The starter catalog fills up to 12 products across the available categories and gives each one an active discount.
Banners are seeded once, only when the banners collection is empty. Three starter banners are created with blank images so an admin can upload artwork later. You can also run npm run seed:banners from backend/.
Demo accounts are optional and created on first login. Each demo login starts a fresh 1-hour session (configurable with DEMO_TTL_MINUTES), and the demo admin can view data but cannot create, edit, or delete anything. A cleanup job runs every minute while the backend is awake and once on startup; it restores stock and permanently deletes expired demo users along with their carts, orders, and reviews. On hosts that sleep idle services (such as Render's free tier), cleanup resumes on the next wake.
netlify.toml builds frontend/ with Vite and rewrites all routes to index.html for client-side routing. The live deployment is https://letshoppin.netlify.app/. Set these environment variables in Site configuration → Environment variables (Vite inlines them at build time, so redeploy after changing them):
| Variable | Example |
|---|---|
VITE_APP_API_URL |
https://letshopping-api.onrender.com |
VITE_STRIPE_PUBLISHABLE_KEY |
pk_live_... |
VITE_CLOUDINARY_CLOUD_NAME |
your-cloud-name |
VITE_CLOUDINARY_UPLOAD_PRESET |
your-unsigned-preset |
- Render's free web service sleeps after inactivity; the first request can take ~50s to wake.
- Restrict the Cloudinary unsigned upload preset (allowed formats, max size, folder) because it is public by design.
- Rotate any credentials that have been used in development before going live.
- Run
npm run buildinfrontend/before deploying to catch build errors.
| Command | Description |
|---|---|
npm run dev |
Start the API with file watching |
npm start |
Start the API |
npm test |
Run the API test suite |
npm run test:watch |
Re-run tests on change |
npm run seed:banners |
Seed the starter banners when the collection is empty |
npm run build |
No-op placeholder (the backend needs no build step) |
| Command | Description |
|---|---|
npm run dev |
Start the Vite dev server |
npm run build |
Build for production |
npm run preview |
Preview the production build |
npm run lint |
Run ESLint |
The backend uses the built-in Node.js test runner (node:test) and fetch, so no extra test dependencies are required.
cd backend
npm testThe tests:
- run against a local MongoDB instance (
127.0.0.1:27017) - create and drop isolated
letshop_test_*databases - skip real email sending (
NODE_ENV=test) - cover registration/login, password reset/change, JWT protection, cart stock rules, order creation, stock restoration, ownership checks, admin-only routes, payment-intent authorization, validation, 404s, and CORS
If MongoDB is not running on the default port, start it before running the tests.
All endpoints are prefixed with /api.
| Method | Path | Access | Description |
|---|---|---|---|
| POST | /register |
Public | Register a customer |
| POST | /login |
Public | Log in and receive a JWT |
| GET | /demo-accounts |
Public | Demo credentials shown on the login page (when configured) |
| GET | /validation-schema |
Public | Registration field rules used by the frontend |
| GET | /verify-email/:token |
Public | Verify an email address |
| POST | /request-password-reset |
Public | Request a reset link |
| POST | /reset-password/:token |
Public | Reset the password |
| GET | /me |
Auth | Current user |
| GET | /profile |
Auth | Current user profile |
| PUT | /update-profile |
Auth | Update own profile |
| PUT | /change-password |
Auth | Change own password |
| DELETE | /delete-account |
Auth | Delete own account |
| GET | /admin/customers |
Admin | List customers |
| PUT | /admin/customers/:id/activate |
Admin | Activate a customer |
| PUT | /admin/customers/:id/deactivate |
Admin | Deactivate a customer |
| PUT | /admin/customers/:id |
Admin | Update a customer |
| DELETE | /admin/customers/:id |
Admin | Delete a customer |
| Method | Path | Access | Description |
|---|---|---|---|
| GET | /products |
Public | List products (supports categoryName, search) |
| GET | /products/:id |
Public | Product details + reviews |
| POST | /search |
Public | Search products by keyword |
| GET | /category/:category |
Public | Products in a category |
| POST | /products |
Admin | Create a product |
| PUT | /products/:id |
Admin | Update a product |
| DELETE | /products/:id |
Admin | Delete a product |
| GET | /products/low-stock |
Admin | Low-stock products |
| PUT | /products/:id/increase-stock |
Admin | Increase stock |
| PUT | /products/:id/decrease-stock |
Admin | Decrease stock |
| Method | Path | Access | Description |
|---|---|---|---|
| POST | /cart |
Auth | Add an item (validates stock) |
| GET | /cart |
Auth | Get the current cart |
| DELETE | /cart/:itemId |
Auth | Remove an item |
| Method | Path | Access | Description |
|---|---|---|---|
| POST | /place-order |
Auth | Create an order from the cart |
| GET | /myorders |
Auth | Current user's orders |
| GET | /myorders/status |
Auth | Current user's orders by status |
| PUT | /:id/cancel |
Owner/Admin | Cancel and restore stock |
| PUT | /:id/activate |
Owner/Admin | Reactivate a canceled order |
| DELETE | /:id |
Owner/Admin | Delete a canceled order |
| POST | /create-payment-intent |
Owner/Admin | Create a Stripe PaymentIntent |
| POST | /confirm-payment |
Owner/Admin | Verify payment with Stripe and mark paid |
| GET | / |
Admin | All orders |
| GET | /status |
Admin | All orders by status |
| PUT | /:id/status |
Admin | Update order status |
| PUT | /update-payment-status/:orderId |
Admin | Manually set payment status |
| Method | Path | Access | Description |
|---|---|---|---|
| GET | /api/banners |
Public | List banners |
| GET | /api/banners/:id |
Public | Get a banner |
| POST/PUT/DELETE | /api/banners/... |
Admin | Manage banners |
| GET | /api/categories |
Public | List categories |
| GET | /api/categories/:id |
Admin | Get category details |
| POST/PUT/DELETE | /api/categories/... |
Admin | Manage categories |
| GET | /api/products/:id/reviews |
Public | Product reviews |
| POST | /api/products/:id/review |
Auth | Add a review |
| PUT | /api/products/:productId/reviews/:id |
Owner | Update a review |
| DELETE | /api/products/:productId/reviews/:id |
Owner/Admin | Delete a review |
| GET | /api/admin/reviews |
Admin | All reviews |
| GET | /api/products/:productId/global-rating |
Public | Average rating |
| POST | /api/upload |
Admin | Upload an image to Cloudinary |
| POST | /api/contact |
Public | Send a contact message |
Protected endpoints expect Authorization: Bearer <token>.
-
Customer places an order (
POST /api/orders/place-order). The server computes totals from product prices, validates stock, decrements it, and clears the cart. -
Frontend requests a PaymentIntent (
POST /api/orders/create-payment-intent) with only theorderId. The server looks up the order and computes the amount itself; the client never supplies the amount. -
Stripe.js confirms the card payment in the browser.
-
Frontend calls
POST /api/orders/confirm-payment. The server retrieves the PaymentIntent from Stripe and verifies:- the payment status is
succeeded - the PaymentIntent belongs to this order
- the paid amount matches the order total
Only then is the order marked as
paid. - the payment status is
Implemented in this codebase:
- Order cancellation, activation, deletion, and payment operations enforce ownership or admin rights (no IDOR)
- Payment amounts are computed server-side and verified with Stripe
- Password reset and email verification tokens are stored hashed; reset links point to the frontend
- Password change no longer double-hashes the password
- Input validation with Joi on authentication, profile, orders, cart, products, reviews, stock, payments, and contact
- Rate limiting on login, registration, password reset, and contact submissions
- Helmet, CORS allow-listing, and a centralized error handler
- Passwords and tokens are never returned by user endpoints
- Order placement validates and atomically adjusts stock with rollback on failure
Remaining recommendations for production:
- Prefer HttpOnly, Secure cookies (or short-lived access tokens + refresh tokens) over
localStorage - Add a Stripe webhook for
payment_intent.succeededas a second source of truth - Use a shared store (Redis) for rate limiting when running more than one instance
- Add pagination to product/order listing endpoints
- Add a request-id logger and structured logging
- Enable MongoDB transactions if you migrate to a replica set (current stock updates use atomic conditional updates instead)
- Add integration tests for Stripe and email providers using their test modes/mocks
The live app is hosted on Netlify, but the included files also support other hosts:
- Backend: the
backend/Dockerfilebuilds a Node 22 image. Set all backend env vars in your host (Fly.io, Koyeb, Render, etc.). At minimum setNODE_ENV=production,ALLOWED_ORIGINS,FRONTEND_URL, a strongJWT_SECRET, MongoDB, Stripe, Cloudinary, and email credentials. - Frontend:
frontend/vercel.jsonrewrites SPA routes toindex.html. SetVITE_APP_API_URL,VITE_STRIPE_PUBLISHABLE_KEY,VITE_CLOUDINARY_CLOUD_NAME, andVITE_CLOUDINARY_UPLOAD_PRESETin the Vercel project settings, then deploy.
ISC