Node.js API Best Practices 2026: Build Production-Ready REST APIs

Sanjeev SharmaSanjeev Sharma
6 min read

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

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

CheckWhyHow
HTTPSEncrypt in transitLet's Encrypt / Nginx
Helmet headersSecurity headers@fastify/helmet
Rate limitingPrevent abuse@fastify/rate-limit
Input validationPrevent injectionJSON Schema
Structured loggingObservabilityPino
Health checksLoad balancer/health endpoint
Graceful shutdownZero downtimeSIGTERM handler
Database poolingPerformancePgBouncer / 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro