DEV Community

Teguh Coding
Teguh Coding

Posted on

Stop Writing Spaghetti API Routes: A Practical Guide to Clean REST API Design

You've probably seen it before. A codebase where /api/getUsers, /api/fetchUserById, /api/deleteUserRecord, and /api/updateUserData all exist as separate endpoints with inconsistent patterns. Welcome to the REST API chaos zone.

This guide is about escaping that zone — writing clean, consistent, and maintainable REST APIs that your future self (and your teammates) will actually thank you for.


The Problem with "Just Making It Work"

When we're under deadline pressure, it's tempting to just add another endpoint, name it whatever makes sense in the moment, and ship it. Over time, this creates APIs that are:

  • Hard to understand without documentation
  • Painful to version and maintain
  • Frustrating for frontend developers to consume
  • Impossible to predict without reading source code

The good news: a few clear principles can prevent most of this mess.


Principle 1: Resources, Not Actions

The most common mistake is naming endpoints after what you do instead of what you're dealing with.

Bad:

GET  /api/getUsers
POST /api/createUser
PUT  /api/updateUser
DEL  /api/deleteUser
Enter fullscreen mode Exit fullscreen mode

Good:

GET    /api/users
POST   /api/users
PUT    /api/users/:id
DELETE /api/users/:id
Enter fullscreen mode Exit fullscreen mode

The HTTP method already tells you the action. Your URL should describe the resource. Think of your API like a filing cabinet — you don't label drawers "getFiles" and "createFiles", you just label them "Files" and use different verbs to interact with them.


Principle 2: Use HTTP Methods Correctly

This sounds obvious but it's violated constantly:

Method Purpose Idempotent?
GET Retrieve data Yes
POST Create new resource No
PUT Replace entire resource Yes
PATCH Partial update No
DELETE Remove resource Yes

A common anti-pattern is using POST for everything because "it's simpler". It's not simpler — it's just hiding complexity from the API contract and pushing confusion onto the caller.

// Express.js example — clean routing
const router = express.Router();

router.get('/users', getUsers);           // list all
router.get('/users/:id', getUserById);    // get one
router.post('/users', createUser);        // create
router.patch('/users/:id', updateUser);   // partial update
router.delete('/users/:id', deleteUser);  // remove
Enter fullscreen mode Exit fullscreen mode

Principle 3: Nest Resources Thoughtfully

Relationships between resources should be reflected in your URL structure — but only one or two levels deep.

// Good: shows relationship clearly
GET /api/users/:userId/posts
GET /api/users/:userId/posts/:postId

// Too deep: painful to work with
GET /api/users/:userId/teams/:teamId/projects/:projectId/tasks/:taskId/comments
Enter fullscreen mode Exit fullscreen mode

For deeply nested relationships, consider using query parameters instead:

GET /api/comments?userId=123&projectId=456
Enter fullscreen mode Exit fullscreen mode

This keeps URLs readable while still expressing the filter relationship.


Principle 4: Consistent Response Shapes

Your API responses should feel predictable. Pick a shape and stick to it across your entire API.

// Consistent success response
{
  "success": true,
  "data": {
    "id": 1,
    "name": "Alice",
    "email": "alice@example.com"
  },
  "meta": {
    "timestamp": "2026-03-02T16:00:00Z"
  }
}

// Consistent error response
{
  "success": false,
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "No user found with the provided ID",
    "status": 404
  }
}
Enter fullscreen mode Exit fullscreen mode

A middleware approach in Express makes this easy:

// response-helpers.js
export const sendSuccess = (res, data, statusCode = 200) => {
  return res.status(statusCode).json({
    success: true,
    data,
    meta: { timestamp: new Date().toISOString() }
  });
};

export const sendError = (res, code, message, statusCode = 500) => {
  return res.status(statusCode).json({
    success: false,
    error: { code, message, status: statusCode }
  });
};

// In your route handler
const getUserById = async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    return sendError(res, 'USER_NOT_FOUND', 'User not found', 404);
  }
  return sendSuccess(res, user);
};
Enter fullscreen mode Exit fullscreen mode

Principle 5: Use Status Codes Meaningfully

