Most REST APIs get rewritten because of four early choices: no versioning, offset pagination, inconsistent errors and unsafe retries. This tutorial covers the REST API design best practices that fix all four before your first client ships, with Node and Express examples you can adapt.
Why APIs end up rewritten
The framework is rarely the problem. The problem is that your first mobile release freezes the contract. After that, every shortcut becomes a field you cannot remove.
Old app versions linger for months, and you cannot force users to update. So teams add workarounds on top of workarounds until a rewrite looks cheaper than one more patch.
If you want a second pair of eyes on your API before it ships, we build this for clients, so ask us about yours.
Prerequisites
- Node.js 18 or later and Express 5. Express 4 also works, but it needs a wrapper to catch errors from async handlers.
- A recent PostgreSQL version. The SQL below uses row comparisons, which PostgreSQL supports.
- Basic SQL and an HTTP client such as curl or Postman.
REST API design best practices, step by step
Step 1: Put the version in the URL
Mount every route under a version prefix: app.use('/v1', v1Router). Header-based versioning is cleaner in theory. URL versions are easier in practice, because they show up in logs, caches and copied curl commands.
The rule that matters is this: inside v1, only make additive changes. New optional fields, new endpoints and new enum values are fine, as long as clients are told to ignore what they do not recognise. Removing a field, renaming it or changing its type means v2.
Step 2: Use plural nouns and real status codes
Name resources with plural nouns, such as /v1/orders and /v1/orders/{id}. Let the HTTP method carry the verb. Use status codes as RFC 9110 defines them. That means 201 with a Location header on create, 204 on delete, 409 for state conflicts and 422 for validation failures.
A create handler ends like this: res.status(201).location(`/v1/orders/${order.id}`).json(toOrderDTO(order)). The toOrderDTO function is an explicit mapper. We come back to why it matters below.
Step 3: Use cursor pagination
Offset pagination (LIMIT 20 OFFSET 10000) forces the database to read and discard 10,000 rows. It also skips or repeats items when rows are inserted between page requests. Cursor pagination avoids both problems:
SELECT id, created_at, total FROM orders WHERE user_id = $1 AND (created_at, id) < ($2, $3) ORDER BY created_at DESC, id DESC LIMIT 21;
Fetch one extra row to learn whether a next page exists. Encode the last row's created_at and id as an opaque string, for example Buffer.from(JSON.stringify([row.created_at, row.id])).toString('base64url'). Return it as next_cursor next to data. The query also needs this index: CREATE INDEX ON orders (user_id, created_at DESC, id DESC).
Step 4: Return one error format everywhere
Pick one shape and use it on every endpoint. RFC 9457 Problem Details gives you a standard one with type, title, status, detail and instance fields. Add your own stable code field so clients branch on code, never on message text:
{"type": "https://api.example.com/errors/out-of-stock", "title": "Item out of stock", "status": 409, "code": "out_of_stock"}
A single Express error handler enforces it: app.use((err, req, res, next) => res.status(err.status || 500).type('application/problem+json').json({ type: err.type || 'about:blank', title: err.title || 'Internal error', status: err.status || 500, code: err.code || 'internal' })). Never send stack traces in the response. Log them instead.
Step 5: Make writes safe to retry
Mobile clients retry on flaky networks, so a POST can arrive twice. Accept an Idempotency-Key header on writes, as Stripe's API does. Store each key with the user ID, a hash of the request body and the final response.
Insert the key before doing any work: INSERT INTO idempotency_keys (user_id, key, request_hash) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING key. If no row comes back, the key already exists. Return the stored response, a 409 if the first request is still running, or a 422 if the body hash differs. Stripe keeps keys for at least 24 hours, and we typically do the same.
Step 6: Write the contract down
Describe the API in an OpenAPI 3.1 file and commit it next to the code. Run oasdiff on every pull request so breaking changes fail the build. A human reviewer misses a renamed field. A diff tool will not.
The two mistakes that cause production bugs
Returning database rows directly
Calling res.json(row) ties your public contract to your schema. Rename a column and every client breaks. Add a column like password_hash and you leak it. Always map rows through a DTO function that lists each public field by name.
Check-then-insert idempotency
Many first versions read the key, see nothing, create the order, then save the key. Two requests arriving in the same few milliseconds both see nothing, and both create an order. The fix is the one from step 5: insert first and let the unique constraint on (user_id, key) decide which request wins.
How to test it
- Pagination: insert 50 orders and fetch page one. Insert five newer orders, then fetch page two. Assert that the original 50 appear once each, with no gaps.
- Idempotency: fire two POSTs with the same key using const [a, b] = await Promise.all([send(key), send(key)]). Assert that exactly one order exists. The other response should be a 201 with the same id or a 409.
- Errors: for each 4xx path, assert the content type is application/problem+json and that a code field is present.
- Contract: run Schemathesis against the OpenAPI file in CI. It generates requests from the spec and flags responses that do not match it.
Supertest covers the first three inside your normal Jest or Vitest suite. Run them against a real PostgreSQL instance, not a mock. The concurrency test only means something when the unique constraint is real.
FAQ
Should I version a REST API in the URL or a header?
Both work, but URL versioning is easier to debug, cache and document. Header versioning keeps URLs clean, yet it hides the version from logs and browser testing. Most teams are better served by /v1 in the path.
What is the difference between PUT and PATCH?
PUT replaces the whole resource with the body you send, so omitted fields are cleared or reset. PATCH changes only the fields you include. PUT is idempotent by definition, while PATCH is only idempotent if you design it that way.
When should I use cursor pagination instead of offset?
Use cursors when data changes often or when lists can grow past a few thousand rows. Offset still suits small admin tables where users jump to page 7. Cursors cannot jump to an arbitrary page, so pick based on how people use the list.



