aslain.dev
0%
01 Hizmetler 02 Hakkımda 03 Projeler 04 Stack 05 Blog 06 İletişim
← Tüm makaleler Web Development

Express API: Building a REST API with Node.js

An Express API is one of the fastest ways to build a backend with Node.js: a handful of lines spins up an HTTP server, answers requests, and splits cleanly into layers as it grows. In this article we'll build a small but real REST API from scratch — organizing routes with routers, collecting shared work in middleware, and finishing with centralized error handling that frees you from scattered try/catch blocks.

Bootstrapping the project

Start with an empty folder and install Node and Express. Node.js 18 or later is recommended; those versions support fetch and modern JavaScript features natively.

mkdir blog-api && cd blog-api
npm init -y
npm install express
npm install --save-dev nodemon

Add "type": "module" to your package.json to enable ES modules so you can use import syntax. Let's also define a script that restarts the server when a file changes during development:

{
  "type": "module",
  "scripts": {
    "dev": "nodemon server.js",
    "start": "node server.js"
  }
}

The first server

The core of an Express app is an app object. We add the express.json() middleware to parse incoming JSON bodies and start with a simple health-check route.

// server.js
import express from "express";

const app = express();
app.use(express.json());

app.get("/health", (req, res) => {
  res.json({ status: "ok", uptime: process.uptime() });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`API running: http://localhost:${PORT}`);
});

Run npm run dev and visit http://localhost:3000/health — you should see a JSON response. That's it; you have an API. Now let's make it ready to grow.

Splitting routes with a Router

Piling every route into server.js works for tiny projects but quickly becomes unreadable. Express's Router object lets you group related routes in a separate file and mount them under a single prefix. Say we're managing a posts resource.

// routes/posts.js
import { Router } from "express";

const router = Router();

const posts = [
  { id: 1, title: "Hello world", body: "First post" }
];

router.get("/", (req, res) => {
  res.json(posts);
});

router.get("/:id", (req, res) => {
  const post = posts.find(p => p.id === Number(req.params.id));
  if (!post) {
    return res.status(404).json({ error: "Post not found" });
  }
  res.json(post);
});

router.post("/", (req, res) => {
  const { title, body } = req.body;
  const post = { id: posts.length + 1, title, body };
  posts.push(post);
  res.status(201).json(post);
});

export default router;

Then mount this router in the main app under the /posts prefix:

import postsRouter from "./routes/posts.js";
app.use("/posts", postsRouter);

Now GET /posts, GET /posts/1 and POST /posts all work. Every new resource (users, comments) gets its own router file, and server.js stays lean.

Middleware: collecting shared work in one place

Middleware is a chain of functions a request passes through before reaching a response. Its signature is (req, res, next); when it's done it calls next() to hand control to the next one. Logging, authentication, rate limiting and other cross-cutting concerns live here. Let's write a simple request logger:

// middleware/logger.js
export function logger(req, res, next) {
  const start = Date.now();
  res.on("finish", () => {
    const ms = Date.now() - start;
    console.log(`${req.method} ${req.originalUrl} ${res.statusCode} - ${ms}ms`);
  });
  next();
}

Add it before all routes and every request is logged automatically:

import { logger } from "./middleware/logger.js";
app.use(logger);

Validation can also be a middleware. For example, a small guard that checks the body before POST /posts:

export function validatePost(req, res, next) {
  const { title } = req.body;
  if (!title || title.trim() === "") {
    return res.status(400).json({ error: "title is required" });
  }
  next();
}

Then attach it to that route only: router.post("/", validatePost, handler). This is the power of middleware — write repeated logic once and plug it in anywhere.

Centralized error handling

One of Express's most useful features is a special four-argument error middleware: (err, req, res, next). This function is defined after all routes and catches any error thrown anywhere. That way you don't have to sprinkle a separate try/catch into every handler.

To make sure errors in async functions reach it, the cleanest approach is a small wrapper (Express 5 forwards async errors automatically, but in 4.x this helper is handy):

// utils/asyncHandler.js
export const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

We can define an error class and throw it comfortably inside asyncHandler:

// utils/ApiError.js
export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

Finally we add the error middleware and a 404 catch-all for unknown routes. These must always come last:

// 404 — no route matched
app.use((req, res) => {
  res.status(404).json({ error: "Resource not found" });
});

// Centralized error handling
app.use((err, req, res, next) => {
  const status = err.status || 500;
  if (status === 500) console.error(err);
  res.status(status).json({ error: err.message || "Server error" });
});

Now a single throw new ApiError(404, "Post not found") inside a handler is enough; this one place handles the rest. In production it's good practice to keep the message generic for 500s, as above, so you don't leak details to the client.

Keeping the structure clean

As the project grows, a simple folder layout keeps things maintainable:

  • routes/ — one router file per resource.
  • controllers/ — the actual business logic that handles a route (keeps the router thin).
  • middleware/ — reusable pieces like logger, auth, validation.
  • utils/ — helpers such as asyncHandler and ApiError.

With this separation the router only answers "which URL goes to which function," and the real work lives in the controller. When you add a database (PostgreSQL or MongoDB, for instance), this structure scales with almost no changes.

Frequently Asked Questions

Couldn't I just use the built-in http module instead of Express?

You could, but you'd have to write routing, body parsing and the middleware chain by hand. Express provides all of that as a thin layer; it's easy to learn and its ecosystem is huge. Even for a small API it saves time.

Why does middleware order matter?

Express runs middleware in the order it's added with app.use. You can't validate a body before express.json() parses it, and the error middleware must come after all routes so it can catch their errors.

Should I use Express 4 or 5?

Express 5 is now stable and automatically forwards errors from async handlers to the error middleware. For new projects you can prefer 5; on existing 4.x projects the asyncHandler pattern above is a safe and common solution.

Want to take your API to the next level? If you need authentication, database integration, or a production-ready Express foundation, we can look at it together. Get in touch with me.

Bu kategorideki tüm yazılar →

Devamı için