Frontend developers do not hate REST. They hate guessing. They hate a list endpoint that returns a different shape than the detail endpoint. They hate 201 with an empty body when they need the id. They hate errors that are HTML on Tuesday and JSON on Wednesday. They hate pagination that uses page on one resource and cursor on another with no docs.
You can keep REST. You should. You just have to treat the JSON as a UI contract, not as a dump of your database.
This is how to design HTTP APIs that a React or mobile app can consume without a translation layer that exists only to paper over your mood.
One resource, one shape
If GET /users/18 returns { id, email, profile: { name } }, then GET /users should not return { id, email, name } flattened and { avatarUrl } missing. Partial representations are fine if you name them. ?fields= or a dedicated UserListItem in the docs is honest. Silent shape-shifting is how TypeScript types become any.
Do not wrap some responses in { data } and others not. Pick a envelope and keep it. { data, error } for everything is valid. Bare objects are valid. Mixing them means every client starts with a helper named unwrap that is wrong half the time.
Lists should be objects if you need pagination metadata: { items, nextCursor, total }. A bare array is fine for tiny enums. Once you need total, a bare array has nowhere to put it and you will invent a header, then a second client will miss the header.
Status codes that match the branch in the UI
200 with { error: "nope" } forces every caller to remember to check the body. Use 4xx for the user’s mistake and 5xx for yours. 401 means we do not know who you are. 403 means we know and you cannot. Frontend auth routing depends on that difference. Do not send 401 for “not an admin.”
404 on GET /users/18 when the user is deleted is fine. 404 on POST /login for a bad password is a gift to attackers who enumerate users, or a confusion if the path was wrong. Use 401 for bad credentials. Do not cleverly hide existence if your signup endpoint already reveals it.
409 for conflicts (email taken, stale version). Include a machine code the UI can switch on: { code: "email_taken", message: "..." }. Human message is for display. code is for logic. Do not parse English.
201 should return the created resource or a Location header plus a body. Empty 201 means a second GET, a race, and an extra spinner.
Errors should be JSON, always
If the app expects JSON and your reverse proxy returns an HTML 502, the frontend will throw in response.json() and show a blank screen. Document that clients should check content-type. Better: make your gateway return JSON too, or teach the client to handle non-JSON as “unavailable.”
Validation errors: 400 with { code: "validation_error", fields: { email: "invalid" } }. The form can highlight email. A single string “invalid payload” is how you get a toast and an unchanged form.
Do not return stack traces to the browser. Log them. Send a requestId the user can quote to support.
Pagination, filtering, and the “just return all” trap
Offset pagination (?page=2&limit=20) is easy and breaks when rows insert at the top. Cursor pagination is more work and behaves better for feeds. Pick based on the UI. Admin tables can use offset. Infinite scroll should use cursors.
Always cap limit. Unbounded GET /events will be fine until a customer has 400,000 events and the browser dies.
Filtering: ?status=open is fine. A JSON filter in a query string is a tax. If filters are complex, POST /users/search with a body is allowed. Purists will complain. Product will ship.
Sorting: whitelist fields. ?sort=passwordHash should 400, not work.
Naming that does not fight JavaScript
snake_case vs camelCase: pick one for JSON. JavaScript wants camelCase. Postgres wants snake. Convert at the edge. Mixing in one response is petty cruelty.
Booleans: isActive, not active: "yes". Dates: ISO-8601 strings in UTC. Do not send Unix seconds on one endpoint and milliseconds on another.
Ids as strings if they are snowflakes that do not fit in JS numbers. Number ids are fine if they are actually 32-bit. The day you switch to bigint, the frontend will round. Then you will “fix” it with a string and break caches.
Versioning without a museum
If you can add fields without breaking, do that. Clients should ignore unknown fields. If you rename, add the new field, deprecate the old, give them a season, then remove.
URL versioning (/v1/) is obvious. Header versioning is fine if you document it. The failure mode is two unofficial shapes of /users depending on who wrote the client. That is an unversioned breaking change.
Do not keep v1 forever out of fear. Measure who calls it. Then sunset with a date and a Deprecation header.
Auth as a boring header
Authorization: Bearer. Session cookies for first-party web, with CSRF strategy. Do not invent a custom header X-Token that logging middleware strips on one service and not another.
Put auth failures in the status code. Do not 200 a login that failed.
Rate limits: send Retry-After. Frontends can back off. Silent 429s with HTML become infinite spinners.
Docs that match production
OpenAPI is worth it if it is generated from the code or tested against the code. A wiki that drifted is worse than none because people trust it.
Give an example for the error body, not only the happy path. Give the list envelope. Give the pagination query names.
A playground (or a saved HTTP file in the repo) is how frontend work unblocks when backend is in meetings. The worst API is the one that only exists in someone’s running laptop.
File uploads and downloads
Multipart uploads should have a documented max size and a clear 413 with JSON, not an HTML nginx page. Downloads should set Content-Disposition and a real content type. If you stream a CSV, say so. Frontends that expect JSON will throw.
Do not make the client poll a URL that 404s until the file exists without a 202 and a job id. Give a job resource: GET /exports/18 with { status, url }.
Idempotency keys on create
If the UI retries POST /orders, say whether a second call creates a second order. If it does, document an Idempotency-Key header. Frontends will implement it once if you are consistent. If only payments have it, they will forget on the next resource and you will get double tickets.
Collaboration habits
Sit with the frontend on the first endpoint of a new domain. Draw the screens. Name the fields on the wire. Then implement. Backend-first “I’ll just expose the model” produces user.user_profile.profile_name.
When you must break, ship a flag or a new field and tell them in the PR, not in Slack after they shipped.
Artikals is a good place for this because it is not framework fashion. It is the contract. A REST API that is boring, consistent, and honest about errors will outlive the frontend framework. Make the JSON something you would want to type. Then the team will stop asking for GraphQL as a personality, and will ask for it only when they actually need a graph.