DEV Community

Abdullah Sheikh
Abdullah Sheikh

Posted on

How to Build a REST API with Node.js and Express from Scratch

Create a production‑ready API step‑by‑step and deploy it in under an hour

Before We Start: What You'll Walk Away With

By the end of this guide you’ll have a ready‑to‑run REST API that you built from the ground up.

First you’ll set up a tidy project folder, run npm init, add ESLint for clean code, and drop a proper .gitignore so nothing unwanted gets committed.

Next you’ll write routes, middleware, validation, and error handling that follow the same rules you’d use when ordering food—a clear menu, a way to confirm the order, and a system to handle any mix‑ups.

Finally you’ll run the API locally, wrap it in a Docker container, and push it to a free host, giving you a deployable service you can share with teammates or clients.

  • Scaffold the project with npm and configure ESLint.

  • Implement REST‑style endpoints, validation, and centralized error handling.

  • Test locally, containerize with Docker, and deploy to a free platform.

  • Folder layout: src/ for code, test/ for tests, config/ for env settings.

  • Middleware chain: request logger → validator → route handler → error catcher.

  • Docker tip: keep the image under 100 MB by using node:alpine as the base.

This roadmap gives you a solid foundation to build REST API Node.js projects without the usual guesswork.

Ready to start the first step?

What a REST API Actually Is (No Jargon)

REST API is simply a collection of URLs that other programs can call to read or change data. Each URL, called an endpoint, listens for standard HTTP verbs—GET to fetch, POST to create, PUT to replace, and DELETE to remove. The server always answers with a predictable format, typically JSON, so the caller knows exactly what to expect.

Think of it like a restaurant menu. The menu lists dishes (endpoints) and tells you what ingredients you’ll get. When you order GET /menu/pizza, the kitchen (server) returns a description of the pizza. If you want a custom pizza, you send POST /order with your toppings, and the kitchen prepares it for you. No matter how many times you place the same order, you receive the same style of response.

Because the menu never changes its layout, you can walk into any branch of the restaurant and order the same dish without confusion. That stability is what makes a REST API reliable for apps, mobile phones, or other services that need to share data.

The 4 Mistakes Everyone Makes With REST APIs

Don’t let these four slip‑ups derail your first build REST API Node.js project.

  • Mixing business logic into route handlers. Think of a restaurant where the chef also takes orders, collects payment, and cleans tables. When the chef gets tangled in non‑cooking tasks, the kitchen slows down and you can’t easily test a single dish. In Express, putting database queries or calculations straight inside app.get('/users') makes unit tests a nightmare.

  • Ignoring proper HTTP status codes. It’s like a GPS that always says “You have arrived” even when you’re still on the highway. Clients can’t tell if a request succeeded, failed, or needs retrying when you always return 200 or default HTML pages.

  • Skipping input validation. Imagine packing a suitcase without checking the airline’s weight limit; you’ll end up paying extra fees or having items rejected. Without validating req.body you expose your API to malformed data, SQL injection, and hard‑to‑track bugs.

  • Forgetting a consistent error‑handling layer. It’s like a storefront that shows a generic “Something went wrong” page instead of a clear error message. Without a centralized errorHandler middleware, crashes bubble up as HTML, breaking the JSON contract your clients expect.

Spot these early, and your API will stay clean, testable, and reliable.

How to Build a REST API with Node.js and Express: Step‑By‑Step

Run npm init -y to create a default package.json, then install the core tools:

npm install express dotenv joi
Enter fullscreen mode Exit fullscreen mode

Think of this as ordering the basic ingredients before you start cooking.

Lay out a tidy folder tree so you always know where to find things:

mkdir -p src/{routes,controllers,middleware,models}
Enter fullscreen mode Exit fullscreen mode

It’s like packing a suitcase: each type of item gets its own compartment.

Create src/app.js. Load environment variables, add express.json() for body parsing, and attach the main router.

require('dotenv').config();
const express = require('express');
const userRouter = require('./routes/userRoutes');
const app = express();

app.use(express.json());
app.use('/api/users', userRouter);

module.exports = app;
Enter fullscreen mode Exit fullscreen mode

Define a router for a resource (e.g., users) in src/routes/userRoutes.js using express.Router(). Add the five CRUD endpoints.

const router = require('express').Router();
const userController = require('../controllers/userController');
const { validateUser } = require('../middleware/validation');

router.get('/', userController.getAll);
router.get('/:id', userController.getOne);
router.post('/', validateUser, userController.create);
router.put('/:id', validateUser, userController.update);
router.delete('/:id', userController.remove);

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

Move the actual work into src/controllers/userController.js. For a quick start, use an in‑memory array named users. Example for the fictional developer Alice:

let users = [];

exports.create = (req, res) => {
  const newUser = { id: Date.now(), ...req.body };
  users.push(newUser);
  res.status(201).json(newUser);
};

exports.getAll = (req, res) => {
  res.json(users);
};
Enter fullscreen mode Exit fullscreen mode

Add validation middleware in src/middleware/validation.js with joi to guard the request body before it reaches the controller.

const Joi = require('joi');

const schema = Joi.object({
  name: Joi.string().min(2).required(),
  email: Joi.string().email().required()
});

exports.validateUser = (req, res, next) => {
  const { error } = schema.validate(req.body);
  if (error) return res.status(400).json({ message: error.details[0].message });
  next();
};
Enter fullscreen mode Exit fullscreen mode

Create a global error‑handler in src/middleware/errorHandler.js that catches thrown errors and sends a consistent JSON payload.

module.exports = (err, req, res, next) => {
  const status = err.status || 500;
  res.status(status).json({ error: err.message });
};
Enter fullscreen mode Exit fullscreen mode

Attach it in app.js after all routes: app.use(require('./middleware/errorHandler'));

Spin up the server with src/server.js. Import the Express app and listen on process.env.PORT (default 3000).

const app = require('./app');
const port = process.env.PORT || 3000;

app.listen(port, () => console.log(`Server running on ${port}`));
Enter fullscreen mode Exit fullscreen mode

Write a few Jest + SuperTest specs in tests/user.test.js to verify each endpoint returns the expected status and data.

const request = require('supertest');
const app = require('../src/app');

test('POST /api/users creates a user', async () => {
  const res = await request(app).post('/api/users').send({name:'Bob',email:'bob@example.com'});
  expect(res.statusCode).toBe(201);
  expect(res.body).toHaveProperty('id');
});
Enter fullscreen mode Exit fullscreen mode

Dockerize the project. A one‑line Dockerfile copies the source, installs deps, and runs node src/server.js. Pair it with a docker-compose.yml that maps port 3000.

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node","src/server.js"]
Enter fullscreen mode Exit fullscreen mode
version: '3'
services:
  api:
    build: .
    ports:
      - "3000:3000"
    env_file: .env
Enter fullscreen mode Exit fullscreen mode

A Real Example: Building a Todo List API for “Alex the Freelancer”

Alex the freelancer wants a tiny API that lets him add, view, finish, and erase tasks—just like a personal notebook you can reach from any device.

  • Create the router file src/routes/todos.js and register it in src/app.js:
const express = require('express');
const router = express.Router();
const todoController = require('../controllers/todoController');

router.get('/', todoController.getAll);
router.post('/', todoController.create);
router.put('/:id', todoController.update);
router.delete('/:id', todoController.remove);

module.exports = router;
Enter fullscreen mode Exit fullscreen mode
  • Write the controller that talks to the in‑memory store src/models/todoStore.js:
const { todos } = require('../models/todoStore');
const Joi = require('joi');

const schema = Joi.object({
  title: Joi.string().min(1).required()
});

exports.getAll = (req, res) => {
  res.json(todos);
};

exports.create = (req, res, next) => {
  const { error, value } = schema.validate(req.body);
  if (error) return next(error);
  const newTodo = { id: Date.now().toString(), title: value.title, completed: false };
  todos.push(newTodo);
  res.status(201).json(newTodo);
};

exports.update = (req, res, next) => {
  const todo = todos.find(t => t.id === req.params.id);
  if (!todo) return next({ status: 404, message: 'Todo not found' });
  todo.completed = true;
  res.json(todo);
};

exports.remove = (req, res, next) => {
  const index = todos.findIndex(t => t.id === req.params.id);
  if (index === -1) return next({ status: 404, message: 'Todo not found' });
  todos.splice(index, 1);
  res.status(204).end();
};
Enter fullscreen mode Exit fullscreen mode
  • Set up a global error handler in src/app.js so missing IDs return a clean JSON message:
app.use((err, req, res, next) => {
  const status = err.status || 500;
  const message = err.message || 'Internal Server Error';
  res.status(status).json({ error: message });
});
Enter fullscreen mode Exit fullscreen mode
  • Validation: joi guarantees every new task has a non‑empty title, just like a restaurant checks your order isn’t blank.

  • Testing: Run npm test. The suite posts a todo then fetches it, confirming the flow works.

  • Docker: Build with docker build -t alex/todo-api . and launch via docker compose up. Alex now sees his API live on localhost:3000.

With these pieces in place, Alex can finally focus on his projects instead of wrestling with a broken backend.

