I'm 2 weeks into learning Express.js. No bootcamp, no course. Just a roadmap, a goal, and 2-3 hours a day.
This week I hit my Phase 2 checkpoint: build a REST API with at least 5 routes, no database, using in-memory arrays. I built a Velto Projects API — a backend for my real estate web design agency.
Here's everything I learned, including the bugs I hit and why they happened.
The folder structure
Before writing a single line of code, I set up this structure:
velto-api/
├── src/
│ ├── routes/
│ │ └── projects.js
│ ├── middleware/
│ │ ├── logger.js
│ │ └── errorHandler.js
│ ├── data/
│ │ └── store.js
│ └── app.js
├── .env
├── .gitignore
└── package.json
Most beginners throw everything into one file. That works until it doesn't. This structure means each file has one job. When I add Prisma in Phase 3, I only touch store.js and route logic. Everything else stays.
The in-memory store
js
const projects = [
{ id: 1, client: "Apex Realty", status: "in_progress", stage: "design", budget: 2500 },
{ id: 2, client: "Pinnacle Homes", status: "completed", stage: "delivered", budget: 4000 },
];
module.exports = { projects };
Node.js caches modules. Every file that imports this array is referencing the same object in memory. So when a route mutates it, every other file sees the change instantly. That's your fake database behavior right there.
Custom middleware
Logger first:
js
const logger = (req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
};
The next() call is everything. Without it the request dies here. Middleware is a chain — each link decides whether to pass the request forward or kill it.
Error handler:
js
const errHandler = (err, req, res, next) => {
const statusCode = err.status || 500;
const errMsg = err.message || "Something went wrong";
res.status(statusCode).json({ statusCode, errorMessage: errMsg });
};
Notice the 4-argument signature — err, req, res, next. That's how Express identifies this as an error handler, not regular middleware. The order matters. err must come first.
The key thing I learned here: always set the HTTP status code with .status() AND include it in the body. The HTTP status is what Postman and browsers read in the header. The body is what your frontend reads. You need both.
The routes
GET all projects with optional filtering:
js
router.get("/", (req, res) => {
const status = req.query.status;
const result = status ? projects.filter((i) => i.status === status) : projects;
res.status(200).json(result);
});
The ternary removes the need for an if/else entirely. When the logic is this simple, one line is cleaner than four.
GET single project:
js
router.get("/:id", (req, res, next) => {
const found = projects.find((i) => i.id === Number(req.params.id));
if (!found) {
return next({ status: 404, message: "Project not found" });
}
res.status(200).json(found);
});
req.params.id comes in as a string. Your IDs are numbers. Direct comparison will always fail. Convert with Number() first.
POST a new project:
js
router.post("/", (req, res, next) => {
const { client, budget, status, stage } = req.body;
if (!client || !budget) {
return next({ status: 400, message: "Client and budget are required" });
}
const newProject = { id: projects.length + 1, client, budget, status, stage };
projects.push(newProject);
res.status(201).json(newProject);
});
Validate before you build. And 201 not 200 — 201 means something was created.
PUT update a project:
js
router.put("/:id", (req, res, next) => {
const projectIndex = projects.findIndex((i) => i.id === Number(req.params.id));
if (projectIndex === -1) {
return next({ status: 404, message: "Project not found" });
}
const updated = { ...projects[projectIndex], ...req.body };
projects[projectIndex] = updated;
res.status(200).json(updated);
});
The spread operator is the cleanest way to merge objects. { ...existing, ...incoming } means: take everything from the existing project, then overwrite only the fields that were sent. Fields not included in the request body stay untouched.
DELETE a project:
js
router.delete("/:id", (req, res, next) => {
const projectIndex = projects.findIndex((i) => i.id === Number(req.params.id));
if (projectIndex === -1) {
return next({ status: 404, message: "Project not found" });
}
projects.splice(projectIndex, 1);
res.status(200).json({ message: "Project deleted successfully" });
});
splice() mutates the array in place. Don't reassign it. Just call it and let it do its job.
Wiring it all together in app.js
js
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const logger = require("./middleware/logger");
const errorHandler = require("./middleware/errorHandler");
const projectsRouter = require("./routes/projects");
const app = express();
const PORT = process.env.PORT || 5000;
app.use(cors({ origin: "*" }));
app.use(express.json());
app.use(logger);
app.use("/projects", projectsRouter);
app.use(errorHandler);
app.listen(PORT, () => console.log(`App is running on ${PORT}`));
module.exports = app;
The order here is intentional. express.json() must come before your routes — without it req.body is always undefined. errorHandler must come last — it only catches errors that fall through from routes above it.
The bugs that taught me the most
return next(err) vs next(err) — if you don't return, execution continues after passing the error. Your route will try to send two responses and crash.
app.use(logger) vs app.use(logger()) — passing without parentheses hands Express the function to call later. With parentheses you're calling it immediately and passing the return value, which is undefined.
const { projects } = require("../data/store") vs const projects = require("../data/store") — the second one imports the whole module object. Calling .find() on an object will always fail.
What's next
Phase 3: PostgreSQL, Prisma ORM, JWT authentication. Same structure, real database. The in-memory array gets replaced.
Top comments (0)