How to Build a REST API with Node.js and Express: Step-by-Step Tutorial
Programming

How to Build a REST API with Node.js and Express: Step-by-Step Tutorial

6 min read
76 Views
Share:

Building a REST API is one of the most in-demand skills in web development. In this tutorial, we'll create a complete API from scratch with Node.js and Express — including CRUD operations, input validation, error handling, authentication middleware, and professional project structure.

What we're building

A task management REST API with:

  • Full CRUD (Create, Read, Update, Delete)
  • Input validation middleware
  • Centralized error handling
  • API Key authentication
  • Professional folder structure
  • Environment variables with dotenv

Prerequisites

You need Node.js 18+ installed:

node --version   # v20.x.x or higher
npm --version    # 10.x.x or higher

Step 1: Initialize the project

mkdir todo-api && cd todo-api
npm init -y
npm install express dotenv uuid
npm install -D nodemon

Update package.json scripts:

{
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js"
  }
}

Step 2: Project structure

todo-api/
├── src/
│   ├── index.js              # Entry point
│   ├── app.js                # Express configuration
│   ├── routes/
│   │   └── tasks.js          # Task routes
│   ├── controllers/
│   │   └── tasksController.js
│   ├── middleware/
│   │   ├── auth.js           # Authentication
│   │   ├── errorHandler.js   # Error handling
│   │   └── validator.js      # Validation
│   └── data/
│       └── store.js          # In-memory storage
├── .env
└── package.json

Step 3: Express setup

Create .env:

PORT=3000
API_KEY=my-secret-key-2026
NODE_ENV=development

src/app.js:

const express = require('express');
const taskRoutes = require('./routes/tasks');
const errorHandler = require('./middleware/errorHandler');

const app = express();

app.use(express.json());

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.use('/api/tasks', taskRoutes);

app.use('*', (req, res) => {
  res.status(404).json({ error: 'Endpoint not found', path: req.originalUrl });
});

app.use(errorHandler);

module.exports = app;

src/index.js:

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

app.listen(PORT, () => {
  console.log(`API running on http://localhost:${PORT}`);
});

Step 4: Data store

src/data/store.js — In a real project, replace this with Prisma or Mongoose:

const { v4: uuidv4 } = require('uuid');

let tasks = [
  {
    id: uuidv4(),
    title: 'Learn Node.js',
    description: 'Complete the REST API tutorial',
    done: false,
    priority: 'high',
    createdAt: new Date().toISOString(),
  },
];

const store = {
  getAll: () => tasks,
  getById: (id) => tasks.find((t) => t.id === id),
  create: (data) => {
    const task = { id: uuidv4(), ...data, done: false, createdAt: new Date().toISOString() };
    tasks.push(task);
    return task;
  },
  update: (id, data) => {
    const i = tasks.findIndex((t) => t.id === id);
    if (i === -1) return null;
    tasks[i] = { ...tasks[i], ...data, updatedAt: new Date().toISOString() };
    return tasks[i];
  },
  delete: (id) => {
    const i = tasks.findIndex((t) => t.id === id);
    if (i === -1) return false;
    tasks.splice(i, 1);
    return true;
  },
};

module.exports = store;

Step 5: Controller

src/controllers/tasksController.js:

const store = require('../data/store');

const tasksController = {
  list: (req, res) => {
    let tasks = store.getAll();
    if (req.query.done !== undefined) {
      tasks = tasks.filter((t) => t.done === (req.query.done === 'true'));
    }
    if (req.query.priority) {
      tasks = tasks.filter((t) => t.priority === req.query.priority);
    }
    res.json({ total: tasks.length, data: tasks });
  },

  get: (req, res) => {
    const task = store.getById(req.params.id);
    if (!task) return res.status(404).json({ error: 'Task not found' });
    res.json(task);
  },

  create: (req, res) => {
    const task = store.create({
      title: req.body.title,
      description: req.body.description || '',
      priority: req.body.priority || 'medium',
    });
    res.status(201).json(task);
  },

  update: (req, res) => {
    const task = store.update(req.params.id, req.body);
    if (!task) return res.status(404).json({ error: 'Task not found' });
    res.json(task);
  },

  delete: (req, res) => {
    if (!store.delete(req.params.id)) {
      return res.status(404).json({ error: 'Task not found' });
    }
    res.json({ message: 'Task deleted' });
  },
};

