
REST API Design: Will Future Me Understand This API?
I couldn't understand my own API after 3 months. Practical REST API design patterns for naming, versioning, pagination, and error handling.

I couldn't understand my own API after 3 months. Practical REST API design patterns for naming, versioning, pagination, and error handling.
A deep dive into Robert C. Martin's Clean Architecture. Learn how to decouple your business logic from frameworks, databases, and UI using Entities, Use Cases, and the Dependency Rule. Includes Screaming Architecture and Testing strategies.

Pringles can (Stack) vs Restaurant line (Queue). The most basic data structures, but without them, you can't understand recursion or message queues.

Why did Facebook ditch REST API? The charm of picking only what you want with GraphQL, and its fatal flaws (Caching, N+1 Problem).

Simple hashing gets cracked in 1 second. How Sprinkling Salt and Pepper blocks Rainbow Table attacks.

I reopened a side project after a few months away. I needed to add a feature, so I pulled up the API endpoint list. And I froze.
GET /getData
POST /process
GET /fetchUserInfo
POST /doUpdate
PUT /handleRequest
What is any of this. /getData—what data? /process—process what? /handleRequest—handle which request? I had to open each handler one by one just to figure out what my own code was doing.
Three months is enough to forget everything. Honestly, I hadn't thought about future-me at all when I wrote it. "It works" was the only bar I was clearing. The endpoint list above was the result.
After that day, I started asking one question whenever I design an API: Could future me, looking only at this endpoint list, figure out what each one does? That became my standard.
The first principle that actually clicked for me was this: endpoints should point to resources, not actions.
Looking at my old mistakes, the pattern is obvious. /getData, /process, /doUpdate—all verbs. I was trying to put what the API "does" into the URL. But that's already what HTTP methods are for. GET says "retrieve." POST says "create." DELETE says "remove." The endpoint name doesn't need to repeat that.
Think of a restaurant menu. No item is listed as "order the bibimbap." It's just "bibimbap." The action of ordering happens when you talk to the waiter (the HTTP method). APIs work the same way.
| Bad | Good | Why |
|---|---|---|
/getUsers | /users | GET already says "retrieve" |
/createPost | /posts | POST already says "create" |
/deleteComment | /comments/{id} | DELETE already says "remove" |
/updateProfile | /users/{id}/profile | PATCH already says "modify" |
Use plural nouns for collections. /users over /user. It makes the difference between a collection and a single resource clear in the URL structure:
GET /users → list users
POST /users → create user
GET /users/{id} → get one user
PATCH /users/{id} → update one user
DELETE /users/{id} → delete one user
These five patterns cover about 90% of every API I've built. Just swap out the resource name.
The next thing that confused me was when to use each verb. I vaguely understood "PATCH is for updates" but the actual distinctions took a while to land.
POST: Creates a new resource. The server assigns the ID. POST /posts and the server creates a new post with a generated ID.
PUT: Replaces an entire resource. It overwrites everything. Fields you don't include go null or reset to defaults. PUT /users/123 with only a name field—you might just wiped everything else.
PATCH: Updates specific fields only. Only what you send gets changed.
The analogy that stuck: PUT is moving to a new apartment. Everything gets reorganized from scratch. Anything not on the moving list gets left behind. PATCH is renovating the kitchen. Everything else stays exactly as it was. If you're just changing a user's email address, PUT is a full apartment move for a one-room fix.
In practice, PATCH is the right choice the vast majority of the time.
The easiest part of an API to neglect is error handling. You build the happy path first and bolt on error handling later, so the formats end up scattered:
{ "error": "not found" }
{ "message": "Unauthorized" }
{ "status": "fail", "reason": "invalid input" }
Three errors, three shapes. The client needs different parsing logic for each one. I eventually settled on a single structure:
{
"success": false,
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "The requested post does not exist.",
"details": {
"field": "postId",
"value": "99999"
}
}
}
And proper HTTP status codes matter. Returning 200 with an error body is a lie your clients have to work around.
| Status Code | When to use |
|---|---|
| 200 OK | Success |
| 201 Created | Resource created (POST responses) |
| 400 Bad Request | Client error—bad input |
| 401 Unauthorized | Not authenticated (login required) |
| 403 Forbidden | Authenticated but not allowed |
| 404 Not Found | Resource doesn't exist |
| 409 Conflict | Already exists (duplicate email, etc.) |
| 422 Unprocessable Entity | Valid format, but logic says no |
| 500 Internal Server Error | Server's fault |
401 versus 403 trips people up. 401 means "who are you?" 403 means "I know who you are, and still no." Not logged in: 401. Logged in but trying to delete someone else's post: 403.
Here's the error utility pattern I use in Next.js App Router:
// src/lib/api-error.ts
export class ApiError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: Record<string, unknown>
) {
super(message);
}
}
export function errorResponse(error: ApiError) {
return Response.json(
{
success: false,
error: {
code: error.code,
message: error.message,
...(error.details && { details: error.details }),
},
},
{ status: error.statusCode }
);
}
// src/app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const post = await db.posts.findUnique({ where: { id: params.id } });
if (!post) {
throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'Post not found.', {
field: 'id',
value: params.id,
});
}
return Response.json({ success: true, data: post });
} catch (error) {
if (error instanceof ApiError) return errorResponse(error);
return errorResponse(
new ApiError(500, 'INTERNAL_ERROR', 'Something went wrong.')
);
}
}
Every endpoint now produces the same error shape. Clients check success once and handle the rest uniformly.
You can't return all 100,000 posts in a single response. Pagination is required, and there are two main approaches.
Offset-based: "Give me items starting at position N." ?page=2&limit=20. Intuitive, easy to show page numbers in the URL.
Cursor-based: "Give me items after this specific ID." ?cursor=abc123&limit=20. More complex, but stable under data changes.
Offset has a subtle bug that bit me. If someone is reading page 2 and a new post gets added, the data shifts. Items they already saw reappear on the next page, or an item falls through the crack between pages entirely.
Cursor-based pagination anchors to a specific item, so inserts between pages don't affect what the user sees next. This is why Instagram's feed doesn't reorganize itself while you scroll—cursor pagination holds the position stable regardless of new content being added at the top.
// Offset-based response
{
"data": [...],
"pagination": {
"page": 2,
"limit": 20,
"total": 340,
"totalPages": 17
}
}
// Cursor-based response
{
"data": [...],
"pagination": {
"nextCursor": "eyJpZCI6MTIzfQ==",
"hasNextPage": true,
"limit": 20
}
}
The rule of thumb I follow: admin dashboards with explicit page numbers use offset. Infinite scroll feeds and anything with frequent writes use cursor.
Versioning felt like something big companies did until I broke a mobile client by changing a response field name. The app hadn't been updated in weeks. The field rename that was a three-second change for me rendered the old app unusable for anyone who hadn't updated.
That's when versioning made sense as a practical tool, not just an enterprise concept.
The most common approach is a version prefix in the URL:
GET /api/v1/users
GET /api/v2/users
There's also a header-based approach (Accept: application/vnd.myapp.v2+json), which is technically more "RESTful." In practice, URL versioning wins on usability—you can test it in a browser, it appears in logs clearly, and everyone understands it immediately. Think of it like book edition numbers. The 1st edition and 2nd edition sit side by side. Existing readers keep the one they have. New readers pick up the latest.
userName → username)Start with /v1 and don't worry about it further until you have external clients. When external clients appear, take versioning seriously. Not before.
Writing API documentation after the fact almost never happens. "I'll do it later" is a lie I told myself many times. Three months later I'm staring at /handleRequest again.
Now I add a JSDoc comment to each route handler as I write it. It takes two minutes per endpoint and saves hours three months later.
/**
* GET /api/v1/posts
* Returns a paginated list of posts using cursor-based pagination.
*
* Query params:
* - cursor: string (optional) - ID of the last received post
* - limit: number (optional, default 20, max 100)
* - category: string (optional) - filter by category
*
* Response:
* 200: { data: Post[], pagination: { nextCursor, hasNextPage, limit } }
* 400: invalid query parameters
*/
export async function GET(request: Request) {
// ...
}
For anything with external consumers, swagger-jsdoc + OpenAPI generates living documentation from these comments. The code and the docs stay in sync because they live in the same file.
The comment doesn't need to be exhaustive. What are the parameters? What does it return? What errors can it throw? Answer those three and future you has enough to work with.
/v1 covers you until then.An API is an interface between you and future you. A small investment in clarity now pays back compounded interest three months down the road.