DEV Community

Cover image for How to Structure a REST API the Professional Way
Chris Siku
Chris Siku

Posted on

How to Structure a REST API the Professional Way

Your API Works. But Does It Slap?

So you've been vibing. You prompted your way into a backend, Cursor autocompleted half the routes, and somehow the thing actually works. Respect. Genuinely.

Most developers don't actually design REST APIs, they just return JSON and call it done. But professional API design is about more than making things work. It's about consistency, scalability, predictability, and developer experience.

This guide walks through the 10 most common REST API mistakes and how to fix each one with real code examples.


1. Use Resource-Based URLs (No Verbs)

Be honest. You have endpoints that look like this:

GET  /getUsers
POST /createUser
PUT /updateUser/4
DELETE /deleteUser/5
Enter fullscreen mode Exit fullscreen mode

It feels natural! It reads like English! It is also wrong.

REST is about resources, not actions. Your HTTP method already defines the action, so don't repeat it in the URL.

❌ Wrong

GET    /getUsers
POST   /createUser
DELETE /deleteUser/5
Enter fullscreen mode Exit fullscreen mode

✅ Correct

GET    /users
POST   /users
DELETE /users/5
Enter fullscreen mode Exit fullscreen mode

The HTTP method tells you what to do. The URL tells you what resource you're acting on.

Method Meaning
GET Retrieve
POST Create
PUT / PATCH Update
DELETE Remove

Rule: URLs are nouns. HTTP methods are verbs.


2. Use The Right HTTP Status Codes Correctly

This one hurts to see. An API that returns 200 OK even when things go wrong:

Returning 200 OK for everything, including errors, is a common anti-pattern. Status codes are part of your API contract.

❌ Wrong

HTTP 200 OK
{
  "status": "error",
  "message": "User not found"
}
Enter fullscreen mode Exit fullscreen mode

✅ Correct

// Resource found
HTTP 200 OK

// Resource created
HTTP 201 created

// Bad Request
HTTP 400 Bad request

// Validation error
HTTP 422 Unprocessable Entity

// Not authenticated
HTTP 401 Unauthorized

// Authenticated but not allowed
HTTP 403 Forbidden

// Resource doesn't exist
HTTP 404 Not Found

// Server error
HTTP 500 Internal Server Error
Enter fullscreen mode Exit fullscreen mode

Example in Node.js (Express)

app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);

  if (!user) {
    return res.status(404).json({
      error: { code: 404, message: 'User not found' }
    });
  }

  return res.status(200).json({ data: user });
});
Enter fullscreen mode Exit fullscreen mode

3. Keep JSON Naming Consistent

Mixing camelCase, snake_case, and lowercase across endpoints forces front-end developers to guess the format and that slows everyone down.

❌ Wrong

// Endpoint A
{ "userName": "alice", "user_email": "alice@example.com" }

// Endpoint B
{ "username": "bob", "userEmail": "bob@example.com" }
Enter fullscreen mode Exit fullscreen mode

✅ Correct — pick one and stick to it

snake_case (common in Laravel/Python APIs):

