API Design Patterns We Use on Every Project
We've shipped 15+ APIs. These 6 patterns appear in every single one. We copy-paste them and so should you.

6 patterns. That's what we copy from project to project. Not frameworks. Not boilerplate repos. Six specific API design decisions that we've refined across Morta CRM, Pushary, Equipment Rentalz, Automaticall, and every other project.
Each pattern was learned the hard way. We built an API without proper pagination and a client loaded 50,000 records in a single request, crashing the server. We built an API without standardized errors and spent 3 days debugging a frontend issue that turned out to be a misinterpreted error response. We learned, we standardized, and now we copy-paste.
Key Takeaways > - Standardize your API patterns before writing a single endpoint. Retrofitting patterns is 5x more expensive. > - These 6 patterns cover 90% of what you need for any SaaS API. > - The most underrated pattern is #3 (error responses). Bad error handling creates more frontend bugs than bad business logic.
Pattern 1: Cursor-Based Pagination
Every list endpoint needs pagination. No exceptions. Even if you "only expect 100 items," one day there will be 10,000.
We use cursor-based pagination, not offset-based. Here's why.
Offset-based (what most tutorials teach): ``` GET /api/notifications?page=2&limit=20 ```
The problem: if a new item is inserted while the user is paginating, items shift. Page 2 might show items that were already on page 1. With 10,000 items and frequent inserts, users see duplicates or miss items entirely.
Also, offset queries get slower as the offset increases. `OFFSET 10000` forces the database to scan and skip 10,000 rows before returning 20.
Cursor-based (what we use): ``` GET /api/notifications?cursor=abc123&limit=20 ```
Response: ```json { "data": [...], "pagination": { "next_cursor": "def456", "has_more": true } } ```
The cursor is an opaque token (usually a base64-encoded ID or timestamp). The query uses a WHERE clause (`WHERE id > cursor_value`) instead of OFFSET, which is fast regardless of position.
We encode the cursor as base64 to prevent clients from manipulating it. The cursor contains the sort field value and the record ID for deterministic ordering.
Implementation detail: For Morta CRM, the property listing API returns 20 items per page with a cursor based on `created_at + id`. This handles the tie-breaking case where multiple listings have the same `created_at` timestamp. Without the ID in the cursor, items with identical timestamps could be skipped.
Pattern 2: Standardized Response Envelope
Every response from our APIs uses the same shape. No exceptions.
Success response: ```json { "data": { ... }, "meta": { "request_id": "req_abc123", "timestamp": "2026-08-13T10:30:00Z" } } ```
List response: ```json { "data": [ ... ], "pagination": { "next_cursor": "abc123", "has_more": true, "total_count": 1547 }, "meta": { "request_id": "req_abc123", "timestamp": "2026-08-13T10:30:00Z" } } ```
Error response (covered in Pattern 3): ```json { "error": { "code": "VALIDATION_ERROR", "message": "Human-readable message", "details": [ ... ] }, "meta": { "request_id": "req_abc123", "timestamp": "2026-08-13T10:30:00Z" } } ```
The `meta.request_id` is critical. When a user reports "I got an error," we ask for the request ID. One string and we can find the exact request in our logs. Without it, debugging user-reported issues involves guessing which request failed based on timestamps and user IDs.
The envelope pattern means the frontend team never has to guess the response shape. Success? Look in `data`. Error? Look in `error`. List? `data` is an array with `pagination`. The type is consistent across every endpoint.
Pattern 3: Structured Error Responses
This is the pattern that prevents the most bugs. Every error from our API includes a machine-readable code, a human-readable message, and structured details.
```json { "error": { "code": "VALIDATION_ERROR", "message": "Invalid request body", "details": [ { "field": "email", "code": "INVALID_FORMAT", "message": "Must be a valid email address" }, { "field": "name", "code": "REQUIRED", "message": "Name is required" } ] } } ```
Why three levels:
The `code` (VALIDATION_ERROR, NOT_FOUND, UNAUTHORIZED, RATE_LIMITED, INTERNAL_ERROR) is for the frontend's control flow. The frontend switches on this code to decide what to show the user.
The `message` is for debugging. It's a developer-readable explanation of what went wrong. It should never be shown to end users directly.
The `details` array is for field-level errors. The frontend maps these to specific form fields, showing "Must be a valid email address" next to the email input.
Our error code taxonomy:
| Code | HTTP Status | When | |---|---|---| | VALIDATION_ERROR | 400 | Request body or params fail validation | | UNAUTHORIZED | 401 | Missing or invalid auth token | | FORBIDDEN | 403 | Valid auth but insufficient permissions | | NOT_FOUND | 404 | Resource doesn't exist | | CONFLICT | 409 | Duplicate resource or state conflict | | RATE_LIMITED | 429 | Too many requests | | INTERNAL_ERROR | 500 | Unhandled server error |
We use the same 7 codes across every project. The frontend team knows exactly how to handle each one before writing a single line of code.
The mistake we see most: APIs that return `{ "error": "Something went wrong" }` for every error type. The frontend can't distinguish between a validation error (show field-level messages) and a server error (show a generic error message). This creates the most frustrating class of bugs: errors that are technically handled but provide zero useful information.
Pattern 4: JWT Auth With Refresh Tokens
Our authentication pattern is identical across every project. Short-lived access tokens, long-lived refresh tokens, and automatic rotation.
Access token: JWT, expires in 15 minutes. Sent in the Authorization header. Contains user ID, role, and tenant ID. Never stored in localStorage (XSS vulnerable). Stored in memory or a secure HTTP-only cookie.
Refresh token: Opaque token (not JWT), expires in 30 days. Stored in an HTTP-only, secure, same-site cookie. Used only to request a new access token.
Flow: 1. User logs in. Server returns access token + sets refresh token cookie. 2. Frontend includes access token in every API request. 3. When access token expires (15 minutes), frontend gets a 401. 4. Frontend calls `/auth/refresh` with the refresh token cookie. 5. Server validates refresh token, issues new access token + rotates refresh token. 6. Frontend retries the original request with the new access token.
Token rotation: Every time a refresh token is used, we issue a new refresh token and invalidate the old one. If an attacker steals a refresh token and the real user also uses it, one of them will present an invalidated token. We detect this and invalidate all tokens for that user, forcing a re-login.
The contrarian take on auth: Most API tutorials recommend OAuth2 with social login from day one. We disagree for MVPs. Email + password with magic link fallback covers 90% of SaaS products at launch. Add Google/GitHub OAuth after launch if users request it. Social login adds 2-3 days of development for a feature that most B2B SaaS users don't need.
Pattern 5: Rate Limiting With Clear Headers
Every API endpoint has a rate limit. The default is 100 requests per minute per user. Specific endpoints get custom limits.
Rate limit headers (returned on every response): ``` X-RateLimit-Limit: 100 X-RateLimit-Remaining: 73 X-RateLimit-Reset: 1691234567 ```
When rate limited (429 response): ```json { "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Try again in 34 seconds.", "details": [ { "limit": 100, "window": "60s", "retry_after": 34 } ] } } ```
Implementation: We use Redis with a sliding window counter. Each request increments a counter keyed by `rate:{user_id}:{endpoint}:{minute}`. The counter expires after 60 seconds.
Custom limits per endpoint: - Authentication endpoints (login, register): 10/minute (brute force protection) - Write endpoints (create, update, delete): 30/minute - Read endpoints: 100/minute - Webhook endpoints: 1000/minute (third-party services send bursts)
Why rate limiting matters even for small products: Automaticall had 2,000 users. One user's integration script had a bug that sent 500 requests per second. Without rate limiting, this would have brought down the entire API. With rate limiting, that user got 429 responses and everyone else was unaffected. The rate limiter paid for itself on that one day.
Pattern 6: Idempotency Keys for Write Operations
Every POST, PUT, and DELETE endpoint accepts an optional `Idempotency-Key` header. If the same key is sent twice, the second request returns the result of the first without executing the operation again.
``` POST /api/notifications Idempotency-Key: user-provided-unique-key ```
Why this matters: Network failures happen. A client sends a "create notification" request. The server processes it. The response gets lost in transit. The client retries. Without idempotency, the notification is created twice.
For Pushary, duplicate notifications are the worst possible bug. A user gets two identical push notifications and immediately loses trust in the platform. Idempotency keys prevent this entirely.
Implementation: We store idempotency keys in Redis with a 24-hour TTL. The value is the full response from the first request. When a duplicate key arrives, we return the stored response. The client never knows whether the first or second request was the one that executed.
``` Key: idempotency:{user_id}:{key} Value: { status: 201, body: { ... } } TTL: 24 hours ```
Client-side implementation: For browser clients, we generate a UUID v4 for each form submission and include it as the idempotency key. For API clients, we let them provide their own key (typically a transaction ID or event ID from their system).
This pattern is especially important for billing operations. A duplicate charge is worse than a failed charge. Every Stripe-related endpoint in our APIs requires an idempotency key.
Bonus: API Versioning (The Approach We Don't Use)
Most API design articles recommend URL-based versioning (`/v1/users`, `/v2/users`). We don't version our APIs at all for SaaS products where we control both the API and the client.
When we own the frontend and the backend, API changes are coordinated deployments. The frontend and backend ship together. There's no third-party consumer who might break when we change an endpoint.
We add versioning only when the API is consumed by external developers (like Pushary's notification API). In that case, we use URL-based versioning because it's the simplest to understand and the hardest to get wrong.
The contrarian take: if your SaaS doesn't have a public API, don't add versioning. It adds complexity (maintaining multiple versions) without adding value (no external consumers). Add it later if you open the API to third parties.
Frequently Asked Questions
Should I use REST or GraphQL for my SaaS API?
REST for 90% of SaaS products. GraphQL adds complexity (schema definition, resolver architecture, N+1 query management, authorization at the field level) that most products don't need. GraphQL shines when you have multiple clients consuming the same API with very different data needs (mobile app needs 5 fields, web app needs 20 fields). If you're building a web app with one frontend, REST is simpler and faster to build.
How do you handle API documentation?
We generate OpenAPI/Swagger specs from code annotations and expose them at `/api/docs`. The spec is auto-generated on every deployment. We don't write documentation separately from the code because separate documentation always drifts from reality. Pushary's API docs are generated from TypeScript types using Zod schemas and a Swagger generator.
What about WebSocket APIs for real-time features?
We use WebSockets for features that genuinely need real-time push: chat, live dashboards, collaborative editing. For everything else, we use polling or server-sent events (SSE). WebSocket connections are stateful, which makes them harder to scale and debug. Don't use WebSockets for features that can tolerate a 5-second delay.
How many endpoints should an MVP API have?
Most MVP APIs have 15-30 endpoints. If you're above 50, you're probably building too many features for an MVP. Pushary launched with 22 endpoints: auth (4), campaigns (5), notifications (4), analytics (3), billing (4), and user settings (2). That covered the entire product.
Notes on building fast.
One short email a month from the RalphNex team. Projects we shipped, ideas we tested, and what worked.
No spam. Unsubscribe anytime.

Dash Santosh
Founding Engineer
Co-founder and engineer at RalphNex. Been coding since 14, shipping fast since.
More from the RalphNex Journal

How We Set Up CI/CD for Every Client Project
Every project we ship gets the same CI/CD pipeline. It takes 4 hours to set up and saves 200+ hours over the project lifetime.

SaaS Development for Edtech: Building for Schools and Students
Schools buy software in June, onboard in August, and complain in September. Your edtech product needs to survive all three.
