Thirty seconds into my first code review at work, my senior engineer pointed at my error response and said:
"This works. But if I'm a frontend dev consuming this at 2am trying to debug a production issue — this tells me nothing."
I had returned this:
{
"success": false,
"message": "Error"
}
That moment changed how I think about API design forever.
I'm Wongsaphat — a Junior Backend Engineer, 30 days into my first real job at INET (Internet Thailand). Before this, I spent years in university learning the theory. But theory and production are two very different things.
This article isn't a guide from an expert. It's an honest look at what I'm learning — the hard way — about designing APIs that other developers actually want to use.
Lesson 1: Error Responses Are a Product Feature
Before my job, I thought error handling meant wrapping code in try/catch and returning 500 if something broke.
I was wrong.
An error response is the first thing a frontend developer sees when something goes wrong. If it's vague, they have to come find you. If it's clear, they can fix it themselves.
What I was doing:
{
"success": false,
"message": "Error"
}
What I learned to do instead:
{
"status": "error",
"code": 422,
"message": "Validation failed",
"errors": [
{
"field": "email",
"message": "Email is required"
},
{
"field": "password",
"message": "Password must be at least 8 characters"
}
]
}
The difference? The second one tells you exactly what went wrong and where. No guessing. No Slack messages asking "hey what does this error mean?"
The rule I now follow: Every endpoint in my API returns the same error structure. Always. No exceptions.
Lesson 2: Consistency Is More Important Than Cleverness
Early on, I was inconsistent without realizing it. Some endpoints returned:
{ "data": { "user": { ... } } }
Others returned:
{ "result": [ ... ] }
And some just returned the raw object:
{ "id": 1, "name": "Wongsaphat" }
All three "worked." But from a frontend developer's perspective, every endpoint behaved differently. They had to read the docs (or ask me) for every single endpoint.
What I do now — one response structure for everything:
// Success - single resource
{
"status": "success",
"data": {
"id": 1,
"name": "Wongsaphat",
"email": "wongsaphat@example.com"
}
}
// Success - list
{
"status": "success",
"data": [...],
"pagination": {
"page": 1,
"limit": 20,
"total": 100
}
}
// Error
{
"status": "error",
"code": 404,
"message": "User not found"
}
Pick a structure. Use it everywhere. Your future teammates will thank you.
Lesson 3: Auth Is Not Something You Add Later
This one came from my professor, not my job — but I saw why it mattered once I started working.
When you're building fast, it's tempting to skip auth on endpoints because "I'll add it later." The problem is later never comes, or comes too late.
Here's what I now implement from day one — no exceptions:
JWT with expiry:
// ❌ Don't do this — no expiry
const token = jwt.sign({ userId: user.id }, SECRET_KEY)
// ✅ Do this — short-lived access token
const accessToken = jwt.sign(
{ userId: user.id },
SECRET_KEY,
{ expiresIn: '15m' }
)
// ✅ Plus a refresh token for longer sessions
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: '7d' }
)
Return the right status code for auth failures:
// 401 = not authenticated (no token / invalid token)
// 403 = authenticated but no permission
// ❌ Using 403 for everything
if (!token) return res.status(403).json({ message: "Forbidden" })
// ✅ Correct
if (!token) return res.status(401).json({ message: "Authentication required" })
if (!hasPermission) return res.status(403).json({ message: "Access denied" })
Mixing up 401 and 403 seems small, but it breaks automated tools and confuses developers integrating with your API.
Lesson 4: Name Your Endpoints Like a Human, Not a Computer
This took me embarrassingly long to internalize.
// ❌ What I used to write
GET /getUsers
POST /createUser
PUT /updateUser/1
DELETE /deleteUser/1
// ✅ What I write now
GET /users
POST /users
PUT /users/1
DELETE /users/1
The HTTP method already tells you the action. The URL should only describe the resource. Nouns, not verbs.
One more thing — be consistent with casing. Pick one and stick to it:
✅ /user-profiles (kebab-case — recommended)
❌ /userProfiles (camelCase — avoid in URLs)
❌ /user_profiles (snake_case — inconsistent with REST conventions)
What I'm Still Figuring Out
I want to be honest — there's a lot I don't have answers to yet:
- When should I use GraphQL vs REST? I understand both conceptually, but I haven't built enough real systems to have a strong opinion.
-
How do you version APIs without breaking existing clients? I know the theory (
/api/v1/,/api/v2/), but handling it gracefully in practice is still unclear to me. - Rate limiting strategy — what's the right limit for different endpoint types?
If you have experience with any of these, I'd genuinely love to hear your approach in the comments.
Summary
Here's what changed in my thinking since starting work:
| Before | After |
|---|---|
Error = { "success": false }
|
Error = structured, field-level details |
| Different response shape per endpoint | One consistent envelope for everything |
| Add auth "later" | Auth from day one, JWT with expiry |
/getUsers, /createUser
|
/users with correct HTTP methods |
None of this is revolutionary. But consistently doing these four things makes the difference between an API that developers enjoy working with — and one they complain about in Slack.
What's Next
I'm documenting everything I learn as I go. Next up:
- REST vs GraphQL: My Honest Take as a Junior Dev — what I've actually used and what I think
- 5 API Design Mistakes I Made (And How I Fixed Them) — a more detailed breakdown of real errors from my first weeks
If this was useful, follow me here on Dev.to — I post regularly about Backend Engineering and API Design, in both English and Thai 🇹🇭
Also check out my API Design Checklist on GitHub — a living document I update as I learn.
Questions, disagreements, or things I got wrong? Drop them in the comments. I'm here to learn. 🌱
Top comments (0)