{
  "user_name": "alice",
  "user_email": "alice@example.com",
  "created_at": "2024-01-15T10:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

camelCase (common in Node.js APIs):

{
  "userName": "alice",
  "userEmail": "alice@example.com",
  "createdAt": "2024-01-15T10:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Rule: It doesn't matter which you choose. What matters is that you use it everywhere.


4. Version Your API from Day One

Skipping versioning feels harmless until you need to change a response structure and suddenly every client breaks.

❌ Wrong

/users         ← you change this and everything breaks
Enter fullscreen mode Exit fullscreen mode

✅ Correct

/api/v1/users  ← stable
/api/v2/users  ← new version when needed
Enter fullscreen mode Exit fullscreen mode

Future you is begging present you to do this one thing.

Example: Express Router with versioning

const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');

app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
Enter fullscreen mode Exit fullscreen mode

Old clients keep using v1. New clients adopt v2. No broken production systems.


5. Implement Pagination

Returning 10,000 records in a single response will slow your server, waste bandwidth, and hurt user experience.

❌ Wrong

GET /users  ← returns all 8,700 users
             ← your server starts crying
             ← your users leave
Enter fullscreen mode Exit fullscreen mode

✅ Correct

GET /users?page=1&limit=10
Enter fullscreen mode Exit fullscreen mode

Expected response structure

{
  "data": [
    { "id": 1, "name": "Alice" },
    { "id": 2, "name": "Bob" }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 10,
    "total": 1000,
    "last_page": 100
  }
}
Enter fullscreen mode Exit fullscreen mode

Example in Node.js

app.get('/users', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 10;
  const offset = (page - 1) * limit;

  const { rows: users, count: total } = await User.findAndCountAll({
    limit,
    offset
  });

  res.status(200).json({
    data: users,
    meta: {
      current_page: page,
      per_page: limit,
      total,
      last_page: Math.ceil(total / limit)
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

6. Separate Authentication from Authorization

A lot of vibe code treats these like they're the same. They're not.
These are two different things and mixing them creates security gaps.

Concept Question it answers
Authentication Who are you? (identity)
Authorization What are you allowed to do? (permissions)

✅ Correct approach in Node.js (JWT)

// Middleware: Authentication
const authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Unauthorized' });

  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
};

// Middleware: Authorization
const authorize = (role) => (req, res, next) => {
  if (req.user.role !== role) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  next();
};

// Usage
app.delete('/users/:id', authenticate, authorize('admin'), deleteUser);
Enter fullscreen mode Exit fullscreen mode

Never rely on front-end validation alone. Hidden buttons don't protect endpoints. Security must be enforced on the server.


7. Return Structured Error Responses

Vague error messages like "Something went wrong" are useless for front-end developers, for logging, and for debugging.

❌ Wrong

HTTP 500
{ "message": "Something went wrong" }
Enter fullscreen mode Exit fullscreen mode

This tells nobody anything. What went wrong? Where? Why? Your front-end team can't handle it properly. You can't debug it. It's just vibes in JSON form.

✅ Correct

HTTP 404
{
  "error": {
    "code": 404,
    "message": "User not found",
    "field": null
  }
}
Enter fullscreen mode Exit fullscreen mode
HTTP 422
{
  "error": {
    "code": 422,
    "message": "Validation failed",
    "fields": {
      "email": "The email field is required.",
      "password": "Password must be at least 8 characters."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Reusable error helper in Node.js

const errorResponse = (res, statusCode, message, fields = null) => {
  return res.status(statusCode).json({
    error: { code: statusCode, message, ...(fields && { fields }) }
  });
};

// Usage
errorResponse(res, 404, 'User not found');
errorResponse(res, 422, 'Validation failed', { email: 'Email is required' });
Enter fullscreen mode Exit fullscreen mode

8. Use Query Parameters for Filtering and Sorting

Creating a new endpoint for every filter combination doesn't scale.

❌ Wrong

GET /getActiveUsersSortedByName
GET /getInactiveUsersSortedByDate
GET /getUsersByRole
Enter fullscreen mode Exit fullscreen mode

✅ Correct

GET /users?status=active&sort=name&order=asc
GET /users?status=inactive&sort=created_at&order=desc
GET /users?role=admin
Enter fullscreen mode Exit fullscreen mode

Example in Node.js

app.get('/users', async (req, res) => {
  const { status, sort = 'created_at', order = 'asc', role } = req.query;

  const where = {};
  if (status) where.status = status;
  if (role) where.role = role;

  const users = await User.findAll({
    where,
    order: [[sort, order.toUpperCase()]]
  });

  res.status(200).json({ data: users });
});
Enter fullscreen mode Exit fullscreen mode

9. Enforce Security from Day One

Security is not something you add after deployment. It's built in from the start.

Common mistakes to avoid

Mistake Risk
No rate limiting Brute force & abuse
No input validation SQL injection, XSS
No HTTPS Data interception
Exposing sensitive fields Token/password leakage

Rate limiting in Express

const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  message: { error: { code: 429, message: 'Too many requests' } }
});

app.use('/api/', limiter);
Enter fullscreen mode Exit fullscreen mode

Input validation (using Joi)

const Joi = require('joi');

const userSchema = Joi.object({
  name:     Joi.string().min(2).max(50).required(),
  email:    Joi.string().email().required(),
  password: Joi.string().min(8).required()
});

app.post('/users', (req, res) => {
  const { error } = userSchema.validate(req.body);
  if (error) {
    return res.status(422).json({
      error: { code: 422, message: error.details[0].message }
    });
  }
  // proceed...
});
Enter fullscreen mode Exit fullscreen mode

Hide sensitive fields (Laravel Resource)

class UserResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'email'      => $this->email,
            'created_at' => $this->created_at,
            // password, token, internal_id are intentionally omitted
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

10. Design Around Client Needs, Not Your Database

Your database schema is an internal implementation detail. Your API is a public contract.

❌ Wrong mindset

"This is how my database table looks, so this is how my response should look."

✅ Right mindset

"This is what the client needs. I'll shape the response to serve that."

Example: Combining fields and transforming data

Database has: first_name, last_name, dob (date of birth)

Client needs: full_name, age

app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);

  if (!user) return res.status(404).json({ error: { code: 404, message: 'User not found' } });

  const age = new Date().getFullYear() - new Date(user.dob).getFullYear();

  res.status(200).json({
    data: {
      id:        user.id,
      full_name: `${user.first_name} ${user.last_name}`,
      email:     user.email,
      age
      // internal columns like dob, password_hash are hidden
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

Your database can change. Your implementation can evolve. But your API contract must remain stable designed for the client, not for the table.


Summary: What Professional REST APIs Look Like

Principle What it means
✅ Resource-based URLs Nouns in URLs, verbs via HTTP methods
✅ Correct HTTP status codes 201 for created, 404 for missing, 422 for invalid, etc.
✅ Consistent naming snake_case or camelCase — never both
✅ API versioning /api/v1/ from day one
✅ Pagination Never return unbounded record sets
✅ Auth + Authz separated Identity vs. permissions are distinct
✅ Structured errors Predictable, machine-readable error objects
✅ Query param filtering One endpoint, flexible behavior
✅ Security by default HTTPS, rate limiting, validation, no sensitive leaks
✅ Client-first design API shape driven by what clients need

The difference between it works and it's well-designed is consistency, predictability, and care for the developer experience. Design it right from the beginning, your future self (and your teammates) will thank you.

You don't have to do all of this perfectly on day one. But the earlier you build these habits in, the less you'll be fighting your own code later.

The AI can write the routes. You have to make them good.
Ship clean. 🚀

Top comments (0)