Food delivery · Next.js + Express + MongoDB · Draft

Nine Day Kitchen

New class, already started on sign-up and login with bcrypt. Deadline Sunday 4 October. Design does not matter, working features do. Every day ships one feature end to end: server first, tested in Postman, then the page that uses it.

7 class days (Thu 24 Sep – Fri 2 Oct) · 2 buffer days · 4 models · 15 endpoints

Order of work

Login and sign-up → categories → foods (the first reference between two tables) → Cloudinary → customer side and cart → orders (two more references). Each step needs the one before it.

If the class falls behind, cut from the end, not the middle. A provider migration can always wait: the feature already works without it. First to go: the customer "My orders" page. Admin orders table stays, it proves the order was saved.

Every form, every day

No design work. Use shadcn Input, Label, Button, Dialog, Select as they come. Each day below shows what its screen should look like and what to validate.

The days

Day 1Thu 24 Sep

Finish auth on the server

Already started: sign-up and login with bcrypt. Today closes it out.

Server
  • user-schema.js: email (unique), password, role (USER / ADMIN, default USER), address.
  • Finish bcrypt: sign-up hashes, login compares. Then login signs a JWT holding userId and role.
  • require-token.js and require-admin.js middleware.
Routes
POST /auth/sign-up public POST /auth/login public
Page
  • None. Browser closed, Postman only.

Done when: wrong password is rejected, no response contains a password, login returns a token.

Day 2Fri 25 Sep

Sign-up and login pages, connected

Page
  • _api/api.js: one axios instance with the base URL. It reads the token from localStorage and adds it to every request.
  • Login saves token and user straight to localStorage. No provider yet.
  • After login: ADMIN goes to /admin/dishes, USER goes to /.
Looks like on a full screen · /signup and /login · login is live, submit it empty
localhost:3000/signup

Create account

At least 8 characters
Passwords don't match
Already have an account? Log in
localhost:3000/login

Log in

No account? Sign up

On a real screen the form sits in the middle, both ways, about 380px wide. Everything around it is empty page. In Tailwind: min-h-screen flex items-center justify-center on the page, w-full max-w-sm on the form.

Validate
emailrequired, has @ and a dotEnter a valid email
passwordsign-up: 8+ characters · login: requiredAt least 8 characters
confirmsame as passwordPasswords don't match
server 409email already usedThis email already has an account
server 401one message for wrong email or passwordEmail or password is wrong

Done when: a new account can sign up and log in, and the token is in DevTools → Application → Local Storage. The admin and the user land on different pages.

Day 3Mon 28 Sep

Category CRUD

Server
  • food-category.js schema, 4 routes. Create, update and delete sit behind requireToken + requireAdmin.
  • Errors: 400 missing name, 404 bad id, 409 duplicate.
Routes
GET /food-category public POST /food-category token + admin PUT /food-category token + admin DELETE /food-category token + admin
Page
  • admin/dishescategory-sidebar.js: list, add, rename, delete.
Looks like · /admin/dishes
localhost:3000/admin/dishes

Categories

Appetizers
Pizza
This category already exists

Dishes

No dishes yet. They arrive on Day 4.

Left menu comes from admin/layout.js. The page is two columns: categories, then dishes. On a phone they stack.

Validate
namerequired after trim, 2–30 charactersName is required
server 409name already existsThis category already exists
deleteask first, Cancel changes nothingDelete "Pizza"?

Done when: a category added in the browser is in Atlas and survives a refresh. The same call from Postman without a token gets 401.

Day 4Tue 29 Sep

Food CRUD and the first reference

Server
  • food.js schema: name, price, image, ingredients, category as ObjectId with ref: "FoodCategory".
  • 5 routes. The list uses .populate("category") and takes ?categoryId=.
Routes
GET /food public GET /food/:id public POST /food token + admin PUT /food/:id token + admin DELETE /food/:id token + admin
Page
  • dish-grid.js and dish-form-dialog.js: list and create. Image is a pasted URL for today.