module.exports = tasksController;

Step 6: Middleware

src/middleware/validator.js:

const validateTask = (req, res, next) => {
  const { title, priority } = req.body;
  const errors = [];

  if (!title || typeof title !== 'string') {
    errors.push('Title is required and must be a string');
  } else if (title.trim().length < 3 || title.trim().length > 200) {
    errors.push('Title must be between 3 and 200 characters');
  }

  const validPriorities = ['low', 'medium', 'high'];
  if (priority && !validPriorities.includes(priority)) {
    errors.push(`Priority must be one of: ${validPriorities.join(', ')}`);
  }

  if (errors.length > 0) return res.status(400).json({ errors });
  req.body.title = title.trim();
  next();
};

module.exports = { validateTask };

src/middleware/auth.js:

const authenticate = (req, res, next) => {
  const apiKey = req.headers['x-api-key'];
  if (!apiKey) return res.status(401).json({ error: 'API Key required' });
  if (apiKey !== process.env.API_KEY) return res.status(403).json({ error: 'Invalid API Key' });
  next();
};

module.exports = authenticate;

src/middleware/errorHandler.js:

const errorHandler = (err, req, res, next) => {
  console.error(`[ERROR] ${req.method} ${req.path}:`, err.message);
  if (err.type === 'entity.parse.failed') {
    return res.status(400).json({ error: 'Invalid JSON in request body' });
  }
  res.status(err.status || 500).json({
    error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
  });
};

module.exports = errorHandler;

Step 7: Routes

src/routes/tasks.js:

const express = require('express');
const router = express.Router();
const controller = require('../controllers/tasksController');
const auth = require('../middleware/auth');
const { validateTask } = require('../middleware/validator');

// Public routes (read-only)
router.get('/', controller.list);
router.get('/:id', controller.get);

// Protected routes (require API Key)
router.post('/', auth, validateTask, controller.create);
router.put('/:id', auth, validateTask, controller.update);
router.delete('/:id', auth, controller.delete);

module.exports = router;

Step 8: Testing the API

npm run dev
# List all tasks
curl http://localhost:3000/api/tasks

# Create a task (requires API Key)
curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: my-secret-key-2026" \
  -d '{"title": "Learn Express", "priority": "high"}'

# Filter tasks
curl "http://localhost:3000/api/tasks?done=false&priority=high"

# Test validation error
curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: my-secret-key-2026" \
  -d '{"title": "ab"}'

# Test missing auth
curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "No API Key"}'

HTTP status codes reference

CodeMeaningWhen to use
200OKSuccessful read or update
201CreatedResource created successfully
400Bad RequestInvalid input data
401UnauthorizedMissing authentication
403ForbiddenInvalid credentials
404Not FoundResource doesn't exist
500Internal ErrorUnexpected server error

Full reference: MDN HTTP Status Codes.

Best practices for REST APIs

  1. Use plural nouns: /api/tasks not /api/getTask.
  2. Version your API: /api/v1/tasks for future compatibility.
  3. Always validate input: For larger projects, use Zod or Joi.
  4. Consistent responses: Always return JSON with the same structure.
  5. Use a proper logger: Pino or Winston instead of console.log.
  6. Rate limiting: Protect with express-rate-limit.
  7. Documentation: Use Swagger/OpenAPI to document endpoints.

Next steps

  • Replace in-memory store with a real database using Prisma
  • Add JWT authentication with jsonwebtoken
  • Write tests with Jest and Supertest
  • Containerize with Docker for consistent deployments
J
Written by
Jesús García

Apasionado por la tecnologia y las finanzas personales. Escribo sobre innovacion, inteligencia artificial, inversiones y estrategias para mejorar tu economia. Mi objetivo es hacer que temas complejos sean accesibles para todos.

Share post:

Related posts

Comments

Leave a comment

Recommended Tools

The ones we use in our projects

Affiliate links. No extra cost to you.

Need technology services?

We offer comprehensive web development, mobile apps, consulting, and more.

Web Development Mobile Apps Consulting