Portfolio/Writing/Curl it before you build the UI: API design from the front end’s side of the table

Curl it before you build the UI: API design from the front end’s side of the table

If the front end is "just CSS", the API should be good enough to prove a feature works with no front end at all: a curl, an assertion, a green check in CI, before a single component exists. Shape responses around screens, keep business logic where a request can test it, and stop making the browser your second backend.

Every few months the same argument restarts. A backend engineer glances at a broken screen and says, with total confidence, "that's a CSS problem." A front-end engineer looks at a 200 OK carrying success: false and says nothing, because their eye has started twitching. "It works in Postman." "It's just a button." "Can't the front end just handle it." Fine. Let's take the insult seriously. If the front end really is just paint, then the API should be good enough to prove a feature works with no front end at all: a curl, an assertion, a green check in CI, before a single component exists. That is the bar. This post is about designing APIs that clear it, written from the side of the table that gets handed the bill.

Said with love, as usual
Affectionate ribbing. Backend engineers keep the money correct, the data consistent, and the pager quiet, and the good ones build APIs so clean the UI writes itself. This post is aimed at the other kind of API.

The house rules of the standoff#

The trench humour is older than REST. A sample of the shots fired, in both directions, and what each one costs once the laughing stops:

Said with a straight faceWhat the other side hearsThe actual cost
"It's just a CSS problem."The layout breaks because the response has three date formats and a null where the contract promised a string.Hours in the network tab proving it is not, in fact, CSS.
"It works in Postman.""It works on my machine", wearing a fake moustache.The one request works. The five the screen actually needs do not compose.
"Can't the front end just handle it?"Please reimplement our tax rules in a utils file with no tests.Two copies of a business rule that drift within a sprint.
"The API is RESTful."There are nouns in the URLs. Everything else is a surprise.Every endpoint is its own dialect and the client is the translator.
"Just loop over the list and call the endpoint for each row."Fifty requests to render one table.A waterfall, a rate limit, and a p95 nobody wants to discuss.
"Frontend is easy, you just move divs around."This person has never fought the iOS keyboard for the viewport height.A hiring loop that screens for the wrong thing.
"We'll return 200 and put the error in the body."Your monitoring is now blind and every caller writes a special case.Retries that should not happen, alerts that never fire.

Business logic lives where the tests can reach it#

There is a whole post on why business logic belongs on the server. The short, testing-flavoured version: a rule that exists only in the browser has no test that runs in CI, no second client that obeys it, and no way for QA to check it except by clicking. "We'll validate on the front end to save a round trip" is how you end up with a pricing rule whose only test is a person with a mouse.

  • The server decides, and the decision can be asserted with a request and no DOM.
  • The client mirrors a rule only for instant feedback, derived from the same schema, and deletable without weakening anything real.
  • If skipping a client-side check lets money move wrong or access leak, it was never a check, it was a hint.
Ask of any rule: can I prove it holds with a curl and an assertion? If the only proof is a screenshot, the rule is in the wrong place.

Design the API for the screen, not the schema#

The fastest way to push work into the browser is to return the database: rows, foreign keys, and a cheerful "the front end can join it." A screen is not a table. Design the response around what the view renders.

Schema-shaped responseScreen-shaped response
Returns userId, client fetches /users/:id for the nameReturns user: { id, name, avatarUrl } inline
Returns raw status codes, client owns the label mapReturns status plus statusLabel and a statusColor intent
One entity per endpoint, client makes six calls per pageOne endpoint per view, composed server-side
Client computes totals, badge counts, "is overdue"Server sends the derived numbers the header displays
GET /orders returns everything, client filters and sortsEndpoint takes filter, sort, page and returns what was asked for
Permissions implied, client guesses which buttons to showcapabilities: { canEdit, canCancel } on every resource

This is what a backend-for-frontend is for: a thin layer that composes the view's data so the client does not. It does not mean a bespoke endpoint per button. It means the unit of an API response is a screen's worth of data, assembled by the side that already has all of it.

jsonc
// GET /views/order-detail?id=8891  — one request, everything the screen paints
{
  "order": {
    "id": "8891",
    "reference": "ORD-8891",
    "placedAt": "2026-08-30T14:12:00Z",
    "status": "processing",
    "statusLabel": "Processing",
    "statusColor": "warning",
    "total": { "amount": 12840, "currency": "EUR", "display": "€128.40" }
  },
  "customer": { "id": "42", "name": "Ada Lovelace", "email": "ada@example.com" },
  "lines": [
    { "sku": "A-1", "name": "Widget", "qty": 2, "lineTotal": { "amount": 4000, "display": "€40.00" } }
  ],
  "capabilities": { "canCancel": true, "canRefund": false, "canEditAddress": true },
  "timeline": [
    { "at": "2026-08-30T14:12:00Z", "label": "Order placed" },
    { "at": "2026-08-31T09:03:00Z", "label": "Payment captured" }
  ]
}