Looks like · /admin/dishes, then the same page with "+ Add dish" clicked
localhost:3000/admin/dishes

Categories

All5
Appetizers3
Pizza2

Pizza (2)

+ Add dish
Pepperoni₮18,000
Margherita₮15,000
localhost:3000/admin/dishes

Categories

All5
Appetizers3
Pizza2

Pizza (2)

+ Add dish
Pepperoni₮18,000
Margherita₮15,000

Add dish to Pizza

Price must be a number above 0
Choose a category

Clicking a category in the left list filters the grid. The rename and delete buttons from Day 3 are still there, hidden here to keep it short.

Validate
namerequired after trimFood name is required
pricea number above 0Price must be a number above 0
categoryone is chosenChoose a category
ingredientsoptional

Done when: a dish comes back with its category name, not a bare id. Clicking a category shows only its dishes.

Day 5Wed 30 Sep

Providers day: Auth, Category, Dish. Then Cloudinary

No new screens. Categories and dishes already work with local state from Days 3–4. Today that state moves into providers, so the admin page and the Day 6 home page read the same data.

Migrate
  • 1. auth-provider.js: user + token, reads localStorage once, gives login / logout. admin/layout.js uses it to send non-admins back to login.
  • 2. category-provider.js: move the useState + fetch out of category-sidebar.js. Gives categories, addCategory, renameCategory, deleteCategory.
  • 3. dish-provider.js: move the dish list + fetch out of dish-grid.js. Gives dishes, addDish. Adding a dish also refreshes category counts.
  • Root app/layout.js wraps all three.
The migration, before and after
// Day 3: category-sidebar.js owns the data
const [categories, setCategories] = useState([]);
useEffect(() => {
  api.get("/food-category")
     .then((res) => setCategories(res.data));
}, []);
// Day 5: the provider owns it, the feature asks
const { categories, addCategory } =
  useCategories();
// same JSX as before, nothing else changes
Page
  • Then Cloudinary: image-upload.js in the Add dish dialog. The browser uploads straight to Cloudinary (unsigned preset) and puts the URL into the form.
Server
  • No new route. The food still stores a URL string.
Validate
imagea file is picked and the upload finishedWait for the image to upload
image typestarts with image/Choose an image file

Done when: the admin page works exactly like yesterday, but no feature file calls api any more. A USER typing /admin gets bounced. A new dish saves a short https://res.cloudinary.com/... URL, never base64.

Day 6Thu 1 Oct

Edit and delete, Order model, customer home, cart

Provider
  • Edit and delete go straight into dish-provider.js: updateDish, deleteDish. The pencil opens dish-form-dialog.js pre-filled. Delete uses confirm-dialog.js.
  • The home page reads useCategories() and useDishes(). No new fetch code for it.
Server
  • order.js schema: user ref User, items[] each with food ref Food + quantity + price copied at order time, total, status, address.
  • POST /order ignores any price the client sends and looks it up. GET /order/me.
Routes
POST /order token GET /order/me token
Page
  • (main)/page.js: category tabs + food grid. Click a food to add it to the cart.
  • providers/cart-provider.js: items, add, remove, quantity, total. Saved in localStorage.
Looks like · admin: pencil on a dish opens Edit, Delete asks first
localhost:3000/admin/dishes
Dishesadmin@food.mn

Categories

All5
Appetizers3
Pizza2

Pizza (2)

+ Add dish
Pepperoni₮18,000
Margherita₮15,000

Edit dish

Image
Uploading… Save waits until this finishes.
localhost:3000/admin/dishes
Dishesadmin@food.mn

Categories

All5
Appetizers3
Pizza2

Pizza (2)

+ Add dish
Pepperoni₮18,000
Margherita₮15,000

Delete "Pepperoni"?

This removes the dish for everyone. It can't be undone.

