I still remember the first time I tried to build an API with Node.js. I had three different tutorials open, all of them doing things differently. One used callbacks everywhere. One had no folder structure. One had no authentication at all.
By the end I had something that worked locally and fell apart the moment a second user tried to log in.
This guide is the tutorial I wish existed back then. We are going to build a real, production-ready REST API from scratch — with proper folder structure, JWT authentication, input validation, rate limiting, and error handling. Not a toy. Not a "hello world" server. Something you can actually deploy and build on.
By the end you will have a fully working User Management API with these endpoints:
POST /api/auth/register— create a new userPOST /api/auth/login— log in and get a JWT tokenGET /api/users— get all users (protected route)GET /api/users/:id— get a single userPUT /api/users/:id— update a userDELETE /api/users/:id— delete a user
Let's build it.
🔗Also Read:-NextAuth.js complete guide: 12 Powerful Steps to Master Authentication
What You Need Before Starting
You should have Node.js version 20 or higher installed. Check your version by running node --version in your terminal. You also need npm (comes with Node) and a tool to test API endpoints — either Postman, Insomnia, or the VS Code REST Client extension.
That is it. No database setup required — we are using an in-memory data store to keep this tutorial focused on the API itself. Once you understand the pattern, swapping in MongoDB or PostgreSQL is straightforward.
What Is a REST API and Why Node.js?
Before we write a single line of code, a quick foundation check.
A REST API (Representational State Transfer) is a way for different software systems to talk to each other over HTTP. Your frontend sends a request to a URL. Your API reads that request, does something (fetch data, save data, delete data), and sends back a response — almost always in JSON format.
REST uses standard HTTP methods to describe what action you want to take:
GET means read. You want to retrieve data. GET /users gets a list of users. GET /users/42 gets one specific user.
POST means create. You are sending new data to the server. POST /users creates a new user.
PUT means update. You are replacing existing data. PUT /users/42 updates user 42.
DELETE means remove. DELETE /users/42 deletes user 42.
Simple. Predictable. Any frontend — React, Vue, mobile app, another server — can talk to your API using these patterns.
Node.js uses a non-blocking execution model that allows the system to continue processing incoming requests while waiting for background operations to complete. This makes it a practical choice for building scalable APIs that must handle thousands of concurrent connections. In plain English: Node.js is fast, handles many users at once without breaking a sweat, and uses JavaScript — the same language you probably already know from the frontend.
Step 1 — Project Setup
Open your terminal and create a new project folder:
bash
mkdir nodejs-rest-api-2026
cd nodejs-rest-api-2026
npm init -yNow install the packages we need:
bash
npm install express bcryptjs jsonwebtoken express-validator express-rate-limit cors helmet dotenv
npm install --save-dev nodemonHere is what each package does, in plain English:
express — the framework that makes building APIs simple. Handles routing, middleware, request and response objects.
bcryptjs — hashes passwords before saving them. Nobody — including you — should ever store plain text passwords. Ever.
jsonwebtoken — creates and verifies JWT tokens for authentication.
express-validator — validates and sanitizes user input before it touches your database.
express-rate-limit — prevents abuse by limiting how many requests an IP can make in a time window.
cors — allows your API to accept requests from different domains (necessary for any frontend app).
helmet — adds security-related HTTP headers automatically. One line of code, much better security.
dotenv — loads environment variables from a .env file so secrets never go into your code.
nodemon — restarts your server automatically when you save a file. Huge time saver during development.
Step 2 — Folder Structure
This is where most tutorials let you down. They dump everything in one file and call it done. That works for 50 lines. It falls apart at 500.
Here is the structure we will use — the same pattern used in real production Node.js apps:
nodejs-rest-api-2026/
├── src/
│ ├── controllers/
│ │ ├── authController.js
│ │ └── userController.js
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── errorHandler.js
│ │ └── rateLimiter.js
│ ├── routes/
│ │ ├── authRoutes.js
│ │ └── userRoutes.js
│ ├── validators/
│ │ └── userValidator.js
│ ├── data/
│ │ └── users.js
│ └── app.js
├── .env
├── .gitignore
├── package.json
└── server.jsControllers handle the logic for each route — what actually happens when a request comes in.
Middleware are functions that run between the request arriving and the controller handling it. Auth checks, rate limiting, error handling — all middleware.
Routes define which URL maps to which controller function.
Validators check that incoming data is valid before the controller even sees it.
By practicing the principles of separation of concern and modular architecture, we can ensure our Express APIs are robust and flexible. This structure makes it easy to find any piece of code, test it in isolation, and add new features without breaking existing ones.
Step 3 — Environment Variables
Create a .env file in your project root. This file holds secrets and config that should never be committed to GitHub:
PORT=3000
JWT_SECRET=your-super-secret-key-change-this-in-production-make-it-long
JWT_EXPIRES_IN=7d
NODE_ENV=developmentCreate a .gitignore file to make sure .env never accidentally gets pushed:
node_modules/
.envChange your package.json scripts section to:
json
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}Step 4 — The Main App File
Create src/app.js. This is where Express gets configured — middleware applied, routes connected, everything wired up:
javascript
const express = require('express')
const cors = require('cors')
const helmet = require('helmet')
require('dotenv').config()
const authRoutes = require('./routes/authRoutes')
const userRoutes = require('./routes/userRoutes')
const errorHandler = require('./middleware/errorHandler')
const app = express()
// Security middleware — always add these
app.use(helmet())
app.use(cors())
// Parse JSON request bodies
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
// Routes
app.use('/api/auth', authRoutes)
app.use('/api/users', userRoutes)
// Health check — useful for deployment platforms
app.get('/health', (req, res) => {
res.status(200).json({
status: 'OK',
message: 'API is running',
timestamp: new Date().toISOString()
})
})
// Handle routes that don't exist
app.use('*', (req, res) => {
res.status(404).json({
success: false,
message: `Route ${req.originalUrl} not found`
})
})
// Global error handler — must be last
app.use(errorHandler)
module.exports = appCreate server.js in the root — this is the entry point that starts the server:
javascript
const app = require('./src/app')
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`)
console.log(`Environment: ${process.env.NODE_ENV}`)
})Step 5 — In-Memory Data Store
For this tutorial we are skipping the database setup to keep focus on the API patterns. Create src/data/users.js:
javascript
// In a real app, replace this with MongoDB/PostgreSQL queries
// The API patterns stay exactly the same — only the data layer changes
let users = [
{
id: 1,
name: 'Rahul Sharma',
email: 'rahul@example.com',
password: '$2b$10$examplehashedpassword', // never store plain text
role: 'admin',
createdAt: new Date('2026-01-01')
}
]
let nextId = 2
const findAll = () => users
const findById = (id) => users.find(u => u.id === parseInt(id))
const findByEmail = (email) => users.find(u => u.email === email)
const create = (userData) => {
const newUser = { id: nextId++, ...userData, createdAt: new Date() }
users.push(newUser)
return newUser
}
const update = (id, data) => {
const index = users.findIndex(u => u.id === parseInt(id))
if (index === -1) return null
users[index] = { ...users[index], ...data }
return users[index]
}
const remove = (id) => {
const index = users.findIndex(u => u.id === parseInt(id))
if (index === -1) return false
users.splice(index, 1)
return true
}
module.exports = { findAll, findById, findByEmail, create, update, remove }Step 6 — Authentication Middleware
Create src/middleware/auth.js. This runs before any protected route and verifies the JWT token:
javascript
const jwt = require('jsonwebtoken')
const protect = (req, res, next) => {
// Get token from the Authorization header
// Expected format: "Bearer <token>"
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
message: 'Not authorized. No token provided.'
})
}
const token = authHeader.split(' ')[1]
try {
// Verify the token using our secret
const decoded = jwt.verify(token, process.env.JWT_SECRET)
// Attach the user data from the token to the request object
// Now any route after this middleware can access req.user
req.user = decoded
next() // Move on to the actual route handler
} catch (error) {
return res.status(401).json({
success: false,
message: 'Not authorized. Token is invalid or expired.'
})
}
}
module.exports = { protect }🔗Also Read:-NextAuth.js complete guide: 12 Powerful Steps to Master Authentication
Step 7 — Rate Limiting
Create src/middleware/rateLimiter.js. Rate limiting prevents abuse by limiting the number of requests per user or IP address. Without this, anyone can hammer your API with thousands of requests and take it down
javascript
const rateLimit = require('express-rate-limit')
// General API limit — applies to all routes
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Maximum 100 requests per 15 minutes per IP
message: {
success: false,
message: 'Too many requests from this IP. Please try again after 15 minutes.'
},
standardHeaders: true,
legacyHeaders: false
})
// Stricter limit for auth routes — prevents brute force attacks
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // Only 10 login/register attempts per 15 minutes
message: {
success: false,
message: 'Too many authentication attempts. Please try again after 15 minutes.'
}
})
module.exports = { apiLimiter, authLimiter }Step 8 — Input Validation
Create src/validators/userValidator.js. Never trust data coming from a client. Always validate it before it touches your business logic:
javascript
const { body, validationResult } = require('express-validator')
// Validation rules for registration
const registerRules = [
body('name')
.trim()
.notEmpty().withMessage('Name is required')
.isLength({ min: 2, max: 50 }).withMessage('Name must be between 2 and 50 characters'),
body('email')
.trim()
.notEmpty().withMessage('Email is required')
.isEmail().withMessage('Please provide a valid email address')
.normalizeEmail(),
body('password')
.notEmpty().withMessage('Password is required')
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('Password must contain at least one uppercase letter, one lowercase letter, and one number')
]
// Validation rules for login
const loginRules = [
body('email')
.trim()
.notEmpty().withMessage('Email is required')
.isEmail().withMessage('Please provide a valid email'),
body('password')
.notEmpty().withMessage('Password is required')
]
// Middleware that checks validation results and returns errors if any
const validate = (req, res, next) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array().map(err => ({
field: err.path,
message: err.msg
}))
})
}
next()
}
module.exports = { registerRules, loginRules, validate }Step 9 — Auth Controller
Create src/controllers/authController.js. This handles user registration and login:
javascript
const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')
const { findByEmail, create } = require('../data/users')
// POST /api/auth/register
const register = async (req, res, next) => {
try {
const { name, email, password } = req.body
// Check if user already exists
const existingUser = findByEmail(email)
if (existingUser) {
return res.status(409).json({
success: false,
message: 'An account with this email already exists'
})
}
// Hash the password — NEVER store plain text passwords
const saltRounds = 10
const hashedPassword = await bcrypt.hash(password, saltRounds)
// Create the user
const newUser = create({
name,
email,
password: hashedPassword,
role: 'user'
})
// Create JWT token
const token = jwt.sign(
{ id: newUser.id, email: newUser.email, role: newUser.role },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
)
// Return user data WITHOUT the password
const { password: _, ...userWithoutPassword } = newUser
res.status(201).json({
success: true,
message: 'Account created successfully',
data: {
user: userWithoutPassword,
token
}
})
} catch (error) {
next(error) // Pass to global error handler
}
}
// POST /api/auth/login
const login = async (req, res, next) => {
try {
const { email, password } = req.body
// Find the user
const user = findByEmail(email)
if (!user) {
// Use the same message for wrong email AND wrong password
// This prevents attackers from knowing which one was wrong
return res.status(401).json({
success: false,
message: 'Invalid email or password'
})
}
// Compare the provided password with the hashed one
const isPasswordCorrect = await bcrypt.compare(password, user.password)
if (!isPasswordCorrect) {
return res.status(401).json({
success: false,
message: 'Invalid email or password'
})
}
// Create JWT token
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
)
const { password: _, ...userWithoutPassword } = user
res.status(200).json({
success: true,
message: 'Login successful',
data: {
user: userWithoutPassword,
token
}
})
} catch (error) {
next(error)
}
}
module.exports = { register, login }Step 10 — User Controller
Create src/controllers/userController.js. This handles all the CRUD operations for users:
javascript
const { findAll, findById, update, remove } = require('../data/users')
// GET /api/users — get all users
const getAllUsers = (req, res) => {
const users = findAll()
// Never return passwords — strip them out
const safeUsers = users.map(({ password, ...user }) => user)
res.status(200).json({
success: true,
count: safeUsers.length,
data: safeUsers
})
}
// GET /api/users/:id — get single user
const getUserById = (req, res) => {
const user = findById(req.params.id)
if (!user) {
return res.status(404).json({
success: false,
message: `User with ID ${req.params.id} not found`
})
}
const { password, ...safeUser } = user
res.status(200).json({
success: true,
data: safeUser
})
}
// PUT /api/users/:id — update a user
const updateUser = (req, res) => {
const user = findById(req.params.id)
if (!user) {
return res.status(404).json({
success: false,
message: `User with ID ${req.params.id} not found`
})
}
// Only allow updating name and email — not role or password through this route
const { name, email } = req.body
const updatedUser = update(req.params.id, { name, email })
const { password, ...safeUser } = updatedUser
res.status(200).json({
success: true,
message: 'User updated successfully',
data: safeUser
})
}
// DELETE /api/users/:id — delete a user
const deleteUser = (req, res) => {
const deleted = remove(req.params.id)
if (!deleted) {
return res.status(404).json({
success: false,
message: `User with ID ${req.params.id} not found`
})
}
res.status(200).json({
success: true,
message: 'User deleted successfully'
})
}
module.exports = { getAllUsers, getUserById, updateUser, deleteUser }Step 11 — Routes
Create src/routes/authRoutes.js:
javascript
const express = require('express')
const router = express.Router()
const { register, login } = require('../controllers/authController')
const { registerRules, loginRules, validate } = require('../validators/userValidator')
const { authLimiter } = require('../middleware/rateLimiter')
// Apply stricter rate limiting to auth routes
router.post('/register', authLimiter, registerRules, validate, register)
router.post('/login', authLimiter, loginRules, validate, login)
module.exports = routerCreate src/routes/userRoutes.js:
javascript
const express = require('express')
const router = express.Router()
const { getAllUsers, getUserById, updateUser, deleteUser } = require('../controllers/userController')
const { protect } = require('../middleware/auth')
const { apiLimiter } = require('../middleware/rateLimiter')
// All user routes require authentication
// The protect middleware runs first and verifies the JWT token
router.use(protect)
router.use(apiLimiter)
router.get('/', getAllUsers)
router.get('/:id', getUserById)
router.put('/:id', updateUser)
router.delete('/:id', deleteUser)
module.exports = routerStep 12 — Global Error Handler
Create src/middleware/errorHandler.js. This catches any error that gets passed to next(error) anywhere in your app:
javascript
const errorHandler = (err, req, res, next) => {
// Log error details in development
if (process.env.NODE_ENV === 'development') {
console.error('Error:', err.message)
console.error('Stack:', err.stack)
}
// Default error values
let statusCode = err.statusCode || 500
let message = err.message || 'Something went wrong on our end'
// Handle specific error types
if (err.name === 'JsonWebTokenError') {
statusCode = 401
message = 'Invalid token. Please log in again.'
}
if (err.name === 'TokenExpiredError') {
statusCode = 401
message = 'Your session has expired. Please log in again.'
}
if (err.name === 'ValidationError') {
statusCode = 400
message = 'Validation failed'
}
res.status(statusCode).json({
success: false,
message,
// Only show stack trace in development — never in production
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
})
}
module.exports = errorHandlerStep 13 — Test Your API
Start your development server:
bash
npm run devYou should see "Server running on http://localhost:3000" in your terminal.
Now test each endpoint. If you're using Postman or Insomnia, here are the exact requests to run:
Test 1 — Health check (no auth needed):
GET http://localhost:3000/healthExpected response: { "status": "OK", "message": "API is running" }
Test 2 — Register a new user:
POST http://localhost:3000/api/auth/register
Content-Type: application/json
{
"name": "Priya Patel",
"email": "priya@example.com",
"password": "SecurePass123"
}Expected: 201 status with user data and a JWT token.
Test 3 — Try registering with a bad password (validation test):
POST http://localhost:3000/api/auth/register
Content-Type: application/json
{
"name": "Test User",
"email": "test@example.com",
"password": "weak"
}Expected: 400 status with a validation error message about password requirements.
Test 4 — Login:
POST http://localhost:3000/api/auth/login
Content-Type: application/json
{
"email": "priya@example.com",
"password": "SecurePass123"
}Copy the token from the response — you need it for the next test.
Test 5 — Access a protected route (with token):
GET http://localhost:3000/api/users
Authorization: Bearer <paste-your-token-here>Expected: 200 status with a list of users.
Test 6 — Try the protected route without a token:
GET http://localhost:3000/api/usersExpected: 401 status with "Not authorized. No token provided."
All six tests passing? Your API is working correctly.
The 5 Security Rules You Should Never Break
Building an API is easy. Building a secure one takes a little more intention. Use established libraries rather than rolling your own authentication. For token-based authentication, implement JWT verification middleware. Store passwords with bcrypt or argon2. Always validate tokens on every request, implement token expiration, and consider refresh token rotation for long-lived sessions.
Here are the five rules that matter most:
Rule 1 — Never store plain text passwords. Always hash with bcrypt before saving. We did this above. If your database gets breached, hashed passwords are useless to an attacker.
Rule 2 — Never expose passwords in API responses. Always destructure and omit the password field before sending user data. We did this in every controller above.
Rule 3 — Use the same error message for wrong email and wrong password. If you say "user not found" for wrong email but "wrong password" for wrong password, attackers can use your API to find out which emails are registered. Always say "Invalid email or password" for both.
Rule 4 — Add rate limiting to auth routes. Without it, a script can try thousands of passwords against your login endpoint. Ten attempts per 15 minutes is a reasonable default for production.
Rule 5 — Use environment variables for all secrets. Your JWT secret, database passwords, API keys — none of these should ever be in your code. Always use .env files locally and environment variables on your hosting platform.
What to Add Next
This API is production-ready as a foundation. Here is what you would add next to make it a complete application:
A real database — swap the in-memory store for MongoDB with Mongoose or PostgreSQL with Prisma. The controller code stays identical — only src/data/users.js changes. This is exactly why the folder structure matters.
Password reset flow — a POST /api/auth/forgot-password endpoint that emails a time-limited reset link. Use Nodemailer with any SMTP provider.
Refresh tokens — JWTs expire. Refresh tokens let users stay logged in without re-entering their password. Store refresh tokens in a database, give them a longer expiry, and rotate them on each use.
Role-based access control — right now any logged-in user can delete any other user. Add an isAdmin middleware that checks req.user.role === 'admin' before allowing destructive operations.
Request logging — add the morgan package to log every request with its method, URL, status code, and response time. Essential for debugging production issues.
API documentation — add swagger-jsdoc and swagger-ui-express to auto-generate interactive API docs from comments in your code. Your future self (and any teammates) will thank you.
Quick Summary — What We Built
Here is everything in the API we just built:
Express app configured with helmet for security headers and cors for cross-origin support.
Folder structure following separation of concerns — routes, controllers, middleware, validators all separated.
JWT authentication — register creates a hashed password and returns a token, login verifies credentials and returns a token, the protect middleware verifies the token on every protected route.
Input validation on register and login using express-validator — bad data never reaches the controller.
Rate limiting — 10 attempts per 15 minutes on auth routes, 100 requests per 15 minutes on all other routes.
Global error handling — one central place to handle all errors, with different messages in development vs production.
Clean API responses — consistent { success, message, data } format on every endpoint.