REST API Design: Will Future Me Understand This API?
The Moment I Couldn't Read My Own Code
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.
Names Matter Most: No Verbs, Only Nouns
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.
POST vs PUT vs PATCH
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.
Error Responses: Consistency Builds Trust
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.
Pagination: Offset vs Cursor
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: When You Actually Need It
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.
When to bump the version:
- Renaming response fields (
userName→username) - Adding required request fields (existing clients can't send them)
- Changing authentication mechanisms
- Major business logic changes that alter the semantics of an endpoint
When not to bump:
- Adding new optional fields to responses (existing clients ignore them)
- Bug fixes
- Performance improvements
Start with /v1 and don't worry about it further until you have external clients. When external clients appear, take versioning seriously. Not before.
Document While You Build
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.
Key Takeaways
- Endpoints are nouns, plural. HTTP methods handle the verb. Don't double up.
- PATCH for partial updates, PUT only for full replacements. Most "updates" are PATCH.
- Lock in a consistent error response format on day one. It's the hardest thing to change later.
- Offset for paginated admin views. Cursor for feeds and frequently-changing data.
- Add versioning when external clients appear, not before.
/v1covers you until then. - Document as you build. Later doesn't exist.
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.