Stackvora.tech

Building REST APIs in Node.js with Express

RESTful APIs are the backbone of modern web applications, enabling seamless communication between client and server. Node.js, with its asynchronous,…

RESTful APIs are the backbone of modern web applications, enabling seamless communication between client and server. Node.js, with its asynchronous, event-driven architecture, combined with the Express.js framework, provides a highly efficient and scalable platform for building these APIs. This guide details the process of constructing robust RESTful services using Node.js and Express, covering setup, routing, middleware, and database integration.

We will focus on creating a typical resource-oriented API for managing users, demonstrating common CRUD (Create, Read, Update, Delete) operations. The examples will use a lightweight in-memory data store for simplicity, but the principles extend directly to persistent databases like PostgreSQL or MongoDB.

Setting up the Node.js Project and Express

First, initialize a new Node.js project. Create a directory, navigate into it, and run npm init -y. This generates a package.json file. Next, install Express:

mkdir express-rest-api
cd express-rest-api
npm init -y
npm install express body-parser --save

body-parser is a middleware that parses incoming request bodies in a middleware before your handlers, available under the req.body property. While Express 4.16.0+ includes its own express.json() and express.urlencoded(), body-parser is still widely used and provides more options for parsing different content types. For simplicity in this guide, we'll use express.json().

Create an app.js file (or index.js) and set up your basic Express server:

// app.js
const express = require('express');
const app = express();
const port = 3000;

// Middleware to parse JSON bodies
app.use(express.json());

// In-memory "database" for demonstration
let users = [
    { id: '1', name: 'Alice', email: 'alice@example.com' },
    { id: '2', name: 'Bob', email: 'bob@example.com' }
];
let nextUserId = 3; // For generating unique IDs

app.get('/', (req, res) => {
    res.send('Welcome to the User API!');
});

// Start the server
app.listen(port, () => {
    console.log(`User API listening at http://localhost:${port}`);
});

Run the server using node app.js. You should see "User API listening at http://localhost:3000" in your console. Navigating to http://localhost:3000 in a browser will display "Welcome to the User API!".

Implementing RESTful Endpoints (CRUD Operations)

RESTful APIs typically map HTTP methods to CRUD operations on resources. We'll implement endpoints for our /users resource.

GET /users - Retrieve All Users

This endpoint will fetch a list of all users. No request body or path parameters are needed.

// app.js (add inside your existing app.js)
// GET all users
app.get('/users', (req, res) => {
    res.json(users);
});

Test with curl http://localhost:3000/users or a browser. Expected output:

[{"id":"1","name":"Alice","email":"alice@example.com"},{"id":"2","name":"Bob","email":"bob@example.com"}]

GET /users/:id - Retrieve a Single User

This uses a URL parameter (:id) to specify which user to retrieve. The req.params object contains these parameters.

// app.js (add inside your existing app.js)
// GET a single user by ID
app.get('/users/:id', (req, res) => {
    const user = users.find(u => u.id === req.params.id);
    if (user) {
        res.json(user);
    } else {
        res.status(404).send('User not found');
    }
});

Test with curl http://localhost:3000/users/1. Expected output: {"id":"1","name":"Alice","email":"alice@example.com"}. Test with curl http://localhost:3000/users/99. Expected output: User not found with a 404 status.

POST /users - Create a New User

The client sends user data in the request body, which our express.json() middleware parses into req.body. We assign a new ID and add the user to our in-memory store.

// app.js (add inside your existing app.js)
// POST a new user
app.post('/users', (req, res) => {
    const { name, email } = req.body;
    if (!name || !email) {
        return res.status(400).send('Name and email are required');
    }
    const newUser = { id: String(nextUserId++), name, email };
    users.push(newUser);
    res.status(201).json(newUser); // 201 Created
});

Test with curl -X POST -H "Content-Type: application/json" -d '{"name":"Charlie","email":"charlie@example.com"}' http://localhost:3000/users. Expected output:

{"id":"3","name":"Charlie","email":"charlie@example.com"}

A subsequent GET /users will now include Charlie.

PUT /users/:id - Update an Existing User

PUT requests are used for full replacement of a resource. The request body contains the new state of the user. We find the user by ID and update their properties.

// app.js (add inside your existing app.js)
// PUT update a user by ID
app.put('/users/:id', (req, res) => {
    const userId = req.params.id;
    const { name, email } = req.body;

    const userIndex = users.findIndex(u => u.id === userId);

    if (userIndex !== -1) {
        if (!name || !email) {
            return res.status(400).send('Name and email are required for update');
        }
        users[userIndex] = { ...users[userIndex], name, email };
        res.json(users[userIndex]);
    } else {
        res.status(404).send('User not found');
    }
});