HTTP status codes exist for a reason. Returning 200 OK with an error in the body is a classic mistake that breaks tooling, monitoring, and developer sanity.

200 OK             — successful GET, PATCH, PUT
201 Created        — successful POST (new resource created)
204 No Content     — successful DELETE (nothing to return)
400 Bad Request    — invalid input from client
401 Unauthorized   — not authenticated
403 Forbidden      — authenticated but no permission
404 Not Found      — resource doesn't exist
409 Conflict       — state conflict (e.g. duplicate email)
422 Unprocessable  — valid format, but invalid data
429 Too Many Req   — rate limit hit
500 Internal Error — something blew up on your end
Enter fullscreen mode Exit fullscreen mode

Principle 6: Pagination, Filtering, and Sorting

Returning an entire database table in one request is a great way to crash your server. Build pagination in from day one.

// Cursor-based pagination (preferred for large datasets)
GET /api/posts?cursor=abc123&limit=20

// Offset-based pagination (simpler, good for small datasets)
GET /api/posts?page=2&limit=20

// Filtering
GET /api/posts?status=published&authorId=42

// Sorting
GET /api/posts?sort=createdAt&order=desc
Enter fullscreen mode Exit fullscreen mode

Return pagination metadata so clients know where they are:

{
  "success": true,
  "data": [...],
  "pagination": {
    "page": 2,
    "limit": 20,
    "total": 340,
    "hasNext": true,
    "hasPrev": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Principle 7: Version Your API from Day One

This one hurts people who skip it. Once your API is public, breaking changes are your enemy.

// URL versioning — most common, very explicit
https://api.example.com/v1/users
https://api.example.com/v2/users

// Header versioning — cleaner URLs, but less discoverable
Accept: application/vnd.myapi.v2+json
Enter fullscreen mode Exit fullscreen mode

URL versioning wins for most teams because it's visible, cacheable, and obvious. Start with /v1/ from the very beginning — retrofitting versioning into an existing API is painful.


Putting It All Together

Here's what a clean API router looks like for a blogging platform:

// routes/index.js
import { Router } from 'express';
import userRoutes from './users';
import postRoutes from './posts';
import commentRoutes from './comments';

const v1Router = Router();

v1Router.use('/users', userRoutes);
v1Router.use('/posts', postRoutes);
v1Router.use('/posts/:postId/comments', commentRoutes);

export default v1Router;

// app.js
app.use('/api/v1', v1Router);
Enter fullscreen mode Exit fullscreen mode
// routes/posts.js
import { Router } from 'express';
import { authenticate } from '../middleware/auth';
import {
  listPosts,
  getPost,
  createPost,
  updatePost,
  deletePost
} from '../controllers/posts';

const router = Router();

router.get('/', listPosts);                        // GET /api/v1/posts
router.get('/:id', getPost);                       // GET /api/v1/posts/:id
router.post('/', authenticate, createPost);        // POST /api/v1/posts
router.patch('/:id', authenticate, updatePost);    // PATCH /api/v1/posts/:id
router.delete('/:id', authenticate, deletePost);   // DELETE /api/v1/posts/:id

export default router;
Enter fullscreen mode Exit fullscreen mode

Clean, readable, and consistent. Any new developer on the team can understand what each route does without reading the controller code.


Quick Checklist Before You Ship

Before you push your API to production, run through this:

  • [ ] URLs use nouns, not verbs
  • [ ] HTTP methods used correctly (GET, POST, PUT/PATCH, DELETE)
  • [ ] Consistent response shape across all endpoints
  • [ ] Meaningful HTTP status codes returned
  • [ ] Pagination on all list endpoints
  • [ ] API is versioned
  • [ ] Error responses include actionable messages
  • [ ] Authentication applied where needed

Final Thoughts

Clean API design is a form of communication. Your API is a contract with every developer who calls it — frontend devs on your team, third-party integrators, or your future self three years from now.

The extra ten minutes you spend naming things consistently and returning proper status codes will save hours of debugging and confusion down the line.

Start with these principles on your next project, and you'll never look back at the spaghetti route days again.


Have a pattern you swear by that I didn't cover? Drop it in the comments — always happy to learn a better approach.

Top comments (0)