Ink&Horizon
HomeBlogTutorialsLanguages
Ink&Horizon— where knowledge meets the horizon —Learn to build exceptional software. Tutorials, guides, and references for developers — from first brushstroke to masterwork.

Learn

  • Blog
  • Tutorials
  • Languages

Company

  • About Us
  • Contact Us
  • Privacy Policy

Account

  • Sign In
  • Register
  • Profile
Ink & Horizon

© 2026 InkAndHorizon. All rights reserved.

Privacy PolicyTerms of Service
Back to Blog
Backend

Building Production REST APIs with Node.js 22 & Fastify 5

Native fetch, built-in test runner, ES modules, structured validation, and deployment-ready patterns

2026-03-22 24 min read
ContentsWhy Node.js 22 + Fastify 5 in 2026Project Setup with TypeScriptFastify 5: Routes, Validation & Type SafetyAuthentication with JWT MiddlewareError Handling: The Production PatternNode.js 22 Built-in Test RunnerRate Limiting, CORS & Security HardeningKey Takeaways

Why Node.js 22 + Fastify 5 in 2026

Node.js 22 (LTS) brings major improvements: native fetch() globally available, a built-in test runner (node:test) that eliminates the need for Jest in many cases, stable ES module support, and the Permission Model for security sandboxing.

Fastify 5 is the fastest Node.js framework in benchmarks — 2-3x faster than Express. It provides built-in JSON schema validation, full TypeScript support with type providers, automatic API documentation, and a robust plugin system. Express is no longer the recommended choice for new projects.

This guide builds a production-ready REST API from scratch using modern Node.js patterns that you will be tested on in 2026 backend interviews.

Key Takeaways

Node.js 22 LTS: native fetch, builtin test runner, stable ESM, Permission Model.
Fastify 5: 2-3x faster than Express, built-in validation, TypeScript-first.
Express is legacy — Fastify, Hono, and Elysia are the modern choices.
This stack is interview-relevant at companies shipping Node.js backends in 2026.

Project Setup with TypeScript

A modern Node.js 22 project uses ES modules (type: "module" in package.json), TypeScript with strict mode, and tsx for development. No Babel needed — tsx uses esbuild for instant compilation.

The project structure follows a modular architecture: each domain (users, posts, auth) gets its own folder with routes, services, and schemas. This scales from solo projects to teams of 50+ engineers.

Snippet
// package.json
{
  "name": "ink-api",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "test": "node --test src/**/*.test.ts"
  },
  "dependencies": {
    "fastify": "^5.0.0",
    "@fastify/cors": "^10.0.0",
    "@fastify/jwt": "^9.0.0",
    "@fastify/rate-limit": "^10.0.0",
    "zod": "^3.23.0"
  },
  "devDependencies": {
    "typescript": "^5.6.0",
    "tsx": "^4.19.0",
    "@types/node": "^22.0.0"
  }
}

// Project structure
src/
├── server.ts           // App entry point
├── config.ts           // Environment validation
├── plugins/            // Fastify plugins (auth, cors, rate-limit)
├── modules/
│   ├── users/
│   │   ├── user.routes.ts
│   │   ├── user.service.ts
│   │   ├── user.schema.ts
│   │   └── user.test.ts
│   ├── posts/
│   └── auth/
└── utils/
    ├── errors.ts
    └── logger.ts

Fastify 5: Routes, Validation & Type Safety

Fastify validates request bodies, query parameters, and URL parameters using JSON Schema. In Fastify 5 with TypeScript, you get full type inference: the route handler's request object is automatically typed based on your schema definition.

Use the Type Provider pattern (Zod adapter or built-in JSON Schema) to turn your validation schemas into TypeScript types automatically. This means your validation and types are always in sync — a single source of truth.

Snippet
import Fastify from 'fastify';
import { z } from 'zod';

const app = Fastify({ logger: true });

// Define schemas with Zod
const CreateUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  role: z.enum(['user', 'admin']).default('user'),
});

const UserParamsSchema = z.object({
  id: z.string().uuid(),
});

type CreateUserInput = z.infer<typeof CreateUserSchema>;

// Routes with validation
app.post('/api/users', async (request, reply) => {
  const parsed = CreateUserSchema.safeParse(request.body);
  
  if (!parsed.success) {
    return reply.status(400).send({
      error: 'Validation failed',
      details: parsed.error.flatten().fieldErrors,
    });
  }

  const user = await userService.create(parsed.data);
  return reply.status(201).send(user);
});

