APIs

How to Design a Public API a Frontend Can Call

A public API is a contract written in public, and the frontend is the party that has to live inside it. That framing sounds obvious, yet most API design advice is written from the server's point of view: how to version, how to paginate, how to name resources. The frontend gets mentioned as a consumer, a downstream concern. This is backwards. The frontend is where the contract actually gets stress-tested, in a browser tab, on a phone, on a train, at the worst possible moment. Designing the API a frontend can call means designing for that stress test.

What follows is a set of decisions that tend to separate an API a frontend team can build against from one that quietly costs them weeks. None of it is exotic. Most of it is about admitting where the real work happens.

The screen is a query, and it usually needs several resources at once

A product detail page is not a row in a table. It is a title, a price, a gallery, a rating summary, shipping estimates, a recommendation strip, and a "customers also bought" module that the designer added late. On the server, those live in different tables and often different services. On the screen, they arrive together or the page looks broken.

An API that exposes only one resource per endpoint forces the frontend into a fan-out pattern: six requests on mount, six loading states, six failure paths, and a layout that shifts as each one lands. This is the single most common source of frontend pain, and it is invisible in the API documentation because every individual endpoint looks fine.

There are two honest fixes. One is a composed read endpoint that returns exactly what a named screen needs, curated by the backend, versioned with the screen. The other is a batch or aggregation layer the frontend can call with a list of resource requirements and get back a map. Both have costs: the composed endpoint couples the API to the UI, and the batch layer needs careful caching. The wrong answer is to pretend the frontend should just call six endpoints and stitch. It can stitch. It will hate it.

Coupling the API to a screen is not a sin. Pretending the coupling does not exist is the sin.

List endpoints need a stable sort and a cursor before anyone asks for them

Every list on a frontend is paginated, sorted, filtered, and searchable, usually within the first sprint. The API that says "we'll add pagination later" is the API that ships with offset pagination, and offset pagination is a trap. Records get inserted while the user scrolls. The second page skips an item or repeats one. The bug shows up as a duplicate card in an infinite scroll, which looks like a frontend bug and gets reported to the frontend team.

Cursor pagination solves this, and the frontend cannot retrofit it. The cursor has to be issued by the server, opaque, and tied to a stable sort order. That means the API must pick a default sort and guarantee it, typically a unique field like a creation timestamp plus an ID tiebreaker. The frontend then holds a cursor string and asks for "the next batch." It does not need to understand the cursor. It needs to trust it.

The same logic applies to filtering. A frontend filter panel needs a defined vocabulary of filter keys and values, and it needs the API to tell it what is filterable. Hardcoding filter options in the client is a decision that ages badly. An endpoint that returns the available facets for a collection costs a little backend work and saves a hundred small client deployments.

Error bodies are UI copy, so design them like copy

A frontend cannot show a stack trace to a customer. It also cannot show an empty toast that says "Something went wrong." The API is the only party that knows why a request failed, and the frontend is the only party that knows how to phrase it for the screen. That division means the error body has to carry enough structure for the frontend to make a decision.

A useful error body has, at minimum: a stable machine-readable code, a human-readable message safe to display, a field pointer when the failure is validation-related, and a retry hint for transient failures. The code is what the frontend branches on. The message is what it falls back to. The field pointer is what lets a form highlight the right input instead of dumping a banner at the top.

The subtle part is stability. A code like email_already_registered is a contract. Renaming it to email_taken in a minor release breaks every client that branched on it, silently. Error codes deserve the same deprecation discipline as endpoints.

Validation deserves its own shape. A 422 with a flat list of strings is nearly useless when the form has twelve fields. A map from field path to error code and message is what the client can actually render. The extra structure on the server is a few lines. The absence of it is a custom parser in every client.

Rate limits belong in headers, not in a surprise

Every public API gets rate-limited eventually. The question is whether the frontend learns about it from a 429 or from a header it can read ahead of time. A 429 with no Retry-After is a game of guess-and-backoff in the client, and backoff without a hint tends to be either too aggressive (hammering) or too timid (a spinner that lasts a minute).

Rate limit headers on every response, not just the ones that fail, let the client behave. The frontend can show a soft warning, throttle a polling loop, or disable a button before the user hits the wall. That is a UX problem the API can solve for free.

