Building a REST API in Node.js used to mean Express and a lot of copy-pasted middleware. In 2026, the ecosystem is more opinionated: Fastify dominates new projects, TypeScript is default, and Zod handles validation cleanly at the schema level. This guide builds a production-grade API from scratch.
What changed in 2026
- Fastify v5 is stable and ships with native TypeScript types, built-in JSON schema validation, and ~35k req/s on a single core — roughly 2× Express on benchmarks.
- Zod 4 cut bundle size by ~50% and is the standard for TypeScript-first validation.
- Node 22 LTS includes native
fetch, WebSocket, and a built-in test runner, removing several common dependencies.
- Hono emerged as the ultra-lightweight alternative (runs on Workers/Deno/Bun) for edge deployments; use it if you're targeting Cloudflare Workers.
Project structure
src/
app.ts # Fastify instance + plugin registration
server.ts # Entry point — listen()
routes/
users.ts
posts.ts
services/
user.service.ts
db/
client.ts # Postgres/Prisma/Drizzle instance
schemas/
user.schema.ts # Zod schemas
middleware/
auth.ts
Keep routes thin. All business logic lives in services/. All DB access lives in db/ or through an ORM. This separation makes testing straightforward.
Bootstrap with Fastify
// src/app.ts
import Fastify from 'fastify';
import { userRoutes } from './routes/users.js';
export function buildApp() {
const app = Fastify({ logger: true });
app.register(userRoutes, { prefix: '/api/users' });
app.setErrorHandler((error, _req, reply) => {
app.log.error(error);
const statusCode = error.statusCode ?? 500;
reply.status(statusCode).send({
error: error.message ?? 'Internal Server Error',
});
});
return app;
}
// src/server.ts
import { buildApp } from './app.js';
const app = buildApp();
await app.listen({ port: 3000, host: '0.0.0.0' });
Route with validation
// src/routes/users.ts
import { z } from 'zod';
import type { FastifyPluginAsync } from 'fastify';
import { createUser, getUserById } from '../services/user.service.js';
const CreateUserBody = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
});
export const userRoutes: FastifyPluginAsync = async (app) => {
app.post('/', async (req, reply) => {
const body = CreateUserBody.parse(req.body); // throws ZodError on bad input
const user = await createUser(body);
return reply.status(201).send(user);
});
app.get('/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const user = await getUserById(id);
if (!user) return reply.status(404).send({ error: 'User not found' });
return user;
});
};
For Zod errors, add a preHandler or error handler that maps ZodError to 400 responses with field-level detail.
HTTP status codes that matter
| Situation |
Status |
| Resource created |
201 Created |
| Bad request / validation error |
400 Bad Request |
| Missing auth |
401 Unauthorized |
| Authenticated but forbidden |
403 Forbidden |
| Resource not found |
404 Not Found |
| Duplicate / conflict |
409 Conflict |
| Server error |
500 Internal Server Error |
Return the status code. Do not return 200 for everything and hide the real status in the body.
Authentication middleware
// src/middleware/auth.ts
import type { FastifyRequest, FastifyReply } from 'fastify';
import { verifyJwt } from '../lib/jwt.js';
export async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return reply.status(401).send({ error: 'No token' });
const payload = verifyJwt(token);
if (!payload) return reply.status(401).send({ error: 'Invalid token' });
req.user = payload; // augment FastifyRequest type
}
Register it per-route or per-plugin with preHandler: [requireAuth].
How to pick your stack
- Fastify + Zod + Drizzle ORM — type-safe end to end, fastest Node option, good for new projects.
- Express + Zod + Prisma — more ecosystem examples, slightly lower perf, still fine for most loads.
- Hono + Drizzle — if you're deploying to Cloudflare Workers or Deno.
- NestJS — structured like Angular; choose it if your team needs the opinionated module system, avoid it if you want simplicity.
Common mistakes
Putting DB queries in route handlers. One refactor away from untestable spaghetti. Services call DB; routes call services.
No pagination on list endpoints. Every list endpoint needs limit + cursor or offset before it hits production. See Pagination: cursor vs offset in 2026.
No rate limiting. Add @fastify/rate-limit before you go live. See How to rate limit an API in 2026.
Swallowing errors silently. catch(e) {} means failures vanish. Always log and rethrow or return an appropriate status.
Not versioning the API. Prefix routes with /api/v1/ from day one — you will need v2 eventually.
What to skip
- Express for new projects unless you have specific ecosystem reasons. Fastify ships with what Express makes you install separately.
- Rolling auth from scratch — use a proven JWT library (
jose, @fastify/jwt) or a managed provider.
- Returning raw DB rows to clients — always serialize through a schema to avoid leaking internal fields.
FAQ
Express or Fastify in 2026?
Fastify for new projects. Express for projects where the team knows it and performance isn't a concern.
Should I use TypeScript?
Yes. Fastify's TypeScript support is first class, and type errors at build time are far cheaper than 400s in production.
How do I handle file uploads?
Use @fastify/multipart. Stream to S3 or R2; do not buffer the whole file in memory.
How do I add OpenAPI/Swagger docs?
@fastify/swagger + @fastify/swagger-ui auto-generate from your JSON schemas or Zod schemas via fastify-type-provider-zod.
Where to go next
See How to set up OAuth login in 2026, How to rate limit an API in 2026, and How to set up Postgres locally in 2026.