🌐 Detecting your location…
📢 Advertisement — Configure AdSense in Appearance → Customize → AdSense Settings

Node.js REST API Guide 2026: Express 5, TypeScript, JWT and Testing

⏱️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.

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.

✍️ Leave a Comment

Your email address will not be published. Required fields are marked *

🌐 Read in:🇬🇧 English🇩🇪 Deutsch🇧🇷 Português🇸🇦 العربية🇮🇳 हिन्दी🇧🇩 বাংলা