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
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.
// 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.
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
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.
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.
// 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
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.
// 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.
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
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.