Designing APIs You Don’t Hate Six Months Later
Most APIs feel clean in the beginning.
The routes make sense. The responses feel simple. Everything is easy to reason about because the product is still small and the requirements are fresh in your head.
Then six months pass.
The mobile app needs slightly different data. Another engineer joins the team. Product changes direction. A quick shortcut becomes permanent infrastructure. Suddenly your “simple” API becomes something nobody wants to touch.
A lot of API design advice focuses too much on architecture diagrams and theory. But most long term API pain comes from smaller things:
- confusing names
- inconsistent responses
- weak typing
- unclear boundaries
- leaking database structure into the API
- handlers doing too much
- breaking clients accidentally
Good API design is really about making future changes less painful.
Especially in TypeScript, where you already have tools that can protect you from a lot of problems if you use them properly.
Design Around Resources, Not Screens
One mistake that shows up often is building APIs around frontend pages instead of domain concepts.
You start with something like this:
GET /dashboardData
GET /homepageFeed
GET /sidebarWidgets
It feels convenient because it matches the UI exactly.
But the moment another client appears, things get awkward.
Now mobile needs slightly different dashboard data.
Then admin needs another variation.
Then desktop needs more fields.
Eventually you end up with APIs that only make sense if you know the history of the frontend.
Resource-based APIs age better.
GET /users
GET /books
GET /comments
GET /reading-goals
Users, books, comments, and goals are stable concepts.
Frontend layouts are not.
Keep Response Shapes Consistent
Nothing becomes annoying faster than APIs returning completely different structures for similar requests.
One route returns:
{
"data": [...]
}
Another returns:
{
"result": [...]
}
Another uses:
{
"payload": [...]
}
These inconsistencies seem harmless until frontend code grows.
Then developers spend unnecessary energy remembering tiny differences.
Pick one shape and commit to it.
Something simple like this works well:
type ApiResponse<T> = {
data: T
error: string | null
}
Now every endpoint behaves predictably.
Predictability matters more than cleverness.
Let TypeScript Protect the API
TypeScript becomes far more valuable when your API contracts are properly typed.
Without shared types, refactors become dangerous because you are relying on memory.
With strong typing, the compiler becomes part of the review process.
export interface User {
id: string
name: string
email: string
}
Then your responses become explicit too:
export interface GetUserResponse {
data: User
}
This matters more over time.
Six months later, nobody remembers every place a field is being used.
TypeScript does.
Validate Requests Early
A surprising amount of backend problems come from trusting incoming data too much.
Even if your frontend already validates forms, your backend should still validate everything.
Using Zod:
import { z } from "zod"
export const createBookSchema = z.object({
title: z.string().min(1),
author: z.string().min(1),
pages: z.number().positive()
})
Then:
const body = createBookSchema.parse(req.body)
Now invalid requests fail immediately instead of spreading weird data deeper into the system.
The earlier bad data gets rejected, the easier systems are to maintain.
Keep Route Handlers Thin
This is one of the easiest ways to keep APIs maintainable.
Route handlers should mostly coordinate things.
They should not contain your entire business logic.
This becomes painful quickly:
app.post("/books", async (req, res) => {
const body = req.body
if (!body.title) {
return res.status(400).json({
error: "Missing title"
})
}
const existing = await db.book.findFirst({
where: {
title: body.title
}
})
if (existing) {
return res.status(400).json({
error: "Book already exists"
})
}
const book = await db.book.create({
data: body
})
res.json(book)
})
A better approach:
app.post("/books", async (req, res) => {
const body = createBookSchema.parse(req.body)
const book = await bookService.create(body)
res.json({
data: book
})
})
Now the route stays readable even as the application grows.
Your business logic also becomes reusable and easier to test.
Don’t Mirror the Database Exactly
A database schema and an API contract are not the same thing.
Your database exists for storage efficiency and relationships.
Your API exists for developer experience.
Those are different goals.
Database:
BookTable {
author_id
created_at
updated_at
}
API:
{
"authorId": "123",
"createdAt": "...",
"updatedAt": "..."
}
You are not obligated to expose raw database structure directly.
Sometimes the cleanest API response is actually a transformed version of your data.
Avoid Deeply Nested Responses
Nested responses usually start with good intentions.
People want responses to feel “organized”.
Then the frontend ends up accessing data like this:
response.data.book.metadata.author.profile.name
At some point it stops being structure and starts becoming friction.
Most frontend apps benefit from flatter responses.
{
"data": {
"id": "1",
"title": "Atomic Habits",
"authorName": "James Clear"
}
}
This is easier to consume and easier to evolve.
Make Errors Helpful
A generic error message saves backend time but wastes frontend time.
This:
{
"error": "Invalid input"
}
usually creates another debugging session.
More useful:
{
"error": {
"code": "EMAIL_TAKEN",
"message": "Email is already in use"
}
}
Now the frontend can react properly.
if (error.code === "EMAIL_TAKEN") {
showEmailAlreadyUsedMessage()
}
Clear APIs reduce confusion across teams.
Add Pagination Earlier Than You Think
At first, returning everything feels harmless.
Then your product grows.
GET /books
works fine when there are 50 books.
Not when there are 500,000.
Pagination is much easier to introduce early than later.
GET /books?cursor=abc123&limit=20
Cursor pagination tends to scale better for growing systems.
Response:
{
"data": [...],
"nextCursor": "xyz456"
}
A lot of good backend engineering is simply preparing for growth before it becomes painful.
Consistency Matters More Than Perfection
One underrated quality of good APIs is that they feel guessable.
When developers can predict routes, response shapes, and naming conventions without checking documentation constantly, the system becomes easier to work with.
This:
GET /books
POST /books
GET /books/:id
PATCH /books/:id
DELETE /books/:id
is easier to reason about than this:
GET /getBooks
POST /createBook
POST /deleteBook
Good APIs reduce mental overhead.
That becomes more valuable as teams grow.
Prefer Explicitness Over Cleverness
A lot of backend abstractions feel impressive early on.
Then nobody understands how anything works.
You see patterns like:
createResourceHandler(BookModel)
which hides important behavior behind layers of abstraction.
Sometimes a little repetition is healthier.
app.get("/books", getBooksHandler)
app.post("/books", createBookHandler)
Readable systems usually survive longer than overly abstract ones.
Especially when new engineers join the project months later.
Think About Change While Designing
Good API design is not about building something that feels perfect today.
It is about building something that can change safely later.
Before adding fields or designing endpoints, it helps to ask:
- Will this field always exist?
- Could this become nullable later?
- Will mobile clients need different behavior?
- Can this evolve without breaking existing users?
The APIs people enjoy working with months later are usually not the most “advanced”.
They are the ones that stayed understandable while the product evolved around them.
And most of the time, that comes down to simple things:
- clear naming
- predictable structures
- strong typing
- thin handlers
- helpful errors
- resisting unnecessary complexity
That is usually what separates APIs people enjoy maintaining from the ones everyone avoids touching.