app.get('/api/users/:id', async (request, reply) => {
  const { id } = UserParamsSchema.parse(request.params);
  const user = await userService.findById(id);
  
  if (!user) {
    return reply.status(404).send({ error: 'User not found' });
  }
  
  return user;
});

// Start server
const start = async () => {
  try {
    await app.listen({ port: 3000, host: '0.0.0.0' });
    console.log('Server running on http://localhost:3000');
  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
};
start();

Key Takeaways

Zod validates AND generates TypeScript types from a single schema.
safeParse returns a result object instead of throwing on invalid input.
Fastify's built-in logger uses pino — the fastest Node.js logger.
Listen on 0.0.0.0 (not localhost) for Docker/container compatibility.

Authentication with JWT Middleware

Modern API authentication uses short-lived access tokens (JWT) with long-lived refresh tokens. The access token lives in memory (for SPAs) or in an HttpOnly cookie (for SSR apps). The refresh token is always in an HttpOnly cookie.

Fastify's plugin system makes it easy to add authentication as middleware that protects specific routes while leaving public routes open.

Snippet
import fastifyJwt from '@fastify/jwt';

// Register JWT plugin
await app.register(fastifyJwt, {
  secret: process.env.JWT_SECRET!,
  sign: { expiresIn: '15m' }, // Short-lived access token
});

// Authentication decorator
app.decorate('authenticate', async (request, reply) => {
  try {
    await request.jwtVerify();
  } catch (err) {
    return reply.status(401).send({ error: 'Unauthorized' });
  }
});

// Login route — issues tokens
app.post('/api/auth/login', async (request, reply) => {
  const { email, password } = LoginSchema.parse(request.body);
  const user = await authService.verifyCredentials(email, password);
  
  if (!user) {
    return reply.status(401).send({ error: 'Invalid credentials' });
  }

  const accessToken = app.jwt.sign(
    { userId: user.id, role: user.role },
    { expiresIn: '15m' }
  );
  
  const refreshToken = app.jwt.sign(
    { userId: user.id, type: 'refresh' },
    { expiresIn: '7d' }
  );

  // Set refresh token as HttpOnly cookie
  reply.setCookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    path: '/api/auth/refresh',
    maxAge: 7 * 24 * 60 * 60, // 7 days
  });

  return { accessToken, user: { id: user.id, name: user.name, role: user.role } };
});

// Protected route — requires authentication
app.get('/api/me', {
  preHandler: [app.authenticate],
}, async (request) => {
  return userService.findById(request.user.userId);
});

Error Handling: The Production Pattern

Production APIs need consistent error responses. Every error — validation, authentication, not found, server crash — should return the same JSON format so clients can handle them uniformly.

Use Fastify's setErrorHandler to catch all uncaught errors and format them consistently. Define custom error classes for domain-specific errors (NotFoundError, ConflictError, etc.) with appropriate HTTP status codes.

Snippet
// Custom error classes
class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
  ) {
    super(message);
    this.name = 'AppError';
  }
}

class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(404, 'NOT_FOUND', `${resource} with id ${id} not found`);
  }
}

class ConflictError extends AppError {
  constructor(message: string) {
    super(409, 'CONFLICT', message);
  }
}

// Global error handler
app.setErrorHandler((error, request, reply) => {
  // Domain errors
  if (error instanceof AppError) {
    return reply.status(error.statusCode).send({
      error: error.code,
      message: error.message,
      statusCode: error.statusCode,
    });
  }

  // Zod validation errors
  if (error.name === 'ZodError') {
    return reply.status(400).send({
      error: 'VALIDATION_ERROR',
      message: 'Request validation failed',
      details: error.issues,
      statusCode: 400,
    });
  }

  // Unexpected errors — log full details, return safe message
  request.log.error(error, 'Unhandled error');
  return reply.status(500).send({
    error: 'INTERNAL_ERROR',
    message: 'An unexpected error occurred',
    statusCode: 500,
  });
});

// Usage in routes
app.get('/api/users/:id', async (request) => {
  const { id } = request.params;
  const user = await userService.findById(id);
  if (!user) throw new NotFoundError('User', id);
  return user;
});

Key Takeaways

Every error response uses the same JSON shape: { error, message, statusCode }.
Custom error classes carry HTTP status codes and machine-readable error codes.
Never leak stack traces or internal error details to clients in production.
Log full error details server-side with request.log.error for debugging.

Node.js 22 Built-in Test Runner

Node.js 22 includes a built-in test runner (node:test) that eliminates the need for Jest, Mocha, or Vitest for most backend testing. It supports describe/it blocks, before/after hooks, mocking, code coverage, and parallel test execution.

For API testing, use the built-in test runner with Fastify's inject() method, which simulates HTTP requests without starting a real server. This makes tests blazingly fast — no port conflicts, no cleanup needed.

Snippet
// user.test.ts — using Node.js 22 built-in test runner
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { buildApp } from '../server.ts';

describe('User API', () => {
  let app;

  beforeEach(async () => {
    app = await buildApp(); // Create fresh Fastify instance
  });

  afterEach(async () => {
    await app.close();
  });

  it('POST /api/users — creates a user', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/users',
      payload: {
        name: 'Ashutosh',
        email: 'ashutosh@inkandhorizon.com',
        role: 'admin',
      },
    });

    assert.equal(response.statusCode, 201);
    const body = JSON.parse(response.body);
    assert.equal(body.name, 'Ashutosh');
    assert.ok(body.id); // UUID was generated
  });

  it('POST /api/users — returns 400 for invalid email', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/users',
      payload: { name: 'Test', email: 'not-an-email' },
    });

    assert.equal(response.statusCode, 400);
    const body = JSON.parse(response.body);
    assert.equal(body.error, 'VALIDATION_ERROR');
  });

  it('GET /api/users/:id — returns 404 for missing user', async () => {
    const response = await app.inject({
      method: 'GET',
      url: '/api/users/00000000-0000-0000-0000-000000000000',
    });

    assert.equal(response.statusCode, 404);
  });
});

// Run: node --test src/**/*.test.ts
// Coverage: node --test --experimental-test-coverage

Rate Limiting, CORS & Security Hardening

Production APIs need multiple security layers. Rate limiting prevents abuse and DDoS. CORS controls which origins can make requests. Helmet sets security headers. These are non-negotiable for any public API.

Fastify's plugin system makes it easy to add these as reusable middleware. Register them once and they apply to all routes automatically.

Snippet
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
import helmet from '@fastify/helmet';

// CORS — allow specific origins only
await app.register(cors, {
  origin: ['https://inkandhorizon.com', 'http://localhost:3000'],
  credentials: true, // Allow cookies
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
});

// Rate limiting — 100 requests per minute per IP
await app.register(rateLimit, {
  max: 100,
  timeWindow: '1 minute',
  // Stricter limits for auth routes
  keyGenerator: (request) => request.ip,
});

// Helmet — security headers
await app.register(helmet, {
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
    },
  },
});

// Stricter rate limit for login (prevent brute force)
app.post('/api/auth/login', {
  config: {
    rateLimit: { max: 5, timeWindow: '15 minutes' },
  },
}, loginHandler);

Key Takeaways

Rate limit: 100 req/min for general API, 5 req/15min for auth routes.
CORS: whitelist specific origins, never use origin: "*" in production.
Helmet: automatic security headers (CSP, X-Frame-Options, etc.).
Always enable credentials: true if your frontend sends cookies.

Key Takeaways

Building production REST APIs in 2026 means using Node.js 22's native features (fetch, test runner, ESM) with a modern framework like Fastify 5 that provides built-in validation, logging, and TypeScript support.

The critical patterns: Zod for validation + type generation, custom error classes for consistent error responses, JWT with refresh token rotation for auth, and the built-in node:test runner for fast API testing.

For interviews: explain why Fastify is faster than Express (schema-based serialization), how JWT refresh token rotation works, the difference between ESM and CJS, and how node:test eliminates the need for Jest.

Key Takeaways

Node.js 22: native fetch, built-in test runner, stable ESM, Permission Model.
Fastify 5 > Express: faster, built-in validation, TypeScript-first, plugin system.
Zod: single source of truth for validation AND TypeScript types.
JWT: short-lived access tokens (15min) + HttpOnly refresh tokens (7d).
Error handling: consistent JSON format with custom error classes.
Testing: node:test + fastify.inject() for blazing fast API tests.
Security: rate limiting, CORS whitelist, helmet headers — non-negotiable.
AS
Article Author
Ashutosh
Lead Developer

Related Knowledge

Tutorial

Python Async Patterns

5m read
Tutorial

Go Concurrency in Practice

5m read
Tutorial

Java Virtual Threads

5m read
Article

Understanding Closures in JavaScript: The Complete 2026 Guide

22 min read
Article

React 19 Server Components: The Definitive 2026 Guide

28 min read
Article

Next.js 15 App Router Masterclass: Everything You Need to Know

25 min read