The client renders this top to bottom. It does not add the lines up, does not decide whether the Cancel button exists, and does not map processing to a colour via a switch statement it maintains. Every one of those is a decision the server made once and can test once.

Screen-shaped is not the same as leaking layout
The server sends statusColor: "warning", an intent, not #f59e0b. It sends display: "€128.40" as a convenience alongside the machine amount, never instead of it. The client still owns pixels, fonts, spacing, and where things sit.

If it needs the UI to test, it isn't done#

Here is the standard that makes "the front end is easy" true instead of insulting: every feature has a path to green that does not involve a browser. A request, an expected response, an assertion, running in CI, the day the endpoint lands and before a component exists.

What that buys you:

  • Front end and back end build in parallel against the contract, instead of the front end waiting a week and then discovering the shape.
  • Real integration tests, not thirty end-to-end specs that each boot Chrome to check a number.
  • QA gets a script, not a treasure hunt of "click here, then here, then wait."
  • A bug repro is a .http file, not a screen recording.
  • The "works in Postman" flex becomes a committed, versioned, automated fact.
bash
# Feature: a user can cancel a processing order. No browser involved.

TOKEN=$(http --print=b POST $API/auth/login email=ada@example.com password=... | jq -r .token)

# 1. the order starts processing, and the action is offered
http GET $API/views/order-detail id==8891 "Authorization:Bearer $TOKEN" \
  | jq -e '.order.status == "processing" and .capabilities.canCancel == true'

# 2. cancel it
http POST $API/orders/8891/cancel "Authorization:Bearer $TOKEN" \
  | jq -e '.status == "cancelled"'

# 3. the capability is gone, and a second cancel is rejected, not silently ok
http --check-status POST $API/orders/8891/cancel "Authorization:Bearer $TOKEN"
test $? -eq 4   # 4xx, because it is no longer allowed

That is the whole feature, proven. The UI work left is a button that POSTs to a URL and re-renders a payload it already knows how to draw.

Postman is not a test suite
A saved collection that one person runs by hand is "works on my machine" with better branding. Put the requests in CI (a .http file, a newman run, a bruno folder, a few hurl files) so a broken contract fails a pipeline, not a demo.

The front end’s half of the bargain#

This only works if the front end holds up its end, so, in fairness:

  • It owns presentation, view models, interaction state, routing, and accessibility, and it does not get to outsource those to a viewData blob that dictates layout.
  • It does not demand forty bespoke endpoints. A screen's worth of data is a reasonable unit; one endpoint per component is how you get a backend nobody can change.
  • It parses the response at the boundary, once, and fails loud when the contract breaks, instead of ?? {} and a shrug.
  • It stops smuggling business rules into reducers and calling them "derived state".
  • When it says "the API is awkward", it brings the payload it wants, not just a complaint.
The deal is simple. The backend sends decisions and screen-shaped data. The front end renders them honestly and stops reimplementing the backend. Both sides get to stop doing the other's job badly.

The checklist#

  1. Every feature has a non-UI test: request, assertion, in CI, from the day the endpoint ships.
  2. Responses are shaped around views, not tables. One request paints one screen where it can.
  3. Derived values (totals, counts, labels, "is overdue", "can cancel") come from the server.
  4. Permissions are explicit capabilities flags, not something the client infers.
  5. Errors use real status codes and one envelope. No 200 with success: false.
  6. List endpoints take filter, sort, and pagination, and honour them.
  7. The client mirrors a rule only for feedback, from the shared schema, never as the gate.
  8. Contract checks (schema, example requests) run in the pipeline, not in someone's saved Postman tab.

Anti-patterns#

  • A feature whose only proof it works is a screenshot in the PR.
  • "The front end can format it" for money, dates, or enum labels, with no machine-readable value alongside.
  • GET returns the whole table; filtering and sorting are "a front-end concern".
  • N+1 by design: a list endpoint that forces one call per row to be useful.
  • 200 OK with an error body, so retries fire and alerts do not.
  • A business rule that lives only in a React reducer, wired to nothing that runs headless.
  • One endpoint per component, until changing the backend needs a front-end release.
  • Three date formats across four endpoints, and "just normalise it on the client".
  • "It works in Postman" standing in for a test that runs without you.
Summary
The "front end is just CSS" jab is only fair if the API makes it true. That means business logic and derived values on the server, where a request and an assertion can prove them; responses shaped around screens, not database tables; explicit capabilities and real status codes; and every feature carrying a non-UI test that runs in CI from day one. Do that and the UI genuinely is a thin, honest coat of paint, which is the compliment the backend was fishing for. Skip it and the browser quietly becomes your second backend, the one with no tests.

Next up
SSE vs WebSockets (and polling): picking a real-time transport on the front end

How Server-Sent Events, WebSockets and plain polling differ from the client side, the auth and reconnection gotchas each one has, and a project-by-project guide for which to reach for.

Read next →