Every API will fail. The database will time out. The third-party service will return garbage. The user will send a request that your validation did not anticipate. The question is not whether your API will encounter errors — it is whether your error handling will make those failures debuggable, recoverable, and invisible to the end user when possible.
This guide covers the practical patterns for API error handling in TypeScript and Next.js, from HTTP status codes to circuit breakers, with code you can use in production.
HTTP Status Codes: The Foundation
Status codes are the first signal your API sends about what happened. Using them correctly means consumers (frontend code, mobile apps, other services) can handle errors programmatically without parsing your error message.
Client errors (4xx)
- 400 Bad Request: The request is malformed. Missing headers, invalid JSON, wrong Content-Type.
- 401 Unauthorized: No valid authentication credentials. The user is not logged in.
- 403 Forbidden: Authenticated but not authorized. The user is logged in but cannot access this resource.
- 404 Not Found: The resource does not exist.
- 409 Conflict: The request conflicts with current state (e.g., duplicate username, editing a deleted resource).
- 422 Unprocessable Entity: The request is syntactically valid but semantically wrong. Invalid email, negative quantity, date in the past.
- 429 Too Many Requests: Rate limit exceeded. Include a
Retry-Afterheader.
Server errors (5xx)
- 500 Internal Server Error: Something broke on the server. This should always be unexpected — your code should catch known failure modes and return appropriate 4xx codes.
- 502 Bad Gateway: An upstream service returned an invalid response.
- 503 Service Unavailable: The service is temporarily down. Include a
Retry-Afterheader if you can estimate recovery time. - 504 Gateway Timeout: An upstream service did not respond in time.
Common mistake: Returning 200 with an error in the body. This forces consumers to parse the response body to know if the request succeeded. Use proper status codes.
Error Response Format
A consistent error response format lets API consumers handle errors programmatically. Here is a practical format based on RFC 7807 (Problem Details for HTTP APIs):
// types/api-error.ts
interface APIError {
status: number
code: string // Machine-readable: "VALIDATION_ERROR", "NOT_FOUND"
message: string // Human-readable: "The email field is required"
details?: {
field?: string
reason: string
}[]
requestId?: string // For support/debugging
}Example responses:
// 422 Validation error
{
"status": 422,
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "reason": "Must be a valid email address" },
{ "field": "age", "reason": "Must be a positive integer" }
],
"requestId": "req_a1b2c3d4"
}
// 404 Not found
{
"status": 404,
"code": "NOT_FOUND",
"message": "User with ID 'usr_xyz' not found",
"requestId": "req_e5f6g7h8"
}
// 500 Internal error
{
"status": 500,
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred. Please try again.",
"requestId": "req_i9j0k1l2"
}Notice the 500 error does not include technical details. Never leak stack traces, database errors, or internal state to the client. Log the full error server-side; return a generic message to the client.
Error Handling in Next.js API Routes
Here is a pattern for consistent error handling across all your Next.js API routes:
// lib/api-error.ts
export class APIError extends Error {
constructor(
public status: number,
public code: string,
message: string,
public details?: { field?: string; reason: string }[]
) {
super(message)
this.name = 'APIError'
}
}
export function handleAPIError(error: unknown): Response {
if (error instanceof APIError) {
return Response.json(
{
status: error.status,
code: error.code,
message: error.message,
details: error.details,
},
{ status: error.status }
)
}
// Unknown error — log it, return generic 500
console.error('Unhandled API error:', error)
return Response.json(
{
status: 500,
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
{ status: 500 }
)
}// app/api/users/route.ts
import { APIError, handleAPIError } from '@/lib/api-error'
export async function POST(request: Request) {
try {
const body = await request.json()
// Validation
if (!body.email) {
throw new APIError(422, 'VALIDATION_ERROR', 'Validation failed', [
{ field: 'email', reason: 'Email is required' },
])
}
// Business logic
const existingUser = await db.user.findByEmail(body.email)
if (existingUser) {
throw new APIError(409, 'CONFLICT', 'A user with this email already exists')
}
const user = await db.user.create({ email: body.email })
return Response.json(user, { status: 201 })
} catch (error) {
return handleAPIError(error)
}
}This pattern gives you three things: consistent error format across all routes, separation of known errors (throw APIError) from unknown errors (caught by the generic handler), and clean route handlers that focus on business logic.
Retry Logic with Exponential Backoff
When your API calls external services, some failures are transient. A database timeout, a network blip, a 503 from a rate-limited service. Retry logic handles these gracefully.
// lib/retry.ts
interface RetryConfig {
maxRetries: number
baseDelayMs: number
maxDelayMs: number
retryableStatuses: number[]
}
const DEFAULT_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelayMs: 100,
maxDelayMs: 10000,
retryableStatuses: [408, 429, 500, 502, 503, 504],
}
export async function fetchWithRetry(
url: string,
options: RequestInit = {},
config: Partial<RetryConfig> = {}
): Promise<Response> {
const { maxRetries, baseDelayMs, maxDelayMs, retryableStatuses } = {
...DEFAULT_CONFIG,
...config,
}
let lastError: Error | null = null
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, {
...options,
signal: AbortSignal.timeout(30000), // 30s timeout
})
// Don't retry client errors (except 429)
if (!retryableStatuses.includes(response.status)) {
return response
}
// Check Retry-After header for 429
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After')
if (retryAfter) {
const delaySeconds = parseInt(retryAfter, 10)
if (!isNaN(delaySeconds)) {
await sleep(delaySeconds * 1000)
continue
}
}
}
if (attempt === maxRetries) {
return response // Return the error response on final attempt
}
// Exponential backoff with jitter
const delay = Math.min(
baseDelayMs * Math.pow(2, attempt) + Math.random() * 100,
maxDelayMs
)
await sleep(delay)
} catch (error) {
lastError = error as Error
if (attempt === maxRetries) throw lastError
const delay = Math.min(
baseDelayMs * Math.pow(2, attempt) + Math.random() * 100,
maxDelayMs
)
await sleep(delay)
}
}
throw lastError || new Error('Retry failed')
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}Key principles:
- Exponential backoff: Each retry waits longer (100ms, 200ms, 400ms). This gives the failing service time to recover.
- Jitter: Random delay added to prevent all clients retrying at the same instant (thundering herd problem).
- Max delay cap: Without it, backoff can grow to absurd values (hours). Cap it at a reasonable maximum.
- Only retry transient errors: Never retry 400 or 422. The request is wrong and retrying will not fix it.
- Respect Retry-After: When a service returns 429 with this header, use the specified delay instead of your own backoff.
Circuit Breaker Pattern
Retries handle individual transient failures. Circuit breakers handle sustained failures. If a service is down, retrying every request 3 times just triples the load on an already failing system.
// lib/circuit-breaker.ts
type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'
interface CircuitBreakerConfig {
failureThreshold: number // Open after N failures
resetTimeoutMs: number // Try again after N ms
monitorWindowMs: number // Count failures within this window
}
export class CircuitBreaker {
private state: CircuitState = 'CLOSED'
private failures: number[] = []
private lastFailureTime = 0
private config: CircuitBreakerConfig
constructor(
private name: string,
config: Partial<CircuitBreakerConfig> = {}
) {
this.config = {
failureThreshold: 5,
resetTimeoutMs: 30000,
monitorWindowMs: 60000,
...config,
}
}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.config.resetTimeoutMs) {
this.state = 'HALF_OPEN'
} else {
throw new Error(`Circuit breaker '${this.name}' is OPEN`)
}
}
try {
const result = await fn()
this.onSuccess()
return result
} catch (error) {
this.onFailure()
throw error
}
}
private onSuccess() {
this.failures = []
this.state = 'CLOSED'
}
private onFailure() {
const now = Date.now()
this.failures.push(now)
this.lastFailureTime = now
// Remove failures outside the monitoring window
this.failures = this.failures.filter(
(t) => now - t < this.config.monitorWindowMs
)
if (this.failures.length >= this.config.failureThreshold) {
this.state = 'OPEN'
console.error(`Circuit breaker '${this.name}' opened after ${this.failures.length} failures`)
}
}
}// Usage in an API route
const paymentCircuit = new CircuitBreaker('stripe', {
failureThreshold: 3,
resetTimeoutMs: 60000,
})
export async function POST(request: Request) {
try {
const charge = await paymentCircuit.execute(() =>
stripe.charges.create({ amount: 1000, currency: 'usd' })
)
return Response.json(charge)
} catch (error) {
if (error.message.includes('Circuit breaker')) {
return Response.json(
{ status: 503, code: 'SERVICE_UNAVAILABLE', message: 'Payment service temporarily unavailable' },
{ status: 503 }
)
}
return handleAPIError(error)
}
}Logging Errors Properly
Error handling code that does not log is useless. When a 500 happens in production, you need enough context to reproduce and fix the issue without asking the user what they did.
// lib/logger.ts
export function logError(error: unknown, context: Record<string, unknown> = {}) {
const entry = {
level: 'error',
timestamp: new Date().toISOString(),
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
...context,
}
// Structured JSON logging — works with Vercel, Datadog, CloudWatch
console.error(JSON.stringify(entry))
}// In your error handler
export function handleAPIError(error: unknown, request: Request): Response {
const requestId = crypto.randomUUID()
if (error instanceof APIError) {
logError(error, {
requestId,
status: error.status,
code: error.code,
path: new URL(request.url).pathname,
method: request.method,
})
return Response.json(
{ ...error, requestId },
{ status: error.status }
)
}
// Unknown errors get full logging
logError(error, {
requestId,
path: new URL(request.url).pathname,
method: request.method,
// Do NOT log request body in production — may contain PII
})
return Response.json(
{ status: 500, code: 'INTERNAL_ERROR', message: 'An unexpected error occurred', requestId },
{ status: 500 }
)
}What Error Handling Misses: The Monitoring Layer
Good error handling catches errors you anticipate. Monitoring catches everything else.
Your try/catch blocks handle known failure modes. Your circuit breakers prevent cascading failures. Your retry logic handles transient issues. But none of these systems can tell you:
- Your
/api/checkouterror rate jumped from 0.1% to 5% in the last 10 minutes - P95 latency on
/api/searchdoubled after the last deploy - A specific API route is returning 500s that are being caught and logged but nobody is looking at the logs
Error handling is reactive — it responds to individual errors. Monitoring is proactive — it watches the aggregate and alerts you when patterns emerge.
Nurbak Watch fills this gap for Next.js applications. It runs inside your server via instrumentation.ts, tracks every API route automatically, and sends alerts via Slack, email, or WhatsApp in under 10 seconds when error rates spike or response times degrade. Five lines of code, $29/month flat, free during beta.
Error handling and monitoring are complementary layers. Write solid error handling code. Then add monitoring so you know when that code is actually catching errors in production.