Validate
editsame rules as Add dish, fields start pre-filled
Looks like · / (home)
localhost:3000/
NomNom
AllAppetizersPizza
Pepperoni₮18,000
Margherita₮15,000
Caesar₮12,000
Spring rolls₮9,000

The header lives in (main)/layout.js, so it stays on every customer page. The chips filter the grid.

Validate
add to cartsame food twice = quantity 2, not two lines
not logged insend to login before addingLog in to order

Done when: edit and delete update the grid with no refresh. an order sent from Postman with price 1 is saved with the real price. The cart survives a refresh.

Day 7Fri 2 Oct

Checkout and admin orders

Build orders with local state inside the features first, like categories on Day 3. They move into order-provider.js on Saturday.

Server
  • GET /order (all orders, admin) with user and food populated. PATCH /order/:id changes status.
Routes
GET /order token + admin PATCH /order/:id token + admin
Page
  • Cart sheet → checkout with an address → cart clears.
  • admin/orders: table with customer email, items, total, status select.
  • (main)/orders/page.js: my orders. First to cut if late.
Looks like · Cart (2) opens the cart sheet
localhost:3000/
NomNom
AllAppetizersPizza
Pepperoni₮18,000
Margherita₮15,000
Caesar₮12,000
Spring rolls₮9,000

Your cart

Pepperoni2
Caesar1
Total₮48,000
Enter a delivery address
/admin/orders
localhost:3000/admin/orders
Ordersadmin@food.mn

Orders

#CustomerItemsTotalAddressStatus
1bold@gmail.com3₮48,000Khan-Uul, 15th khoroo
2saraa@gmail.com1₮15,000Sukhbaatar, 1st khoroo
/orders · first to cut if late
localhost:3000/orders
NomNom

My orders

2 Oct · 3 items · ₮48,000pending
1 Oct · 1 item · ₮15,000delivered
Validate
cartat least 1 itemPlace order disabled
quantitynever below 1 (− is disabled at 1)
addressrequired, 5+ charactersEnter a delivery address
statusonly PENDING, DELIVERED, CANCELED (enum on the server)

Done when: a customer places an order and the admin, in another browser, changes it to Delivered.

BufferSat 3 – Sun 4 Oct

Catch up, then demo

  • Sat: migrate orders into order-provider.js: myOrders, allOrders, placeOrder, updateStatus. Same rule as Day 5: the screens must work exactly as before.
  • Finish whatever slipped. Loading and error message on every fetch.
  • Deploy only if everything already works: frontend to Vercel, server to Render.
  • Sun 4 Oct: final demo, the full flow in one go.

Folder structure

The finished layout on Day 7. Green = created after auth is done. Grey notes say what the file holds and which day writes it.

_features

A piece of the page that owns data: it fetches, holds state, or submits. Example: category-sidebar.js loads the list and calls the API.

_components

A small piece that only shows what it is given through props. No fetch, no API call. Example: dish-card.js gets one dish and draws it.

page.js

Puts features side by side. Keep it short.

server/
├── index.js            mounts every router
├── connectDB.js
├── .env                MONGO_URI, JWT_SECRET
├── schemas/
│   ├── user-schema.js     D1
│   ├── food-category.js   D3
│   ├── food.js            D4
│   └── order.js           D6
├── middleware/
│   ├── require-token.js   D1
│   └── require-admin.js   D1
├── controllers/
│   ├── auth/auth.js
│   ├── food-category/     4 files
│   ├── food/              5 files
│   └── order/             4 files
└── router/
    ├── auth/auth.js
    ├── food-category/food-category-router.js
    ├── food/food-router.js
    └── order/order-router.js
