Skip to content
    All posts
    Full Stack

    Designing an API Your Frontend Actually Wants

    Most frontend complexity is a backend design decision arriving in disguise. What changes when the same person builds both.

    3 min readby

    The best argument for full-stack work isn't productivity. It's that you stop shipping APIs that are convenient for the database and hostile to the screen.

    Most of the ugliest frontend code I've written existed to paper over an API shape. Here are the decisions that cause it.

    1. Return what the screen needs, in one request

    A perfectly normalised API forces the client to reassemble the data:

    text
    GET /orders/123          → { id, userId, items: [itemId, itemId] }
    GET /users/456           → { id, name }
    GET /items/1             → { id, productId, qty }
    GET /products/9          → { id, name, price }

    Four round trips, sequential because each depends on the last, to render one order. On a 200ms mobile connection that's most of a second of pure waiting — and the client now owns joining logic that the database does far better.

    json
    GET /orders/123
    {
      "id": "123",
      "placedAt": "2026-08-01T09:30:00Z",
      "customer": { "id": "456", "name": "Anita R." },
      "items": [
        { "product": "Wireless Mouse", "qty": 2, "unitPrice": 1299 }
      ],
      "total": 2598,
      "status": "shipped"
    }

    Purists object that this duplicates data across endpoints. Correct, and worth it. Read endpoints exist to serve views, not to mirror your schema. If you need generality, that's what GraphQL or a ?include= parameter is for — but a hand-shaped endpoint per major screen is simpler than either and usually enough.

    2. Send data in a form the UI can use

    • Money as integers in the minor unit. 1299, not 12.99. Floats and currency don't mix, and every client will format it differently anyway.
    • Dates as ISO 8601 with an offset. 2026-08-01T09:30:00Z. Never a pre-formatted string — you've made a locale decision on the server that belongs on the client.
    • Enums as stable machine values. "shipped", not "Shipped" or 2. The display string is the frontend's problem, and it changes without a deployment.
    • Booleans as booleans. Not "Y", not 1, not "true".
    • Missing means `null`, consistently. Not sometimes absent, sometimes null, sometimes "". Every inconsistency here becomes a defensive check on the client.

    3. Make errors machine-readable

    json
    // Useless
    { "error": "Validation failed" }
    
    // Usable
    {
      "error": {
        "code": "VALIDATION_FAILED",
        "message": "Some fields need attention.",
        "fields": {
          "email": { "code": "ALREADY_REGISTERED",
                     "message": "This email is already registered." },
          "age":   { "code": "OUT_OF_RANGE", "message": "Must be 18 or older." }
        }
      }
    }

    With the second shape, the frontend puts each message under the right field and focuses the first one. With the first, it shows a toast saying "Validation failed" and the user has to guess. That's not a frontend deficiency — it's the only thing possible with the information provided.

    The code matters as much as the message. Codes are what you branch on; messages are what you display and translate. Branching on English prose breaks the moment someone improves the wording.

    4. Paginate everything that can grow

    Every list endpoint, from day one, even when the table has nine rows. The one that bites is always the one that was obviously small — until one customer imported 40,000 records and the endpoint tried to serialise all of them.

    Cursor pagination over offset for anything real-time; offset pagination silently skips and duplicates rows when the underlying data changes between pages.

    5. Version before you need to

    /api/v1/ from the first commit. It costs four characters and it's the difference between shipping a breaking change and coordinating a simultaneous deploy across web, mobile, and whichever integration you forgot about.

    6. Share the types

    ts
    // generated from the OpenAPI spec — not hand-written twice
    import type { Order, OrderStatus } from '@acme/api-types';

    Generate client types from your schema — OpenAPI, tRPC, Prisma, whatever fits your stack. Hand-maintaining a parallel set of interfaces means they drift, and the drift is discovered in production because TypeScript will confidently confirm your incorrect belief about the response shape.

    The heuristic

    If the frontend needs a transformation layer to render your response, the response is wrong.

    Some mapping is normal. But when a mappers/ folder appears and starts growing, that's not frontend architecture — that's a backend design problem with a frontend cost centre, and it'll be paid every time someone builds a new screen.

    APIBackendFull StackArchitecture

    Keep reading

    PAPulapa Arun Kumar

    Full-stack developer building performant, clean, and user-friendly web & mobile applications. Also written as Arun Kumar Pulapa — same person, surname first.

    Built with

    ReactTypeScriptTailwind CSSVite

    © 2026 Pulapa Arun Kumar. All rights reserved.