How to Build a REST API You Can Change Later

    6 min read3 approaches compared

    An API is a promise you can't quietly break. Internal code can be refactored freely; an endpoint someone else's production system calls cannot. So the useful frame for API design is not elegance, it's which changes will be additive later and which will require a version.

    The good news is that a handful of early decisions cover most of it: return objects rather than bare arrays, paginate with cursors rather than offsets, and pick one error shape. Each is nearly free upfront and expensive to retrofit.

    Design for the change you'll need to make

    Every one of these is about leaving room to add things without breaking existing callers.

    • Return an object at the top level, never a bare array. `{ "data": [...] }` can gain a `meta` field; `[...]` cannot gain anything.
    • Cursor pagination, not offset. Offset pagination silently skips and duplicates records when rows are inserted between pages.
    • One error shape everywhere, with a machine-readable code and a human-readable message. Clients switch on the code.
    • Additive changes only within a version: new optional fields are fine, renaming or removing is not.
    • Version from day one, even at v1 with no v2 planned. Adding versioning later means every existing caller is on an unversioned path forever.

    The approaches

    Framework REST (Express, FastAPI, NestJS, Django REST, Rails, Laravel)

    // best for: Most APIs. Conventional, well-understood, easy to hire for.

    • Express.js
    • FastAPI
    • NestJS
    • Django
    • Ruby on Rails
    • Laravel
    • Go

    Resources as nouns, HTTP verbs for actions, status codes that mean what they say. The convention is valuable precisely because it's boring — a new developer can guess the shape of your API correctly.

    FastAPI and NestJS deserve specific mention for deriving OpenAPI documentation from the code itself. Documentation generated from the same types that validate requests cannot drift, which is the failure mode of every hand-maintained API doc.

    Status codes worth getting right: 400 for malformed input, 401 for not authenticated, 403 for authenticated but not permitted, 404 for missing, 409 for a conflict such as a duplicate, 422 for well-formed input that fails validation, 429 for rate limited. Returning 200 with an error in the body defeats every generic HTTP client, retry policy, and monitoring tool.

    json
    // Bare array: can never gain pagination or metadata
    // without breaking every existing caller.
    [ { "id": 1 }, { "id": 2 } ]
    
    // Object at the top level: additive forever.
    {
      "data": [ { "id": 1 }, { "id": 2 } ],
      "meta": { "next_cursor": "eyJpZCI6MTA0fQ", "has_more": true }
    }
    
    // One error shape everywhere. Clients switch on the code,
    // humans read the message, and fields drives form errors.
    {
      "error": {
        "code": "validation_failed",
        "message": "Amount must be greater than zero.",
        "fields": { "amount": "Must be greater than zero" }
      }
    }

    The gotcha

    Offset pagination (`?page=2&limit=20`) is wrong whenever rows can be inserted. A record added while a client pages through shifts everything down, so page 2 repeats an item from page 1 and something else is never returned at all. It's invisible in testing and produces silently incomplete data in production. Use a cursor based on a stable sort key.

    Type-safe RPC (tRPC, GraphQL, gRPC)

    // best for: First-party clients you ship alongside the API.

    • TypeScript
    • GraphQL
    • Next.js
    • NestJS
    • Go

    When you own both ends, REST's uniform interface buys you less. tRPC gives a TypeScript client typed directly from the server implementation with no code generation and no schema file — rename a field on the server and the frontend fails to compile. For a monorepo shipping a web app against its own backend, that's a genuine step up in safety.

    GraphQL solves a different problem: clients specifying exactly what they need, which eliminates over-fetching and the endless proliferation of `?include=` parameters. The cost is real — N+1 resolution needs DataLoader, arbitrary query depth needs complexity limits, and caching is harder because everything is one POST endpoint.

    Both are poor choices for a public API consumed by third parties, where REST's ubiquity and curl-ability matter more than type safety you can't share.

    The gotcha

    GraphQL without query depth and complexity limits is a denial-of-service endpoint. A nested query walking a cyclic relationship a few levels deep can generate millions of database calls from a single small request. Depth limiting is not optional on any public GraphQL endpoint.

    Generated and platform APIs (Supabase, Hasura, PostgREST, Xano)

    // best for: CRUD-heavy applications where the API mirrors the schema.

    • Supabase
    • PostgreSQL
    • GraphQL
    • Xano
    • Appwrite
    • Nhost

    PostgREST — what Supabase exposes — generates a REST API directly from your Postgres schema, including filtering, ordering, and pagination. Combined with row-level security, authorization is enforced in the database, so the generated API is safe by construction rather than by remembering to add a check.

    This is genuinely excellent for the CRUD majority of an application, and it eliminates a large amount of boilerplate that does nothing but move rows.

    Where it stops is business logic. Anything that spans multiple tables transactionally, calls a third party, or enforces a rule the database can't express needs real code — a database function, an edge function, or a small conventional service alongside. Most successful implementations are a hybrid: generated API for CRUD, hand-written endpoints for the rest.

    The gotcha

    A generated API exposes your schema shape as your public contract, so renaming a column is a breaking API change. Put views in front of the tables you expose and let the view be the stable interface, so the underlying schema can evolve.

    Common pitfalls

    Returning 200 for errors

    A 200 with `{"success": false}` breaks every generic HTTP client, retry policy, and alerting rule, because they all key on status. Monitoring shows a healthy API while every request is failing. Use the status codes.

    No rate limiting

    One buggy client in a retry loop saturates your database. Rate limit per API key or user, return 429 with a `Retry-After` header, and make the limits visible in response headers so well-behaved clients can back off.

    Leaking internal errors

    Returning a raw exception exposes table names, file paths, and library versions. Log the detail server-side with a correlation ID, return a generic message plus that ID, and let support look it up.

    Inconsistent field naming

    `created_at` on one endpoint and `createdAt` on another means every client writes mapping code. Pick one convention and apply it everywhere, including nested objects and error payloads.

    Questions people actually ask

    Should I version my API?

    Yes, from the first release — `/v1/` costs nothing now. Retrofitting versioning later means every existing integration is pinned to an unversioned path you can never change.

    Cursor or offset pagination?

    Cursor, whenever records can be inserted or deleted. Offset pagination skips and duplicates rows as the underlying set shifts, and the bug is invisible until someone notices missing data. Offset is fine only for static, ordered datasets.

    REST or GraphQL?

    REST for public APIs and anything third parties consume — it's universally understood and trivially debuggable. GraphQL when clients genuinely need to shape their own responses and you can absorb the complexity of depth limiting and N+1 handling.

    Need someone who's done this before?

    Browse vetted developers who work with these tools day to day.

    Related guides