src/
├── app/
│   ├── layout.js                             wraps Auth, Category, Dish, Order providers
│   ├── _api/api.js                           axios + token · D2
│   ├── (auth)/
│   │   ├── layout.js                         centers the form · D2
│   │   ├── _components/field-error.js        red text under an input · D2
│   │   ├── login/
│   │   │   ├── page.js
│   │   │   └── _features/login-form.js       D2
│   │   └── signup/
│   │       ├── page.js
│   │       └── _features/signup-form.js      D2
│   ├── (main)/
│   │   ├── layout.js                         Header + CartProvider · D6
│   │   ├── page.js                           home · D6
│   │   ├── _components/
│   │   │   ├── header.js                     logo, Cart (n), Log out · D6
│   │   │   ├── food-card.js                  image, name, price, Add · D6
│   │   │   └── cart-item.js                  name, − qty + · D7
│   │   ├── _features/
│   │   │   ├── category-chips.js             D6
│   │   │   ├── food-grid.js                  D6
│   │   │   └── cart-sheet.js                 items, total, address · D7
│   │   └── orders/
│   │       ├── page.js                       my orders · D7
│   │       └── _components/order-row.js      D7
│   └── admin/
│       ├── layout.js                         sidebar + guard · D3, guard D5
│       ├── _components/
│       │   ├── sidebar.js                    Dishes, Orders · D3
│       │   └── confirm-dialog.js             "Delete X?" · D3
│       ├── dishes/
│       │   ├── page.js                       two columns · D3
│       │   ├── _components/dish-card.js      D4
│       │   └── _features/
│       │       ├── category-sidebar.js       list, add, rename, delete · D3
│       │       ├── dish-grid.js              D4
│       │       ├── dish-form-dialog.js       add D4, edit D5
│       │       └── image-upload.js           Cloudinary · D5
│       └── orders/
│           ├── page.js                       D7
│           ├── _components/status-select.js  D7
│           └── _features/orders-table.js     D7
├── providers/
│   ├── auth-provider.js                      user, token, login, logout · D5
│   ├── category-provider.js                  migrated from Day 3 · D5
│   ├── dish-provider.js                      migrated from Day 4, edit/delete D6
│   ├── cart-provider.js                      D6
│   └── order-provider.js                     migrated from Day 7 · Sat
├── components/ui/                            shadcn
└── lib/utils.js
Delete app/page.js on Day 6. It and (main)/page.js both mean /, and Next.js refuses to build with two.

Four models, three references

ModelFieldsPoints to
Useremail, password (hashed), role, addressnothing
FoodCategorycategoryNamenothing
Foodname, price, image, ingredients, categorycategory → FoodCategory
Orderuser, items[ food, quantity, price ], total, status, addressuser → User, items.food → Food

A reference stores the other document's id. populate() swaps the id for the real document when reading. Order items also copy the price, so changing a food's price later does not change old orders.

15 endpoints

DayRouteAccessMiddleware on the route
1POST /auth/sign-uppublicnone
1POST /auth/loginpublicnone
3GET /food-categorypublicnone
3POST /food-categoryadminrequireToken, requireAdmin
3PUT /food-categoryadminrequireToken, requireAdmin
3DELETE /food-categoryadminrequireToken, requireAdmin
4GET /foodpublicnone
4GET /food/:idpublicnone
4POST /foodadminrequireToken, requireAdmin
4PUT /food/:idadminrequireToken, requireAdmin
4DELETE /food/:idadminrequireToken, requireAdmin
6POST /ordertokenrequireToken
6GET /order/metokenrequireToken
7GET /orderadminrequireToken, requireAdmin
7PATCH /order/:idadminrequireToken, requireAdmin
Order matters: requireToken always runs before requireAdmin. requireToken reads the JWT and puts the user on req.user. requireAdmin only checks req.user.role === "ADMIN", so without the token step it has nothing to check.
router.post("/", requireToken, requireAdmin, createFood)

Public = anyone, no login. Token = any logged-in user, answers 401 without a valid token. Admin = logged in with role ADMIN, answers 401 with no token and 403 for a USER. POST /order and GET /order/me take the user id from the token, never from the request body.

Left out on purpose

Signed Cloudinary uploads (unsigned is fine for class) · password reset · search · settings page · automated tests (Postman is the test) · visual design polish.