⏱️6 min read · 1,227 words
Node.js remains the dominant runtime for building REST APIs in 2026. With native fetch, ESM by default, and excellent TypeScript support via tsx/Bun, building production Node.js APIs is faster than ever. This guide uses Express 5 + TypeScript for a production-grade REST API with auth, validation, and testing.
📋 Table of Contents
Project Setup
mkdir node-api-2026 && cd node-api-2026
npm init -y
npm install express zod bcryptjs jsonwebtoken cors helmet morgan
npm install --save-dev typescript tsx @types/express @types/node @types/bcryptjs @types/jsonwebtoken nodemon
# tsconfig.json
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext --strict true --outDir dist
Project Structure
src/
app.ts — Express app setup
server.ts — entry point
routes/
users.ts
auth.ts
posts.ts
middleware/
auth.ts — JWT validation
validate.ts — Zod validation
errorHandler.ts
services/
userService.ts
authService.ts
models/
User.ts
Post.ts
schemas/ — Zod schemas
user.ts
auth.ts
Express App Setup
// src/app.ts
import express, { Express, Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import { errorHandler } from './middleware/errorHandler.js';
import { userRouter } from './routes/users.js';
import { authRouter } from './routes/auth.js';
export function createApp(): Express {
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') ?? ['http://localhost:3000'],
credentials: true,
}));
// Parsing
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
// Logging
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Routes
app.use('/api/auth', authRouter);
app.use('/api/users', userRouter);
// Health check
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// 404
app.use((req: Request, res: Response) => {
res.status(404).json({ error: 'NOT_FOUND', message: `Route ${req.path} not found` });
});
// Error handler (must be last)
app.use(errorHandler);
return app;
}
Validation with Zod
// src/schemas/user.ts
import { z } from 'zod';
export const createUserSchema = z.object({
name: z.string().min(2).max(50).trim(),
email: z.string().email().toLowerCase(),
password: z.string().min(8).max(128),
role: z.enum(['user', 'admin']).default('user'),
});
export const updateUserSchema = createUserSchema.partial().omit({ password: true });
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
// src/middleware/validate.ts
import { AnyZodObject, ZodError } from 'zod';
import { Request, Response, NextFunction } from 'express';
export const validate = (schema: AnyZodObject) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
req.body = await schema.parseAsync(req.body);
next();
} catch (err) {
if (err instanceof ZodError) {
const errors = err.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
}));
res.status(422).json({ error: 'VALIDATION_ERROR', errors });
return;
}
next(err);
}
};
JWT Authentication Middleware
// src/middleware/auth.ts
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';
interface JWTPayload {
userId: number;
role: string;
}
declare global {
namespace Express {
interface Request {
user?: JWTPayload;
}
}
}
export const authenticate = (req: Request, res: Response, next: NextFunction): void => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
res.status(401).json({ error: 'UNAUTHORIZED', message: 'Authentication required' });
return;
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JWTPayload;
req.user = payload;
next();
} catch {
res.status(401).json({ error: 'UNAUTHORIZED', message: 'Invalid or expired token' });
}
};
export const authorize = (...roles: string[]) =>
(req: Request, res: Response, next: NextFunction): void => {
if (!req.user || !roles.includes(req.user.role)) {
res.status(403).json({ error: 'FORBIDDEN', message: 'Insufficient permissions' });
return;
}
next();
};
User Routes
// src/routes/users.ts
import { Router } from 'express';
import { authenticate, authorize } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { createUserSchema, updateUserSchema } from '../schemas/user.js';
import * as userService from '../services/userService.js';
export const userRouter = Router();
// GET /api/users - list users (admin only)
userRouter.get('/', authenticate, authorize('admin'), async (req, res, next) => {
try {
const page = parseInt(req.query.page as string ?? '1');
const limit = Math.min(parseInt(req.query.limit as string ?? '20'), 100);
const { users, total } = await userService.listUsers({ page, limit });
res.json({ users, total, page, limit });
} catch (err) { next(err); }
});
// GET /api/users/:id
userRouter.get('/:id', authenticate, async (req, res, next) => {
try {
const userId = parseInt(req.params.id);
// Users can only access their own profile, admins can access any
if (req.user!.userId !== userId && req.user!.role !== 'admin') {
res.status(403).json({ error: 'FORBIDDEN' });
return;
}
const user = await userService.getUser(userId);
if (!user) {
res.status(404).json({ error: 'NOT_FOUND', message: `User ${userId} not found` });
return;
}
res.json(user);
} catch (err) { next(err); }
});
// POST /api/users
userRouter.post('/', validate(createUserSchema), async (req, res, next) => {
try {
const user = await userService.createUser(req.body);
res.status(201).json(user);
} catch (err) { next(err); }
});
// PATCH /api/users/:id
userRouter.patch('/:id', authenticate, validate(updateUserSchema), async (req, res, next) => {
try {
const userId = parseInt(req.params.id);
if (req.user!.userId !== userId && req.user!.role !== 'admin') {
res.status(403).json({ error: 'FORBIDDEN' });
return;
}
const updated = await userService.updateUser(userId, req.body);
res.json(updated);
} catch (err) { next(err); }
});
// DELETE /api/users/:id (admin only, soft delete)
userRouter.delete('/:id', authenticate, authorize('admin'), async (req, res, next) => {
try {
await userService.deleteUser(parseInt(req.params.id));
res.status(204).send();
} catch (err) { next(err); }
});
Testing with Vitest
npm install --save-dev vitest supertest @types/supertest
// src/__tests__/users.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../app.js';
const app = createApp();
describe('POST /api/auth/login', () => {
it('returns JWT token with valid credentials', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'password123' });
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
expect(typeof res.body.token).toBe('string');
});
it('returns 401 with invalid credentials', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'wrong' });
expect(res.status).toBe(401);
expect(res.body.error).toBe('UNAUTHORIZED');
});
});
describe('GET /api/users', () => {
it('returns 401 without auth', async () => {
const res = await request(app).get('/api/users');
expect(res.status).toBe(401);
});
it('returns 403 for non-admin users', async () => {
const { token } = await loginAs('user@example.com', 'user-password');
const res = await request(app)
.get('/api/users')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
});
});
Node.js REST API in 2026: Express 5 + TypeScript + Zod is the production standard. Validate all input at the route level with Zod middleware, use JWT for stateless auth, and test with Vitest + supertest. The architecture above scales to production-grade applications.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment