Node.js API Best Practices 2026: Build Production-Ready REST APIs
Advertisement
Node.js API Development 2026: Production from Day One
Building an API is easy. Building one that handles 10,000 concurrent users, never loses data, and stays maintainable for 3 years — that's the goal.
- Fastify vs Express in 2026
- Project Structure
- Fastify App Setup
- Schema Validation (Zero Runtime Overhead)
- Authentication with JWT
- Error Handling
- Database with Prisma
- Testing with Vitest
- Production Checklist
Fastify vs Express in 2026
Fastify: 3x faster than Express, built-in TypeScript, schema validation
Express: Larger ecosystem, more tutorials, slower but simpler
Recommendation: Fastify for new projects
npm init -y
npm install fastify @fastify/jwt @fastify/rate-limit @fastify/cors
npm install -D typescript @types/node tsx
Project Structure
src/
index.ts # Entry point
app.ts # Fastify app setup
plugins/
auth.ts # JWT plugin
cors.ts
rateLimit.ts
routes/
users.ts # /api/users routes
posts.ts # /api/posts routes
schemas/
user.schema.ts # JSON schemas for validation
services/
user.service.ts # Business logic
repositories/
user.repository.ts # Database queries
middleware/
error.ts # Error handler
lib/
db.ts # Database connection
logger.ts # Logger config
Fastify App Setup
// src/app.ts
import Fastify from 'fastify'
import fastifyJwt from '@fastify/jwt'
import fastifyRateLimit from '@fastify/rate-limit'
import fastifyCors from '@fastify/cors'
export function buildApp() {
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL ?? 'info',
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty' }
: undefined,
},
})
// Plugins
app.register(fastifyCors, {
origin: process.env.ALLOWED_ORIGINS?.split(',') ?? ['http://localhost:3000'],
credentials: true,
})
app.register(fastifyRateLimit, {
max: 100,
timeWindow: '1 minute',
errorResponseBuilder: () => ({
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded. Try again in 1 minute.',
}),
})
app.register(fastifyJwt, {
secret: process.env.JWT_SECRET!,
sign: { expiresIn: '15m' },
})
// Routes
app.register(import('./routes/users'), { prefix: '/api/users' })
app.register(import('./routes/posts'), { prefix: '/api/posts' })
// Health check
app.get('/health', () => ({ status: 'ok', timestamp: new Date().toISOString() }))
return app
}
Schema Validation (Zero Runtime Overhead)
// src/schemas/user.schema.ts
export const CreateUserSchema = {
body: {
type: 'object',
required: ['email', 'password', 'name'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 8, maxLength: 100 },
name: { type: 'string', minLength: 1, maxLength: 100 },
},
additionalProperties: false,
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
name: { type: 'string' },
createdAt: { type: 'string' },
},
},
},
} as const
// src/routes/users.ts
import type { FastifyPluginAsync } from 'fastify'
import { CreateUserSchema } from '../schemas/user.schema'
const usersRoute: FastifyPluginAsync = async (app) => {
app.post('/', { schema: CreateUserSchema }, async (request, reply) => {
const { email, password, name } = request.body as {
email: string; password: string; name: string
}
const user = await userService.create({ email, password, name })
return reply.code(201).send(user)
})
app.get('/', { preHandler: [app.authenticate] }, async (request) => {
return userService.findAll()
})
}
export default usersRoute
Authentication with JWT
// src/plugins/auth.ts
import fp from 'fastify-plugin'
import type { FastifyPluginAsync } from 'fastify'
const authPlugin: FastifyPluginAsync = fp(async (app) => {
app.decorate('authenticate', async function (request, reply) {
try {
await request.jwtVerify()
} catch {
reply.code(401).send({ error: 'Unauthorized', message: 'Invalid or expired token' })
}
})
})
// src/routes/auth.ts
app.post('/login', async (request, reply) => {
const { email, password } = request.body as { email: string; password: string }
const user = await userService.findByEmail(email)
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return reply.code(401).send({ error: 'Invalid credentials' })
}
const accessToken = app.jwt.sign({ sub: user.id, email: user.email }, { expiresIn: '15m' })
const refreshToken = app.jwt.sign({ sub: user.id, type: 'refresh' }, { expiresIn: '7d' })
reply.setCookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7,
})
return { accessToken, user: { id: user.id, email: user.email, name: user.name } }
})
Error Handling
// src/middleware/error.ts
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
export function errorHandler(
error: FastifyError,
request: FastifyRequest,
reply: FastifyReply
) {
const statusCode = error.statusCode ?? 500
request.log.error({ err: error, statusCode }, 'Request error')
if (statusCode >= 500) {
// Don't expose internal errors in production
return reply.code(500).send({
statusCode: 500,
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'production'
? 'An unexpected error occurred'
: error.message,
})
}
return reply.code(statusCode).send({
statusCode,
error: error.name,
message: error.message,
...(error.validation && { validation: error.validation }),
})
}
// Register in app:
app.setErrorHandler(errorHandler)
Database with Prisma
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// src/repositories/user.repository.ts
import { prisma } from '../lib/db'
export const userRepository = {
async findById(id: string) {
return prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true, role: true, createdAt: true },
})
},
async create(data: { email: string; passwordHash: string; name: string }) {
return prisma.user.create({
data,
select: { id: true, email: true, name: true, createdAt: true },
})
},
async findManyWithPagination(page: number, limit: number) {
const [users, total] = await prisma.$transaction([
prisma.user.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
select: { id: true, email: true, name: true, createdAt: true },
}),
prisma.user.count(),
])
return { users, total, pages: Math.ceil(total / limit) }
},
}
Testing with Vitest
// src/routes/users.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { buildApp } from '../app'
const app = buildApp()
beforeAll(async () => { await app.ready() })
afterAll(async () => { await app.close() })
describe('POST /api/users', () => {
it('creates a user with valid data', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/users',
payload: { email: 'test@example.com', password: 'password123', name: 'Test User' },
})
expect(response.statusCode).toBe(201)
const body = response.json()
expect(body).toMatchObject({ email: 'test@example.com', name: 'Test User' })
expect(body.id).toBeDefined()
expect(body.password).toBeUndefined() // Never expose password
})
it('returns 400 for invalid email', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/users',
payload: { email: 'not-an-email', password: 'password123', name: 'Test' },
})
expect(response.statusCode).toBe(400)
})
})
Production Checklist
| Check | Why | How |
|---|---|---|
| HTTPS | Encrypt in transit | Let's Encrypt / Nginx |
| Helmet headers | Security headers | @fastify/helmet |
| Rate limiting | Prevent abuse | @fastify/rate-limit |
| Input validation | Prevent injection | JSON Schema |
| Structured logging | Observability | Pino |
| Health checks | Load balancer | /health endpoint |
| Graceful shutdown | Zero downtime | SIGTERM handler |
| Database pooling | Performance | PgBouncer / Prisma |
Production-ready Node.js APIs aren't complicated — they're disciplined. Pick Fastify, use Prisma, add JWT auth, validate all inputs, and log everything. Ship it.
Advertisement