Designing APIs That Are Easier to Integrate and Maintain
Learn best practices for designing and building RESTful API boundaries using Node.js, Express, and TypeScript.
Updated October 22, 2026

An API becomes easier to work with when its behaviour is predictable. A client should know how to identify a resource, submit a valid request, handle an error, and retry safely. Those decisions matter as much as the framework used to implement the endpoint.
This guide uses Node.js, Express, and TypeScript examples. The snippets illustrate individual boundaries; database access and identity provider integration depend on the application.
Define the resource and its operations
Start with a small API contract. Consistent plural resource names such as /orders are a useful convention, but the important part is that methods and responses communicate what happened.
| Operation | Endpoint | Typical successful response |
|---|---|---|
| List orders | GET /orders | 200 with a bounded result page |
| Create an order | POST /orders | 201 with the new resource |
| Read one order | GET /orders/:id | 200, or 404 if it is unavailable to the caller |
| Partially update an order | PATCH /orders/:id | 200 with the updated representation, or 204 |
| Remove an order | DELETE /orders/:id | Often 204 when deletion succeeds |
PUT conventionally replaces a representation; PATCH expresses a partial update. Decide and document the semantics before clients depend on them. For operations such as submitting or cancelling an order, an explicit domain action may communicate intent more clearly than an unrestricted status update.
Follow the complete request path
Keep the route handler small enough to see those steps. A service layer can hold the business operation, while a repository or database module handles persistence. Those layers are useful when they clarify ownership; they do not need to become a large class hierarchy.
Validate input before using it
TypeScript cannot validate a network payload at runtime. Treat the body, parameters, and query string as untrusted input. A schema validator such as Zod can produce a validated value for the rest of the operation.
import { z } from 'zod'
const CreateOrder = z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(100),
})
const result = CreateOrder.safeParse(req.body)
if (!result.success) {
res.status(400).json({
error: {
code: 'INVALID_ORDER',
message: 'Check the product and quantity.',
issues: result.error.issues,
},
})
return
}
const input = result.data
Pass input to the business operation, rather than returning to the original request body. Do not accept privileged fields such as an administrator role or another tenant’s ownership ID simply because the client supplied them.
Keep authentication and authorisation separate
Authentication establishes identity. Authorisation decides what that identity may do. A valid session or JWT does not automatically grant access to every order.
For each operation, check the workspace, ownership, and role against server side policy. Apply the same scope when querying or updating the database. Browser storage and hidden UI controls are useful for presentation but cannot enforce permissions.
If using JWTs, validate signatures, expected algorithms, issuer, audience, and expiry according to the identity provider’s contract. If using cookies, choose appropriate cookie attributes and CSRF protection for the request model. Avoid copying a short token example and treating it as a complete identity system.
Return useful, consistent errors
Use stable error codes that clients can handle, a readable message, and a request ID where available. Keep sensitive database details and stack traces in protected server logs.
{
"error": {
"code": "ORDER_ALREADY_SUBMITTED",
"message": "This order can no longer be edited."
}
}
Separate expected errors, such as invalid input or a business conflict, from unexpected failures. An unavailable database is not a client validation error and should not become a generic 400 response.
Express error middleware runs after route handlers and has four arguments. Express 5 forwards rejected promises returned by handlers to the error chain; older Express versions require explicit forwarding or an async wrapper. Check the version before choosing a pattern.
Bound list queries
A list endpoint should have a validated page size and a deterministic sort order. Reject invalid parameters, cap the maximum limit, and include a unique tie breaker in the ordering.
For large or changing datasets, cursor pagination often gives a more stable traversal than a large offset. A typical order cursor might include both created_at and id. Scope queries to the authorised workspace and parameterise all values rather than concatenating user input into SQL.
Make retries and slow operations explicit
Clients retry when a response is lost. For operations that create an order or trigger a payment, define an idempotency key and persist its association with the outcome. This prevents a repeated request from creating a second side effect.
For document generation or ingestion, return a durable job identifier and let the client check status. Sending the job to a queue should have a recovery strategy if enqueueing fails after the database commit, such as a transactional outbox.
Before publishing the API
- Document request schemas, response schemas, permissions, and error codes in OpenAPI or an equivalent contract.
- Test success, invalid input, unauthorised access, cross tenant access, conflicts, and retry behaviour.
- Use rate limits appropriate to the route, with shared accounting when multiple instances need a common limit.
- Configure proxy trust and CORS for the actual deployment. CORS controls browser access to responses; it is not authentication.
- Capture latency, error rate, and request IDs so failures can be investigated.
A useful API has clear rules for both successful and unsuccessful requests. When those rules are visible in the contract and enforced at the boundary, clients need less guesswork and backend changes become easier to review.