Zachary
Skip to main content

Zachary

REST API Design Habits That Scale

REST API design concept cover

A scalable API is usually not the one with the most endpoints or the fanciest architecture diagram. It is the one that stays understandable as more developers, more features, and more edge cases arrive. The reason I care about API design so much is simple. Poor API decisions spread pain everywhere. They confuse the frontend, complicate testing, blur business rules, and make debugging harder. Good design does the opposite. It makes the rest of the system feel calmer.

Consistency is the first scaling strategy

When I say an API should scale, I do not mean only scale in traffic. I also mean scale in team understanding. The easiest way to lose that is inconsistent naming, response structure, and ownership. If one route returns one shape, another returns a different error format, and a third mixes resource and action based naming with no pattern, the API becomes expensive to use.

Consistency lowers cognitive load. I want route naming to feel predictable, status codes to mean what they should mean, and response bodies to follow recognizable shapes. That kind of discipline saves time on both the frontend and backend every single week.

How I think about resource design

Model the domain clearly

Routes should reflect the business domain rather than internal implementation details. If the system is about clients, projects, listings, messages, or invoices, the API should express those concepts directly. That keeps the surface area understandable for developers who did not build the original version.

I avoid muddy route naming because unclear language tends to hide unclear thinking. Good naming often forces better modeling.

Use actions carefully

Sometimes actions are necessary, especially for workflows that are not simple CRUD. That is fine. The important part is being honest about where they belong. If a route performs a business transition like publish, archive, assign, or approve, I want that action to be explicit rather than hidden in a vague update endpoint.

Clear actions improve auditability and reduce accidental behavior.

A well structured route handler

Here is how I structure a Next.js App Router API route. The pattern separates validation, business logic, and response formatting:

// app/api/projects/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { db } from '@/lib/db';

export async function GET(request: NextRequest) {
  const session = await getServerSession();
  if (!session) {
    return NextResponse.json(
      { error: { code: 'UNAUTHORIZED', message: 'Authentication required' } },
      { status: 401 }
    );
  }

  const { searchParams } = new URL(request.url);
  const page = parseInt(searchParams.get('page') || '1', 10);
  const limit = Math.min(parseInt(searchParams.get('limit') || '20', 10), 100);
  const status = searchParams.get('status');

  const where = status ? { status, userId: session.user.id } : { userId: session.user.id };
  const [projects, total] = await Promise.all([
    db.project.findMany({ where, skip: (page - 1) * limit, take: limit, orderBy: { createdAt: 'desc' } }),
    db.project.count({ where }),
  ]);

  return NextResponse.json({
    data: projects,
    pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
  });
}

export async function POST(request: NextRequest) {
  const session = await getServerSession();
  if (!session) {
    return NextResponse.json(
      { error: { code: 'UNAUTHORIZED', message: 'Authentication required' } },
      { status: 401 }
    );
  }

  const body = await request.json();
  const { name, client, description } = body;

  if (!name || !client) {
    return NextResponse.json(
      { error: { code: 'VALIDATION_ERROR', message: 'Name and client are required', fields: { name: !name ? 'Required' : null, client: !client ? 'Required' : null } } },
      { status: 400 }
    );
  }

  const project = await db.project.create({
    data: { name, client, description, userId: session.user.id },
  });

  return NextResponse.json({ data: project }, { status: 201 });
}

Every route follows the same pattern: authenticate, validate, execute, respond with a consistent shape. This makes it easy for the frontend to build generic error handling and response parsing.

Response shapes matter more than people admit

A good response format makes consuming the API easier. I care about stable keys, useful metadata, and errors that are specific enough to act on. Frontends should not have to reverse engineer what the backend meant. If pagination exists, say so clearly. If validation fails, return field level context when appropriate. If something cannot be found, be direct about it.

This is not just nicety. It directly affects user experience because the frontend can only build good flows when the API communicates clearly.

Error response shape

I use a consistent error shape across all endpoints:

interface ApiError {
  error: {
    code: string;
    message: string;
    fields?: Record<string, string | null>;
  };
}

// 400 Validation Error
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input",
    "fields": {
      "email": "Must be a valid email address",
      "budget": "Must be at least 250"
    }
  }
}

// 404 Not Found
{
  "error": {
    "code": "NOT_FOUND",
    "message": "Project not found"
  }
}

// 403 Forbidden
{
  "error": {
    "code": "FORBIDDEN",
    "message": "You do not have access to this resource"
  }
}

