How to Build a REST API: Step-by-Step Guide for Beginners
What You Are Actually Building (And Why Most Tutorials Get It Wrong)
Most REST API tutorials teach you how to write routes. They show you app.get('/users', ...) and call it done. What they skip — and what breaks in production — is everything around the routes: validation that actually rejects bad input, error handling that does not leak stack traces to the client, authentication middleware that is consistent across endpoints, and documentation that updates automatically when your code changes.
This guide builds a real API from scratch. By the end you will have a working user management API with proper HTTP semantics, input validation using Zod, JWT authentication, a global error handler, and auto-generated OpenAPI documentation. Each section explains not just the how but the why — the reasoning behind the conventions, where they come from in the REST specification, and what breaks if you cut corners.
Per the RapidAPI Developer Survey 2025, REST remains the dominant API architecture at 83% of all APIs in production, followed by GraphQL at 29% and gRPC at 18% (many organizations use multiple). Express.js, the Node.js framework we will use, has over 30 million weekly npm downloads and is the most widely deployed Node.js HTTP framework for APIs.
Key Takeaways
- ▸REST is a set of architectural constraints from Roy Fielding's 2000 PhD dissertation, not a protocol or standard. The key constraints are statelessness, uniform interface (HTTP methods + status codes), and client-server separation.
- ▸Always validate input at the boundary — before it touches your database. Use Zod or Joi for schema validation. Never trust request bodies, query params, or path params without checking them.
- ▸HTTP status codes are part of your API contract. 201 for resource creation, 204 for deletion, 400 for validation errors, 401 for unauthenticated, 403 for unauthorized, 404 for not found, 422 for semantic validation failures.
- ▸A global error handling middleware in Express — registered after all routes — is mandatory for consistent error responses. Without it, unhandled errors expose stack traces and crash the process.
- ▸Per the Stack Overflow Developer Survey 2025, 67% of professional developers use Node.js and 40% use Express.js — making it the most practical framework to learn for backend API development.
What REST Actually Means
REST stands for Representational State Transfer. Roy Fielding defined it in his 2000 doctoral dissertation at UC Irvine, “Architectural Styles and the Design of Network-based Software Architectures.” It is not a protocol — HTTP is the protocol. REST is a set of architectural constraints for how a web system should be designed. An API that follows these constraints is “RESTful.”
The six constraints that define REST, per Fielding's dissertation:
- Client-server separation — the client and server evolve independently; the interface between them is the contract
- Statelessness — every request contains all information needed to process it; the server stores no session state between requests
- Cacheability — responses should indicate whether they are cacheable; clients can cache them to improve performance
- Uniform interface — resources are identified by URIs; HTTP methods have specific semantics; responses contain representations (JSON, XML)
- Layered system — clients cannot tell whether they are connected directly to the server or through a proxy, load balancer, or cache
- Code on demand (optional) — servers can send executable code (JavaScript) to clients
The most practically important of these are statelessness and the uniform interface. Statelessness means you cannot use server-side session state — authentication must travel with every request (JWT in the Authorization header). The uniform interface means your HTTP methods and status codes must be semantically correct — not all 200, not all POST, not custom error formats per endpoint.
| HTTP Method | Operation | Success Status | Idempotent? | Body? |
|---|---|---|---|---|
| GET | Read resource(s) | 200 OK | Yes | No (request) |
| POST | Create resource | 201 Created | No | Yes |
| PUT | Replace resource | 200 OK | Yes | Yes |
| PATCH | Partial update | 200 OK | Conditional | Yes |
| DELETE | Delete resource | 204 No Content | Yes | No |
For deeper coverage of HTTP method semantics and status codes, see BytePane's HTTP methods guide and the interactive HTTP status codes reference.
Step 1: Project Setup and Structure
Start with a clean project directory. We will use Node.js (v20+), Express, TypeScript, Zod for validation, and jsonwebtoken for authentication.
mkdir user-api && cd user-api
npm init -y
# Dependencies
npm install express zod jsonwebtoken bcryptjs
# Dev dependencies
npm install -D typescript ts-node-dev @types/express @types/jsonwebtoken @types/bcryptjs
# Initialize TypeScript
npx tsc --init --rootDir src --outDir dist --strict --esModuleInterop true
# Project structure
user-api/
src/
routes/
users.ts ← user CRUD routes
auth.ts ← login/register routes
middleware/
auth.ts ← JWT verification middleware
validate.ts ← Zod validation middleware
error.ts ← global error handler
models/
user.ts ← User type + in-memory store
app.ts ← Express app (no listen here)
server.ts ← entry point (calls listen)
package.json
tsconfig.jsonThe separation of app.ts from server.ts is a deliberate pattern. app.ts exports the configured Express application without starting the HTTP server. server.ts imports app and calls app.listen(). This allows your test suite to import app directly and use supertest to run HTTP assertions without binding to a port.
import express from 'express'
import { usersRouter } from './routes/users'
import { authRouter } from './routes/auth'
import { errorHandler } from './middleware/error'
const app = express()
// Parse JSON request bodies
app.use(express.json())
// Routes
app.use('/api/v1/users', usersRouter)
app.use('/api/v1/auth', authRouter)
// 404 handler — must be after all routes
app.use((_req, res) => {
res.status(404).json({ error: 'Not found' })
})
// Global error handler — must be last middleware (4 args signals error handler to Express)
app.use(errorHandler)
export default appimport app from './app'
const PORT = process.env.PORT ?? 3000
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`)
})Step 2: Define Your Data Model and Validation Schemas
Define your data shape first — the TypeScript type, the Zod validation schema (which derives its type), and the in-memory store. In a real application this is where your database model lives; for this guide we use a simple Map to keep the focus on API structure rather than database setup.
import { z } from 'zod'
// Zod schema — single source of truth for validation AND TypeScript types
export const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8).max(128),
role: z.enum(['admin', 'user']).default('user'),
})
export const UpdateUserSchema = CreateUserSchema.partial().omit({ password: true })
// TypeScript types derived from the schema — they stay in sync automatically
export type CreateUserInput = z.infer<typeof CreateUserSchema>
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>
// The User object stored in the database (never return password to client)
export interface User {
id: string
name: string
email: string
passwordHash: string
role: 'admin' | 'user'
createdAt: string
}
// Public-facing user (strip sensitive fields before sending)
export type PublicUser = Omit<User, 'passwordHash'>
// In-memory store (swap for a real DB in production)
export const users = new Map<string, User>()The key insight here: define one Zod schema, get both validation and TypeScript types for free. UpdateUserSchema is the create schema with all fields optional (.partial()) and password removed (.omit()) — users cannot update their own password through this endpoint; that is a separate flow. This composition pattern eliminates schema duplication.
Step 3: Validation Middleware
Validation belongs in middleware, not inside each route handler. A reusable validate middleware accepts a Zod schema, parses the request body, and either calls next() with the validated data attached or sends a 400 response with structured error messages.
import { Request, Response, NextFunction } from 'express'
import { ZodSchema, ZodError } from 'zod'
// Generic middleware factory — accepts any Zod schema
export function validate<T>(schema: ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body)
if (!result.success) {
// ZodError.flatten() converts nested errors to a flat, client-friendly format
const errors = result.error.flatten()
return res.status(400).json({
error: 'Validation failed',
fieldErrors: errors.fieldErrors,
formErrors: errors.formErrors,
})
}
// Replace req.body with the validated (and transformed) data
// Zod may coerce types, strip unknown fields, and apply defaults
req.body = result.data
next()
}
}
// Example of what the 400 response looks like:
// {
// "error": "Validation failed",
// "fieldErrors": {
// "email": ["Invalid email"],
// "password": ["String must contain at least 8 character(s)"]
// },
// "formErrors": []
// }Step 4: Global Error Handler
Every Express application needs a centralized error handler. Without one, unhandled errors either crash the process or return a raw HTML stack trace to the client — both are unacceptable in production. The global error handler is an Express middleware with four parameters — Express uses the arity to identify it as an error handler.
import { Request, Response, NextFunction } from 'express'
// Custom error class — lets routes throw typed errors with status codes
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500,
public code?: string,
) {
super(message)
this.name = 'AppError'
}
}
// Four-parameter signature tells Express this is the error handler
// Express skips this middleware for normal requests — only called when next(err) is used
export function errorHandler(
err: Error,
req: Request,
res: Response,
_next: NextFunction, // must be declared even if unused
) {
// Log the full error server-side (never send stack trace to client)
console.error({
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
})
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
})
}
// Unknown error — 500 with no internal details exposed
res.status(500).json({ error: 'Internal server error' })
}In route handlers, throw by calling next(new AppError('User not found', 404)). For async route handlers, wrap with a try/catch that calls next(err) — Express does not automatically catch async errors until v5 (currently in RC). Alternatively, use the express-async-errors package which patches Express to handle this automatically.
Step 5: CRUD Routes with Proper HTTP Semantics
import { Router } from 'express'
import { randomUUID } from 'crypto'
import bcrypt from 'bcryptjs'
import { users, CreateUserSchema, UpdateUserSchema, PublicUser } from '../models/user'
import { validate } from '../middleware/validate'
import { authenticate } from '../middleware/auth'
import { AppError } from '../middleware/error'
export const usersRouter = Router()
function toPublicUser(user: Parameters<typeof users.set>[1]): PublicUser {
const { passwordHash, ...publicUser } = user
return publicUser
}
// GET /api/v1/users — list all users (protected)
usersRouter.get('/', authenticate, (_req, res) => {
const allUsers = Array.from(users.values()).map(toPublicUser)
res.json({ data: allUsers, total: allUsers.length })
})
// GET /api/v1/users/:id — get one user
usersRouter.get('/:id', authenticate, (req, res, next) => {
const user = users.get(req.params.id)
if (!user) return next(new AppError('User not found', 404))
res.json({ data: toPublicUser(user) })
})
// POST /api/v1/users — create user (201 + Location header)
usersRouter.post('/', validate(CreateUserSchema), async (req, res, next) => {
try {
const { name, email, password, role } = req.body
// Check for duplicate email
const emailTaken = Array.from(users.values()).some(u => u.email === email)
if (emailTaken) return next(new AppError('Email already registered', 409))
const id = randomUUID()
const passwordHash = await bcrypt.hash(password, 12)
const user = {
id,
name,
email,
passwordHash,
role,
createdAt: new Date().toISOString(),
}
users.set(id, user)
// 201 Created + Location header pointing to the new resource
res
.status(201)
.setHeader('Location', `/api/v1/users/${id}`)
.json({ data: toPublicUser(user) })
} catch (err) {
next(err)
}
})
// PATCH /api/v1/users/:id — partial update (not PUT — we don't replace the whole resource)
usersRouter.patch('/:id', authenticate, validate(UpdateUserSchema), (req, res, next) => {
const user = users.get(req.params.id)
if (!user) return next(new AppError('User not found', 404))
const updated = { ...user, ...req.body }
users.set(req.params.id, updated)
res.json({ data: toPublicUser(updated) })
})
// DELETE /api/v1/users/:id — 204 No Content (no body in response)
usersRouter.delete('/:id', authenticate, (req, res, next) => {
if (!users.has(req.params.id)) return next(new AppError('User not found', 404))
users.delete(req.params.id)
res.status(204).send() // 204 = success but no body
})Notice the correct status codes: 201 (not 200) for creation, 204 (no body) for deletion, 409 for conflict (email taken), 404 for not found. The Location header on 201 is specified in RFC 9110 §15.3.2 — it should point to the URI of the newly created resource. Many APIs skip this and regret it later when clients need to know where the resource lives.
Step 6: JWT Authentication
Stateless authentication for REST APIs means the client sends credentials (or a token derived from credentials) with every request — the server validates it and derives the user identity. JWT (JSON Web Token) is the dominant mechanism for this in 2026. For a detailed explanation of JWT structure and security, see BytePane's JWT guide.
import { Router } from 'express'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { z } from 'zod'
import { users } from '../models/user'
import { validate } from '../middleware/validate'
import { AppError } from '../middleware/error'
export const authRouter = Router()
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
const JWT_SECRET = process.env.JWT_SECRET ?? (() => {
throw new Error('JWT_SECRET environment variable is required')
})()
authRouter.post('/login', validate(LoginSchema), async (req, res, next) => {
try {
const { email, password } = req.body
const user = Array.from(users.values()).find(u => u.email === email)
// Use constant-time comparison to prevent timing attacks
// Even if user not found, still run bcrypt.compare to use equal time
const passwordHash = user?.passwordHash ?? '$2b$12$invalidhashpadding000000000000000000000000000000000000000'
const valid = await bcrypt.compare(password, passwordHash)
if (!user || !valid) {
// Return 401 with a generic message — never reveal which is wrong
return next(new AppError('Invalid credentials', 401))
}
const token = jwt.sign(
{ sub: user.id, role: user.role },
JWT_SECRET,
{ expiresIn: '1h', algorithm: 'HS256' },
)
res.json({
accessToken: token,
expiresIn: 3600,
tokenType: 'Bearer',
})
} catch (err) {
next(err)
}
})import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { AppError } from './error'
const JWT_SECRET = process.env.JWT_SECRET!
// Extend the Express Request type to include the authenticated user
declare global {
namespace Express {
interface Request {
user?: { id: string; role: string }
}
}
}
export function authenticate(req: Request, _res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return next(new AppError('Authentication required', 401))
}
const token = authHeader.slice(7)
try {
const payload = jwt.verify(token, JWT_SECRET) as { sub: string; role: string }
req.user = { id: payload.sub, role: payload.role }
next()
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return next(new AppError('Token expired', 401, 'TOKEN_EXPIRED'))
}
next(new AppError('Invalid token', 401))
}
}
// Role-based access middleware — use after authenticate
export function authorize(...roles: string[]) {
return (req: Request, _res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
return next(new AppError('Insufficient permissions', 403))
}
next()
}
}
// Usage: usersRouter.delete('/:id', authenticate, authorize('admin'), ...)Step 7: URL Design and Versioning Conventions
URL design is part of your public API contract — once clients depend on a URL shape, changing it is a breaking change. Get it right upfront:
# Collections and resources GET /api/v1/users # List all users POST /api/v1/users # Create user (body contains the new user) GET /api/v1/users/:id # Get one user PATCH /api/v1/users/:id # Partial update PUT /api/v1/users/:id # Full replace DELETE /api/v1/users/:id # Delete # Nested resources (belong to a parent) GET /api/v1/users/:id/orders # Orders belonging to user POST /api/v1/users/:id/orders # Create order for user GET /api/v1/users/:id/orders/:oid # Specific order # Filtering, sorting, pagination (query params — not in the path) GET /api/v1/users?role=admin # Filter GET /api/v1/users?sort=createdAt:desc # Sort GET /api/v1/users?limit=20&offset=40 # Pagination (offset-based) GET /api/v1/users?after=cursor_value # Cursor-based pagination (preferred at scale) # Actions that don't map to CRUD (use nouns, not verbs in paths — except here) POST /api/v1/users/:id/activate # Specific action POST /api/v1/auth/logout # Action endpoint — POST is appropriate POST /api/v1/auth/password-reset # kebab-case for multi-word # Versioning: URI prefix (most common), header (RFC preferred), or subdomain # URI: /api/v1/... /api/v2/... ← pragmatic, easy to test in browser # Header: Accept: application/vnd.myapp.v2+json ← RFC-compliant, harder to use # Subdomain: v2.api.example.com ← good for major architectural breaks
The URL versioning prefix (/api/v1/) is pragmatic and survives content negotiation debates — engineers can test it directly in a browser address bar, curl, or Postman without setting custom headers. Version the entire API at the same level rather than individual endpoints — partial versioning (/users/v2/:id) is impossible to reason about.
Step 8: Testing Your API
Before any automated tests, verify your endpoints manually with curl. This builds muscle memory for what correct HTTP looks like and catches obvious issues quickly.
# Start server
npx ts-node-dev src/server.ts
# Create a user (POST → 201 Created)
curl -s -i -X POST http://localhost:3000/api/v1/users -H "Content-Type: application/json" -d '{"name":"Alice","email":"[email protected]","password":"secret123","role":"admin"}'
# Response:
# HTTP/1.1 201 Created
# Location: /api/v1/users/abc-123
# {"data":{"id":"abc-123","name":"Alice","email":"[email protected]",...}}
# Login (POST → 200 + token)
TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/auth/login -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"secret123"}' | jq -r '.accessToken')
# Get all users (GET → 200, requires auth)
curl -s http://localhost:3000/api/v1/users -H "Authorization: Bearer $TOKEN" | jq .
# Invalid body (POST → 400 Validation failed)
curl -s -X POST http://localhost:3000/api/v1/users -H "Content-Type: application/json" -d '{"name":"A","email":"not-an-email"}' | jq .
# {"error":"Validation failed","fieldErrors":{"email":["Invalid email"],...}}
# Delete user (DELETE → 204 No Content — empty body)
curl -s -i -X DELETE http://localhost:3000/api/v1/users/abc-123 -H "Authorization: Bearer $TOKEN"
# HTTP/1.1 204 No ContentThe -i flag in curl shows response headers — essential for verifying that status codes and Location headers are correct. Pipe through jq (a JSON command-line processor) for formatted output. Alternatively, paste the JSON into BytePane's JSON Formatter for a readable view.
What to Add Next: Production Checklist
| Feature | Why | Recommended Package |
|---|---|---|
| Rate limiting | Prevent brute-force and DoS | express-rate-limit |
| Security headers | XSS, clickjacking, MIME sniffing | helmet |
| CORS | Allow browser clients to call the API | cors |
| Request logging | Audit trail, debugging | morgan + pino |
| OpenAPI docs | Auto-generated interactive API docs | swagger-autogen + swagger-ui-express |
| Database | Replace in-memory Map | Prisma (ORM) or pg (raw SQL) |
| Integration tests | Verify routes behave correctly | vitest + supertest |
import helmet from 'helmet'
import cors from 'cors'
import rateLimit from 'express-rate-limit'
import morgan from 'morgan'
// Security headers — XSS protection, HSTS, frameguard, etc.
app.use(helmet())
// CORS — allow only your frontend domain
app.use(cors({
origin: process.env.FRONTEND_URL ?? 'http://localhost:3001',
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
}))
// Rate limiting — 100 requests per 15 minutes per IP
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false,
}))
// Request logging
app.use(morgan('combined'))Frequently Asked Questions
What is the difference between REST and SOAP?
SOAP (Simple Object Access Protocol) is a protocol with a formal XML message format, a WSDL contract, and WS-Security for auth — stateful, heavyweight, and verbose. REST is an architectural style using HTTP natively — lightweight JSON, stateless, and any HTTP client can call it. Per the RapidAPI 2025 survey, 83% of new APIs are REST; SOAP persists in banking and enterprise systems for its formal contracts and built-in transaction semantics.
Should I use REST or GraphQL for my API?
REST for most cases — simpler to implement, debug, and cache at the HTTP layer. GraphQL for applications with complex, highly variable data requirements (mobile apps that need different field sets per screen, frontends that need to aggregate many resources in a single request). The key GraphQL advantage is eliminating over-fetching and under-fetching — each client requests exactly the fields it needs. The key REST advantage is leveraging HTTP caching, standard tooling, and browser-native fetch without a client library.
What status code should I return for a validation error?
Use 400 Bad Request for malformed or syntactically invalid input (missing required fields, wrong types). Use 422 Unprocessable Entity for semantically invalid input — the format is correct but the values fail business rules (e.g., an end date before a start date). In practice, many APIs use 400 for both and distinguish via the error body. RFC 9110 defines 422 as the semantically precise choice for validation failures where the request was well-formed.
How should I handle pagination in a REST API?
Two main approaches: offset pagination (?limit=20&offset=40) is simple but inconsistent when items are added/deleted mid-pagination. Cursor pagination (?after=cursor_value) is stable and scales better but requires an ordered, stable cursor field. Include pagination metadata in the response: { "data": [...], "total": 423, "limit": 20, "offset": 40, "nextCursor": "..." }. Use Link headers (RFC 5988) for next/prev/first/last links — consumed by HTTP clients automatically.
What is the difference between PUT and PATCH?
PUT replaces the entire resource — the request body must contain the complete representation. If the client sends PUT without a field, that field is removed. PATCH applies a partial update — only the fields in the request body are modified; omitted fields are left unchanged. Use PATCH for most update operations: it is less error-prone (client cannot accidentally delete fields it did not include) and more bandwidth-efficient. PUT is appropriate when you want clients to explicitly own the entire resource state.
How should I version my REST API?
URL path versioning (/api/v1/) is the most pragmatic approach — visible in URLs, easy to test, clear in logs. HTTP header versioning (Accept: application/vnd.myapi.v2+json) is RFC-compliant but harder to use and test. Always version from day one, even for internal APIs — it costs nothing and saves a painful refactor later. Maintain at least two versions simultaneously when making breaking changes; give clients 6-12 months notice before deprecating old versions.
Test and Debug Your REST API
Use BytePane's tools to inspect and debug your API: format JSON responses, decode JWT tokens from Authorization headers, check HTTP status code meanings, and validate your request/response shapes.