Programming Beginner 12 min

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

Express is still the most widely deployed Node.js web framework. It is minimal by design — you get routing, middleware, and an HTTP server. Everything else (validation, auth, database) you add yourself, which means you understand exactly what is in your stack.

This guide builds a working REST API for a /api/users resource: list, read, create, update, delete. We start from scratch, structure the code across routes/ and controllers/ folders, and cover the dev tooling that makes iteration fast. The data layer uses an in-memory array for clarity — swapping it for a real database is the only step that changes when you take this to production.

Step-by-step

  1. 1

    Initialise the project

    Create a new directory, initialise a package.json, and install Express. Install nodemon as a dev dependency for automatic restarts during development. Also install dotenv for environment variable management from day one — you will need it before long.

    bash
    mkdir my-api && cd my-api
    npm init -y
    npm install express dotenv
    npm install --save-dev nodemon
    
    # Add a start script and a dev script to package.json
    # We will do that in the next step
  2. 2

    Configure package.json scripts

    Add the start and dev scripts. The dev script uses nodemon, which watches for file changes and restarts the server automatically. Node.js 18+ also ships a built-in --watch flag — use whichever you prefer.

    json
    {
      "name": "my-api",
      "version": "1.0.0",
      "main": "src/app.js",
      "scripts": {
        "start": "node src/app.js",
        "dev": "nodemon src/app.js"
        // Modern alternative — no nodemon needed:
        // "dev": "node --watch src/app.js"
      },
      "dependencies": {
        "dotenv": "^16.0.0",
        "express": "^4.18.0"
      },
      "devDependencies": {
        "nodemon": "^3.0.0"
      }
    }
  3. 3

    Create the Express entry point

    Create src/app.js. This file boots Express, mounts express.json() (body parser for JSON requests), attaches your routers, and adds a 404 fallback and a central error handler. Keep this file thin — actual logic lives in routes and controllers.

    javascript
    // src/app.js
    require("dotenv").config()
    const express = require("express")
    const usersRouter = require("./routes/users")
    
    const app = express()
    const PORT = process.env.PORT || 3000
    
    // Middleware
    app.use(express.json())
    
    // Routes
    app.use("/api/users", usersRouter)
    
    // 404 fallback
    app.use((req, res) => {
      res.status(404).json({ error: "Not found" })
    })
    
    // Central error handler
    app.use((err, req, res, next) => {
      console.error(err.stack)
      res.status(500).json({ error: "Internal server error" })
    })
    
    app.listen(PORT, () => {
      console.log(`Server running on http://localhost:${PORT}`)
    })
    
    module.exports = app
  4. 4

    Create the users router

    Create src/routes/users.js. Routers group related endpoints. This file maps HTTP methods and paths to controller functions — it contains no business logic itself. Using express.Router() keeps each resource's routes in its own file.

    javascript
    // src/routes/users.js
    const express = require("express")
    const router = express.Router()
    const usersController = require("../controllers/usersController")
    
    router.get("/", usersController.getAll)
    router.get("/:id", usersController.getOne)
    router.post("/", usersController.create)
    router.put("/:id", usersController.update)
    router.delete("/:id", usersController.remove)
    
    module.exports = router
  5. 5

    Write the controller with in-memory storage

    Create src/controllers/usersController.js. This is where the actual request handling lives. The in-memory array is intentionally naive — in a real project replace it with database calls. Controllers should be thin too: validate input (or delegate to a validator), call the data layer, return the response.

    javascript
    // src/controllers/usersController.js
    let users = [
      { id: 1, name: "Alice", email: "alice@example.com" },
      { id: 2, name: "Bob", email: "bob@example.com" },
    ]
    let nextId = 3
    
    exports.getAll = (req, res) => {
      res.json(users)
    }
    
    exports.getOne = (req, res) => {
      const user = users.find((u) => u.id === Number(req.params.id))
      if (!user) return res.status(404).json({ error: "User not found" })
      res.json(user)
    }
    
    exports.create = (req, res) => {
      const { name, email } = req.body
      if (!name || !email) {
        return res.status(400).json({ error: "name and email are required" })
      }
      const user = { id: nextId++, name, email }
      users.push(user)
      res.status(201).json(user)
    }
    
    exports.update = (req, res) => {
      const index = users.findIndex((u) => u.id === Number(req.params.id))
      if (index === -1) return res.status(404).json({ error: "User not found" })
      users[index] = { ...users[index], ...req.body, id: users[index].id }
      res.json(users[index])
    }
    
    exports.remove = (req, res) => {
      const index = users.findIndex((u) => u.id === Number(req.params.id))
      if (index === -1) return res.status(404).json({ error: "User not found" })
      users.splice(index, 1)
      res.status(204).send()
    }
  6. 6

    Test every endpoint

    Start the dev server and smoke-test all five operations. Use curl or import these into Postman.

    bash
    npm run dev
    
    # List all users
    curl http://localhost:3000/api/users
    
    # Get one user
    curl http://localhost:3000/api/users/1
    
    # Create a user
    curl -X POST http://localhost:3000/api/users \
      -H "Content-Type: application/json" \
      -d '{"name":"Carol","email":"carol@example.com"}'
    
    # Update a user
    curl -X PUT http://localhost:3000/api/users/3 \
      -H "Content-Type: application/json" \
      -d '{"name":"Caroline"}'
    
    # Delete a user
    curl -X DELETE http://localhost:3000/api/users/3
  7. 7

    Use proper HTTP status codes

    Consistent status codes are what separates a professional API from a toy. Follow these conventions in every handler:

    • 200 OK — successful GET, PUT, PATCH
    • 201 Created — successful POST that created a resource
    • 204 No Content — successful DELETE (no body)
    • 400 Bad Request — malformed input, missing required fields
    • 404 Not Found — resource does not exist
    • 422 Unprocessable Entity — input is valid JSON but fails business validation
    • 500 Internal Server Error — unhandled exception (should rarely reach the client)
  8. 8

    Add project structure for scale

    The current flat layout works for one or two resources. As the project grows, organise it like this. The database layer is separated from the controller so you can swap storage without touching route logic.

    bash
    src/
    ├── app.js            # Entry point — boot Express, mount routers
    ├── routes/
    │   ├── users.js      # Route definitions for /api/users
    │   └── posts.js      # Route definitions for /api/posts
    ├── controllers/
    │   ├── usersController.js
    │   └── postsController.js
    ├── models/           # Database access layer (queries, ORM calls)
    │   ├── User.js
    │   └── Post.js
    ├── middleware/
    │   ├── auth.js       # JWT verification
    │   └── validate.js   # Input validation wrapper
    └── config/
        └── db.js         # Database connection setup

Tips & gotchas

  • Mount `express.json()` before your routes, not after — middleware runs in order. Forgetting this is why `req.body` is sometimes `undefined`.
  • Always handle the case where `req.params.id` is not a valid number before doing a database lookup — a non-numeric ID can cause unexpected query errors.
  • Use `express.Router()` for every resource. Grouping routes in the same `app.js` works for demos but becomes unmanageable past three or four endpoints.
  • When replacing in-memory storage with a database, keep the controller signatures the same — only the model functions change. This makes the refactor mechanical.

Wrapping up

Express gives you a routing layer and a middleware pipeline. Everything you build on top — controllers, models, validation — is plain JavaScript. That simplicity is a feature: you can read the entire stack without framework magic getting in the way. Once you are comfortable with this structure, adding a database, authentication, and input validation is a matter of dropping in the right libraries in the right layers.

#Node.js #Express #API
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.