The same principle covers quotas and plan limits. If a user is on a tier that allows fifty exports a month, the export endpoint should tell the client the remaining count on success, not wait until the fifty-first call. A frontend that knows the remaining count can disable the button and explain why. A frontend that does not knows only how to fail.

Payload size is a frontend performance budget, and the API sets it

A JSON payload that is 400 KB on the wire is a design decision, even when nobody made it consciously. It usually happens because the resource includes every field by default: full descriptions, embedded history, timestamps with nanosecond precision, and nested objects the screen never reads. The frontend then either renders slowly or builds its own field selection on top, which is fragile.

Sparse fieldsets, where the client asks for specific fields, are one answer. A default minimal representation plus an explicit expand parameter is another, and it tends to be kinder to caching. Either way, the API should know which fields are cheap and which are expensive, and it should not ship the expensive ones by default.

Compression is table stakes. A response that is not gzipped or brotli-compressed is a response that costs the user data and the frontend time. This is usually handled at the gateway, but a surprisingly large number of internal-turned-public APIs ship without it because nobody owned the decision.

Caching contracts are for the browser, so state them in headers the browser understands

The frontend talks to a cache whether it wants to or not. The browser has one, service workers add another, and every fetch library layers its own. The API's job is to tell those caches what to do with Cache-Control, ETag, and Vary, in terms the browser already implements. A resource that changes rarely should carry a long max-age and an ETag. A resource that is user-specific should carry private and a short max-age. A response that varies by language should say so in Vary.

None of this is exotic, and all of it is routinely omitted. The result is a frontend that re-fetches everything on every navigation because the API gave it no permission to do otherwise. The API team reads this as "the client is inefficient." The client team reads it as "the API is slow." Both teams are right, and the fix lives in headers nobody wrote.

CORS and auth are frontend concerns the API has to get right the first time

A public API called from a browser runs into CORS before it runs into anything else. The preflight request is real traffic. The allowed origins list is a production configuration. Credentials mode changes whether cookies travel. A wildcard origin with credentials is invalid, and an API that ships it will work in a developer's tool and fail in a browser tab, which is the worst possible place to discover the problem.

Auth tokens have a similar shape. A token in an Authorization header is easy. A refresh flow is where designs diverge, and the frontend feels every divergence. A refresh endpoint that returns a new access token without a clear expiry makes the client guess. A refresh that rotates the refresh token needs the client to persist the new one atomically, and the API should document that handshake with the same care as any other contract.

Cookie-based sessions are fine too, but they commit the API to CSRF handling and to cookie attributes the frontend cannot fix later. SameSite, Secure, and the domain scope are all decisions the API makes and the frontend inherits.

Versioning is a promise about what the frontend can ignore

Versioning gets discussed as a URL scheme question (path, header, query param) when the real question is additive change. A frontend can absorb a new field in a response without a deployment. It cannot absorb a renamed field or a changed type. The practical promise of a stable version is that the client can ignore fields it does not recognize and keep working. That promise is worth more than any particular versioning convention.

The corollary is that removals need a deprecation window long enough for the slowest client to ship. Public APIs have clients the maintainers cannot see, which means the window is measured in months, not sprints. A Deprecation header and a changelog are cheap. A surprise removal is a broken integration and a support ticket from someone the team has never met.

The best test client is the screen the API was built for

API teams often test with curl and a REST client, which proves the endpoint returns something. It does not prove the frontend can build a screen from it. A useful ritual is to write the frontend's fetch calls first, against a stub, and let the shape of those calls define the contract. The screen becomes the test harness. If the fetch call needs three chained requests to assemble a header, that is a design signal the API can act on before the endpoint ships.

This is slower at the start and faster in the middle. It also tends to produce APIs with fewer endpoints that do more, because the screen tells the designer which reads belong together. The frontend stops being a consumer of a finished contract and becomes a co-author of it.

A public API is judged by the integrations built on it. The frontend is the integration the API team can see, and the one that will expose every gap between what the contract says and what a screen actually needs. Designing for that screen, with its fan-out reads, its error paths, its rate limits, and its caching, is the difference between an API that gets used and one that gets worked around.

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button