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
| Code | Meaning | When to use |
|---|---|---|
200 | OK | Successful read or update |
201 | Created | Resource created successfully |
400 | Bad Request | Invalid input data |
401 | Unauthorized | Missing authentication |
403 | Forbidden | Invalid credentials |
404 | Not Found | Resource doesn't exist |
500 | Internal Error | Unexpected server error |
Full reference: MDN HTTP Status Codes.
Best practices for REST APIs
- Use plural nouns:
/api/tasksnot/api/getTask. - Version your API:
/api/v1/tasksfor future compatibility. - Always validate input: For larger projects, use Zod or Joi.
- Consistent responses: Always return JSON with the same structure.
- Use a proper logger: Pino or Winston instead of console.log.
- Rate limiting: Protect with express-rate-limit.
- 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