JavaScript

Building a REST API with Node.js and Express in 2026

Build a production-ready REST API with Node.js, Express, and modern best practices — including validation, error handling, authentication, and deployment.

March 18, 20264 min read

Project Setup

Initialize a modern Node.js project with ES modules:

mkdir my-api && cd my-api

npm init -y

npm install express cors helmet dotenv zod

npm install -D nodemon

Update package.json:

{

"type": "module",

"scripts": {

"dev": "nodemon server.js",

"start": "node server.js"

}

}

Create the server entry point:

// server.js

import express from 'express';

import cors from 'cors';

import helmet from 'helmet';

import 'dotenv/config';

const app = express();

const PORT = process.env.PORT || 3000;

// Middleware

app.use(helmet()); // Security headers

app.use(cors()); // CORS

app.use(express.json()); // Parse JSON bodies

// Health check

app.get('/health', (req, res) => {

res.json({ status: 'ok' });

});

app.listen(PORT, () => {

console.log(Server running on port ${PORT});

});

Structured Route Organization

Organize routes in separate files:

// routes/users.js

import { Router } from 'express';

const router = Router();

let users = [

{ id: '1', name: 'Alice', email: 'alice@example.com' },

];

// GET /api/users

router.get('/', (req, res) => {

res.json({ users, total: users.length });

});

// GET /api/users/:id

router.get('/:id', (req, res) => {

const user = users.find(u => u.id === req.params.id);

if (!user) return res.status(404).json({ error: 'User not found' });

res.json(user);

});

// POST /api/users

router.post('/', (req, res) => {

const { name, email } = req.body;

const user = { id: crypto.randomUUID(), name, email };

users.push(user);

res.status(201).json(user);

});

export default router;

Register in server.js:

import userRoutes from './routes/users.js';

app.use('/api/users', userRoutes);

Input Validation with Zod

Never trust client input. Use Zod for type-safe validation:

import { z } from 'zod';

// Define schemas

const CreateUserSchema = z.object({

name: z.string().min(2).max(100),

email: z.string().email(),

password: z.string().min(8),

});

// Validation middleware

function validate(schema) {

return (req, res, next) => {

const result = schema.safeParse(req.body);

if (!result.success) {

return res.status(400).json({

error: 'Validation failed',

details: result.error.flatten().fieldErrors,

});

}

req.body = result.data; // Use parsed (clean) data

next();

};

}

// Usage

router.post('/', validate(CreateUserSchema), (req, res) => {

// req.body is guaranteed to be valid here

const { name, email, password } = req.body;

// ... create user

});

Zod catches invalid data before it reaches your business logic and provides clear error messages for the frontend.

Global Error Handling

Add a centralized error handler so you never leak stack traces to clients:

// middleware/errorHandler.js

export function errorHandler(err, req, res, next) {

console.error(${req.method} ${req.path}:, err.message);

// Known errors

if (err.status) {

return res.status(err.status).json({

error: err.message,

});

}

// Zod validation errors

if (err.name === 'ZodError') {

return res.status(400).json({

error: 'Validation failed',

details: err.flatten().fieldErrors,

});

}

// Unknown errors — don't leak details

res.status(500).json({

error: 'Internal server error',

});

}

// Custom error class

export class AppError extends Error {

constructor(message, status = 400) {

super(message);

this.status = status;

}

}

Register it last (after all routes):

app.use(errorHandler);

Now any route can throw new AppError('User not found', 404) and the error handler formats the response consistently.

JWT Authentication Middleware

Protect routes with JWT tokens:

import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET;

export function authenticate(req, res, next) {

const header = req.headers.authorization;

if (!header?.startsWith('Bearer ')) {

return res.status(401).json({ error: 'No token provided' });

}

const token = header.slice(7);

try {

const payload = jwt.verify(token, JWT_SECRET);

req.user = payload; // { id, email, role }

next();

} catch {

res.status(401).json({ error: 'Invalid or expired token' });

}

}

// Generate token on login

function generateToken(user) {

return jwt.sign(

{ id: user.id, email: user.email, role: user.role },

JWT_SECRET,

{ expiresIn: '7d' }

);

}

// Protect routes

router.get('/profile', authenticate, (req, res) => {

res.json({ user: req.user });

});

Install the dependency: npm install jsonwebtoken

Deploying to Production

A production Express app needs:

  • 1. Environment variables — never hardcode secrets
  • 2. Process manager — use PM2 or systemd (not node server.js)
  • 3. Reverse proxy — Nginx in front for SSL and static files
  • 4. Logging — structured JSON logs for debugging

# Install PM2 globally

npm install -g pm2

# Start with PM2

pm2 start server.js --name my-api -i max

# Auto-restart on crash, auto-start on boot

pm2 startup

pm2 save

# View logs

pm2 logs my-api

Or use a systemd service file (see our tutorial on systemd services) for tighter control over resources and security.

For the Nginx reverse proxy setup with SSL, check our Nginx Reverse Proxy with SSL tutorial.

node.jsexpressrest apibackendjavascriptauthenticationtutorial

Related Articles