Test with curl -X PUT -H "Content-Type: application/json" -d '{"name":"Charles","email":"charles@example.com"}' http://localhost:3000/users/3. Expected output:

{"id":"3","name":"Charles","email":"charles@example.com"}

PATCH /users/:id - Partially Update an Existing User

PATCH is for partial updates. Only the fields present in the request body are updated.

// app.js (add inside your existing app.js)
// PATCH partially update a user by ID
app.patch('/users/:id', (req, res) => {
    const userId = req.params.id;
    const updates = req.body; // Can contain name or email or both

    const userIndex = users.findIndex(u => u.id === userId);

    if (userIndex !== -1) {
        // Apply updates
        users[userIndex] = { ...users[userIndex], ...updates };
        res.json(users[userIndex]);
    } else {
        res.status(404).send('User not found');
    }
});

Test with curl -X PATCH -H "Content-Type: application/json" -d '{"name":"Chaz"}' http://localhost:3000/users/3. Expected output:

{"id":"3","name":"Chaz","email":"charles@example.com"}

DELETE /users/:id - Delete a User

Removes a user resource based on the provided ID.

// app.js (add inside your existing app.js)
// DELETE a user by ID
app.delete('/users/:id', (req, res) => {
    const userId = req.params.id;
    const initialLength = users.length;
    users = users.filter(u => u.id !== userId);

    if (users.length < initialLength) {
        res.status(204).send(); // 204 No Content
    } else {
        res.status(404).send('User not found');
    }
});

Test with curl -X DELETE http://localhost:3000/users/2. A 204 status means success with no body. A subsequent GET /users will show Bob is gone.

Router Modules for Better Organization

As an API grows, putting all routes in app.js becomes unwieldy. Express Routers allow you to organize routes into modular, mountable route handlers.

Create a routes directory and a users.js file inside it:

// routes/users.js
const express = require('express');
const router = express.Router();

// This 'db' would normally come from a proper database connection pool
// For this example, we'll keep the in-memory store
let users = [
    { id: '1', name: 'Alice', email: 'alice@example.com' },
    { id: '2', name: 'Bob', email: 'bob@example.com' }
];
let nextUserId = 3;

// Middleware specific to this router
router.use((req, res, next) => {
    console.log('Time:', Date.now(), 'Request Type:', req.method, 'Path:', req.originalUrl);
    next();
});

// GET all users
router.get('/', (req, res) => {
    res.json(users);
});

// GET a single user by ID
router.get('/:id', (req, res) => {
    const user = users.find(u => u.id === req.params.id);
    if (user) {
        res.json(user);
    } else {
        res.status(404).send('User not found');
    }
});

// POST a new user
router.post('/', (req, res) => {
    const { name, email } = req.body;
    if (!name || !email) {
        return res.status(400).send('Name and email are required');
    }
    const newUser = { id: String(nextUserId++), name, email };
    users.push(newUser);
    res.status(201).json(newUser);
});

// PUT update a user by ID
router.put('/:id', (req, res) => {
    const userId = req.params.id;
    const { name, email } = req.body;

    const userIndex = users.findIndex(u => u.id === userId);

    if (userIndex !== -1) {
        if (!name || !email) {
            return res.status(400).send('Name and email are required for update');
        }
        users[userIndex] = { id: userId, name, email }; // Ensure ID is preserved
        res.json(users[userIndex]);
    } else {
        res.status(404).send('User not found');
    }
});

// PATCH partially update a user by ID
router.patch('/:id', (req, res) => {
    const userId = req.params.id;
    const updates = req.body;

    const userIndex = users.findIndex(u => u.id === userId);

    if (userIndex !== -1) {
        users[userIndex] = { ...users[userIndex], ...updates };
        res.json(users[userIndex]);
    } else {
        res.status(404).send('User not found');
    }
});

// DELETE a user by ID
router.delete('/:id', (req, res) => {
    const userId = req.params.id;
    const initialLength = users.length;
    users = users.filter(u => u.id !== userId);

    if (users.length < initialLength) {
        res.status(204).send();
    } else {
        res.status(404).send('User not found');
    }
});

module.exports = router;

Then, modify app.js to use this router:

// app.js
const express = require('express');
const app = express();
const port = 3000;

// Import the user routes
const usersRouter = require('./routes/users');

app.use(express.json());

app.get('/', (req, res) => {
    res.send('Welcome to the User API!');
});

// Mount the router at the /users path
app.use('/users', usersRouter);

app.listen(port, () => {
    console.log(`User API listening at http://localhost:${port}`);
});

Notice how the /users prefix is now handled by app.use('/users', usersRouter), so the routes inside users.js are defined relative to that base path (e.g., router.get('/') effectively becomes /users, and router.get('/:id') becomes /users/:id).

Error Handling Middleware

