A REST API you won't want to redo in six months
An API is a contract. Breaking it is expensive: clients break, you end up maintaining several versions, and business logic spreads into places no one expected. A few decisions made early on greatly reduce that risk.
Version from day one
Even if right now there's only one client and one version, add /v1/ to the URL anyway. It costs nothing at the start, but saves a lot of pain later when you need to change the response shape without breaking existing integrations.
Alternatives — header versioning (Accept: application/vnd.api.v1+json) or a query parameter — work too, but URL versioning is simpler to debug and cache.
One consistent response shape
When every endpoint returns data in its own format, that's pain for everyone working with the API. Agree on an envelope: { data, meta, errors } and stick to it everywhere.
Success: { data: {...} }. List: { data: [...], meta: { total, page, per_page } }. Error: { errors: [{ code, message, field }] }. A simple schema a client can handle generically.
- data — the actual payload (object or array)
- meta — pagination, counters, supporting info
- errors — an array of errors with a code, message and optional field
- Never change the type of data between requests to the same endpoint
HTTP status codes used as intended
200 OK with { success: false } in the body is an anti-pattern. A client should be able to tell the outcome from the status code without parsing the body. Use 201 Created when creating a resource, 204 No Content on delete, 422 Unprocessable Entity for validation errors.
You don't need to know all 50+ codes — a dozen of the most common ones are enough. What matters is using them consistently instead of inventing your own system on top of HTTP.
Pagination: three approaches
Offset pagination (?page=2&per_page=20) is the simplest to implement and fits most cases. Downside: under active writes, pages "shift", and an item can appear twice or be skipped entirely.
Cursor pagination (?after=eyJpZCI6MTAwfQ) solves the shifting problem and works great for infinite feeds, but you can't jump to an arbitrary page. Keyset pagination (?last_id=100) is a middle ground: simple and stable, though less flexible.
Error handling: think about the client
An error should explain what went wrong and, where possible, what to do about it. { code: 'EMAIL_TAKEN', message: 'This email is already in use' } is far more useful than { error: 'Validation failed' }.
For validation errors, return the field the error relates to — then the frontend can show it right next to the relevant input. A small thing that meaningfully improves UX.