DEV Community

Cover image for I built a REST API from scratch with no database here's everything I learned
Chinwuba
Chinwuba

Posted on

I built a REST API from scratch with no database here's everything I learned

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
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

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 });
};
Enter fullscreen mode Exit fullscreen mode

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);
});

Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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" });
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)