Robust APIs include centralized error handling. Express allows you to define middleware functions specifically for error handling by providing them with four arguments: (err, req, res, next).

// app.js (add this before app.listen)

// Error handling middleware
app.use((err, req, res, next) => {
    console.error(err.stack); // Log the error stack for debugging
    res.status(500).send('Something broke!');
});

If any synchronous route handler throws an error, or if an asynchronous handler calls next(err), this middleware will catch it. For example, to test it, you could add a route that explicitly throws an error:

app.get('/error', (req, res, next) => {
    // Simulate an error
    const error = new Error('This is a test error!');
    error.status = 400; // Custom status if needed
    next(error); // Pass the error to the error handling middleware
});

Accessing http://localhost:3000/error would trigger the error handler and return "Something broke!". More sophisticated error handlers can check err.status or err.name to return different responses.

Connecting to a Real Database (Illustrative Example with Prisma)

Replacing the in-memory array with a persistent database is crucial for real-world applications. Here's a brief illustration using Prisma ORM with a PostgreSQL database. (Full setup of Prisma/PostgreSQL is beyond this guide's scope but assumes you have it configured.)

First, install Prisma Client and Prisma CLI:

npm install @prisma/client
npm install prisma --save-dev
npx prisma init

Modify your prisma/schema.prisma to define your User model:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id    String @id @default(uuid())
  email String @unique
  name  String?
}

Then, run migrations and generate the client:

npx prisma migrate dev --name init
npx prisma generate

Now, your routes/users.js (or a dedicated controller) would look something like this, using the Prisma client:

// routes/users.js (Database-backed example)
const express = require('express');
const router = express.Router();
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient(); // Instantiate Prisma Client

// GET all users
router.get('/', async (req, res) => {
    const users = await prisma.user.findMany();
    res.json(users);
});

// GET a single user by ID
router.get('/:id', async (req, res) => {
    const user = await prisma.user.findUnique({ where: { id: req.params.id } });
    if (user) {
        res.json(user);
    } else {
        res.status(404).send('User not found');
    }
});

// POST a new user
router.post('/', async (req, res, next) => {
    try {
        const { name, email } = req.body;
        if (!name || !email) {
            return res.status(400).send('Name and email are required');
        }
        const newUser = await prisma.user.create({ data: { name, email } });
        res.status(201).json(newUser);
    } catch (error) {
        if (error.code === 'P2002') { // Prisma unique constraint violation code
            return res.status(409).send('User with this email already exists');
        }
        next(error); // Pass other errors to general error handler
    }
});

// PUT update a user by ID
router.put('/:id', async (req, res, next) => {
    try {
        const userId = req.params.id;
        const { name, email } = req.body;
        if (!name || !email) {
            return res.status(400).send('Name and email are required for update');
        }
        const updatedUser = await prisma.user.update({
            where: { id: userId },
            data: { name, email }
        });
        res.json(updatedUser);
    } catch (error) {
        if (error.code === 'P2025') { // Prisma record not found code
            return res.status(404).send('User not found');
        }
        next(error);
    }
});

// DELETE a user by ID
router.delete('/:id', async (req, res, next) => {
    try {
        const userId = req.params.id;
        await prisma.user.delete({ where: { id: userId } });
        res.status(204).send();
    } catch (error) {
        if (error.code === 'P2025') {
            return res.status(404).send('User not found');
        }
        next(error);
    }
});

module.exports = router;

Notice the use of async/await for database operations and specific error handling for Prisma client errors, then falling back to the general error middleware for unhandled exceptions.

Common Pitfalls and Troubleshooting

  • Middleware Order: Express middleware executes in the order they are defined. If express.json() is placed after your route handlers, req.body will be undefined. Similarly, error handling middleware must be defined after all other routes and middleware to catch errors.
  • Asynchronous Errors: Errors thrown in asynchronous code (e.g., inside async/await or callbacks) won't automatically be caught by Express's default error handler. You must explicitly catch them and pass them to next(err), or use an async error handling library like express-async-errors.
  • Missing next(): If a middleware function doesn't call next(), the request will hang, as it won't proceed to subsequent middleware or route handlers.
  • Incorrect HTTP Methods: Ensure you are using the correct HTTP method (GET, POST, PUT, PATCH, DELETE) for the intended operation. Mismatched methods will result in a 404 (Not Found) or 405 (Method Not Allowed).
  • Payload Issues: Always include Content-Type: application/json header when sending JSON data in POST/PUT/PATCH requests. Without it, express.json() might not parse the body correctly.
  • ID Types: Be consistent with ID types (string vs. number) between your client, API, and database. Using String(nextUserId++) for our in-memory store ensured consistency with typical database UUIDs or string primary keys.

Back to the knowledge base · Ask the AI assistant