The code field is machine readable, so the frontend can match on it programmatically. The message field is human readable, suitable for displaying to users. The fields object maps directly to form inputs for inline validation display.

Pagination response envelope

For list endpoints, I always wrap the response in a pagination envelope:

interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

// GET /api/projects?page=2&limit=10
{
  "data": [
    { "id": "proj-1", "name": "weROI Platform", "client": "weROI", "status": "active" },
    { "id": "proj-2", "name": "Portfolio Site", "client": "Self", "status": "completed" }
  ],
  "pagination": {
    "page": 2,
    "limit": 10,
    "total": 47,
    "totalPages": 5
  }
}

This shape makes it trivial for the frontend to build pagination controls, infinite scroll, or load more patterns. The frontend never has to guess whether there are more pages or how many total records exist.

Auth middleware pattern

For Express or standalone Node APIs, I extract authentication into middleware so every route does not repeat the same checks:

import { Request, Response, NextFunction } from 'express';
import { verifyToken } from '@/lib/auth';

export function requireAuth(req: Request, res: Response, next: NextFunction) {
  const header = req.headers.authorization;
  if (!header || !header.startsWith('Bearer ')) {
    return res.status(401).json({
      error: { code: 'UNAUTHORIZED', message: 'Bearer token required' },
    });
  }

  const token = header.slice(7);
  const payload = verifyToken(token);

  if (!payload) {
    return res.status(401).json({
      error: { code: 'TOKEN_INVALID', message: 'Token expired or invalid' },
    });
  }

  req.user = payload;
  next();
}

// Usage
app.get('/api/projects', requireAuth, projectController.list);
app.post('/api/projects', requireAuth, projectController.create);

Error handling should serve developers and users

I try to treat errors as part of the API contract, not an afterthought. That means matching status codes correctly, keeping messages structured, and separating user safe messaging from internal debugging detail. A client should be able to respond appropriately to an error without guessing what happened.

At the same time, I do not want errors to become generic mush. "Something went wrong" is not enough when the issue is validation, authorization, missing resources, or a business rule conflict. Better error contracts make debugging calmer and product behavior clearer.

Rate limiting approach

For public APIs or endpoints that accept unauthenticated requests (like contact forms), I add basic rate limiting. The approach depends on the deployment: for Vercel or serverless, I use an in-memory store with IP based tracking or a Redis backed solution. The key principle is to fail gracefully with a clear response:

// Respond with 429 when rate limited
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests. Please try again in 60 seconds."
  }
}

// Include Retry-After header
// Retry-After: 60

I set rate limits based on the endpoint's expected usage pattern. A contact form might allow 5 requests per minute per IP. A search endpoint might allow 30. Public listing endpoints that serve cached data might have higher limits or none at all.

Documentation habits

I document APIs as I build them, not after. My habit is to maintain a simple markdown file in the repo that describes each endpoint, its expected inputs, response shapes, and error codes. For larger projects, I use OpenAPI specs generated from TypeScript types.

The minimum documentation I write for every endpoint: the HTTP method and path, required and optional parameters, the success response shape, possible error codes, and whether authentication is required. This takes five minutes per endpoint and saves hours of back and forth with frontend developers.

API testing approach

I test APIs at three levels. Unit tests cover business logic functions in isolation (validation, data transformation, authorization checks). Integration tests hit the actual routes with a test database and verify the full request/response cycle. Manual testing with tools like Postman or curl covers edge cases and helps me verify the developer experience of the API.

For integration tests, I focus on the happy path plus the most common error cases: missing auth, invalid input, not found, and forbidden. If an endpoint has complex business rules (like budget minimums or status transitions), those get dedicated test cases.

Versioning, evolution, and restraint

A lot of scaling pain comes from not thinking about evolution early enough. That does not mean overengineering the first version. It means leaving yourself room to add fields safely, deprecate behavior carefully, and separate stable contracts from implementation churn. Small forward looking habits go a long way.

I also try not to expose everything just because I can. A tighter API surface is easier to secure, document, and maintain. Every public field or route becomes something you may need to support later.

The backend habit that helps the frontend most

The backend habit that helps the frontend most is empathy. I try to design endpoints the way I would want to consume them. That means useful defaults, predictable filters, honest naming, and responses that support real interface needs instead of forcing the frontend to perform gymnastics.

If you want to see how I think across both sides of the stack, explore my custom software service, read the CORS article, or contact me if your product needs backend decisions that will still make sense a year from now.

Need help building something similar?

I design and build premium websites, product experiences, and custom software with a strong focus on clarity, performance, and real business outcomes.