The Tools That Make This Easier

Grab the right gear before you start building your REST API with Node.js.

  • VS Code + ESLint & Prettier – Think of ESLint as a spell‑checker for your code and Prettier as the auto‑formatter that keeps everything neat, just like a kitchen drawer organized by size.

  • Postman (free tier) – It’s the “menu” you use to order a request, see the response, and then print the receipt as documentation. Perfect for sanity‑checking each endpoint.

  • Docker Desktop – Packs your API into a portable container the way a suitcase bundles all your travel gear, so it runs the same on any machine.

  • Jest + SuperTest – Jest runs the test suite, while SuperTest acts like a delivery driver that quickly knocks on each route to confirm it’s open.

  • Render.com (free hobby plan) – Deploys your service with a single click, similar to dropping a finished dish onto a table for guests to enjoy.

With these tools in place, the rest of the process feels less like a guess‑work project and more like following a well‑marked trail.

Quick Reference: REST API with Node.js Cheat Sheet

Grab this cheat sheet and keep it beside your editor; it captures every step you need to build REST API Node.js without hunting through paragraphs.

  • Initialize project – run npm init -y, then npm install express dotenv joi jest supertest. Think of it as ordering the basic ingredients before cooking.

  • Folder layout – create src/{routes,controllers,middleware,models}. It’s like packing a suitcase: each compartment holds a specific type of item.

  • app.js – set up express.json(), mount the main router, and attach a centralized error handler. This file is the entry gate, similar to a lobby directing visitors.

  • Routes – define GET, POST, PUT, DELETE with express.Router(). Each route is a street sign pointing to a destination.

  • Controllers – pure functions that handle the request and response; keep DB calls out of the router. For example, Alex the developer writes:

const getUser = async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) return res.status(404).json({ error: 'User not found' });
    res.json(user);
  } catch (err) {
    next(err);
  }
};
Enter fullscreen mode Exit fullscreen mode
  • Validation – craft a joi schema and plug it into a middleware function. It works like a bouncer checking IDs before letting anyone in.

  • Error handling – one middleware catches all errors and returns { error, message }. Centralized handling is the fire alarm that alerts everyone at once.

  • Tests – write jest suites with supertest for each route. Think of them as quality checks before shipping a product.

  • Dockerfile – use FROM node:20-alpine, copy src, run npm ci, then CMD ["node","src/server.js"]. It’s the sealed container you’d load onto a truck.

  • Deploy – push the Docker image to Render (or similar), set the PORT env variable, and your API goes live.

Keep this list open; you’ll reference it each time you spin up a new service.

What to Do Next

Pick one of these upgrades and start expanding your API right away.

Add a real database – swap the in‑memory array for PostgreSQL using Prisma. Think of it like moving from a kitchen counter pantry to a full‑size fridge: you can store more, keep things fresh, and retrieve exactly what you need later.

  • Install prisma and run npx prisma init.

  • Define a Todo model in schema.prisma.

  • Generate the client and replace your current CRUD functions with prisma.todo.findMany(), prisma.todo.create(), etc.

Implement JWT authentication so only registered users can manage their todos. It's like giving each diner a badge that lets them order from the kitchen; without the badge, the kitchen stays closed.

  • Install jsonwebtoken and bcrypt.

  • Create /register and /login routes that hash passwords and issue a token.

  • Add a middleware that verifies the token on protected routes.

Write OpenAPI (Swagger) documentation and generate client SDKs with Swagger Codegen. Imagine handing a Google Maps sheet to a delivery driver; the map tells them every turn without guessing.

  • Create swagger.yaml describing your endpoints.

  • Serve it with swagger-ui-express at /api-docs.

  • Run swagger-codegen generate -i swagger.yaml -l javascript -o ./client-sdk to get a ready‑to‑use client.

Which of these steps are you tackling next? Let me know in the comments!



About the Author

Abdullah Sheikh is the Founder & CEO at Exteed, where he leads a team of skilled developers specializing in Web2 and Web3 applications, Custom Smart Contracts, and Blockchain solutions.

With 6+ years of experience, Abdullah has built CRMs, Crypto Wallets, DeFi Exchanges, E-Commerce Stores, HIPAA Compliant EMR Systems, and AI-powered systems that drive business efficiency and innovation.

His expertise spans Blockchain, Crypto & Tokenomics, Artificial Intelligence, and Web Applications; building reliable and smooth web apps that fit the client’s goals and requirements.

📧 info@abdullah-sheikh.com · 🔗 LinkedIn · 🌐 abdullah-sheikh.com

Top comments (0)