I’ve built a lot of APIs and I’ve also consumed a lot of terrible ones. The kind where every endpoint returns a different structure. Where error messages say “something went wrong” with no details. Where you need to read the source code to figure out what query parameters are supported.

After enough pain on both sides, I started following a few patterns that keep things simple and predictable. Nothing groundbreaking, just stuff that saves time and reduces frustration.


Keep your response structure consistent

This is the number one thing that makes an API nice to work with. Every endpoint should return the same shape.

{
  "success": true,
  "data": { ... }
}
{
  "success": false,
  "error": {
    "message": "User not found",
    "code": "USER_NOT_FOUND"
  }
}

The frontend should never have to guess what shape the response is in. If it’s a success, the data is in data. If it’s an error, the message is in error.message. Always.

I’ve worked with APIs where some endpoints return { result: ... }, others return { data: ... }, and some just return the raw object. It makes frontend code a mess because you need different parsing logic for every endpoint.


Use proper HTTP status codes

Don’t return 200 for everything. I’ve seen APIs that return 200 with { error: true } in the body. That defeats the whole purpose.

The basics:

  • 200 - success
  • 201 - created something new
  • 400 - client sent bad data
  • 401 - not authenticated
  • 403 - authenticated but not allowed
  • 404 - thing doesn’t exist
  • 500 - server messed up

You don’t need to memorize all the status codes. These 7 cover 95% of what you’ll ever need.


Validate input early and return clear errors

Don’t let bad data travel deep into your application before failing. Validate at the edge.

app.post("/api/users", (req, res) => {
  const { email, name } = req.body;

  if (!email) {
    return res.status(400).json({
      success: false,
      error: { message: "Email is required", code: "MISSING_EMAIL" }
    });
  }

  if (!name) {
    return res.status(400).json({
      success: false,
      error: { message: "Name is required", code: "MISSING_NAME" }
    });
  }

  // proceed with creating the user
});

For anything more complex I use Zod. Define a schema, validate the body, and return the errors automatically. It takes 5 minutes to set up and saves hours of debugging weird issues caused by unexpected input.

import { z } from "zod";

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
});

app.post("/api/users", (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      success: false,
      error: {
        message: "Validation failed",
        details: result.error.flatten(),
      },
    });
  }

  const { email, name } = result.data;
  // now you know the types are correct
});

Name your endpoints like a normal person

Keep URLs simple and predictable. Use nouns for resources, not verbs.

Good:

  • GET /api/users - get all users
  • GET /api/users/123 - get one user
  • POST /api/users - create a user
  • PUT /api/users/123 - update a user
  • DELETE /api/users/123 - delete a user

Bad:

  • GET /api/getUsers
  • POST /api/createNewUser
  • POST /api/deleteUser

The HTTP method already tells you the action. You don’t need to repeat it in the URL.


Don’t return everything from the database

This is a mistake I made early on. Just dumping the entire database row into the response.

// don't do this
app.get("/api/users/:id", async (req, res) => {
  const user = await db.user.findUnique({ where: { id: req.params.id } });
  res.json({ success: true, data: user });
});

Now your API is returning password hashes, internal IDs, timestamps you don’t need, and whatever else is in that table. At best it’s messy. At worst it’s a security issue.

Always pick what you return:

app.get("/api/users/:id", async (req, res) => {
  const user = await db.user.findUnique({
    where: { id: req.params.id },
    select: { id: true, name: true, email: true, createdAt: true },
  });

  res.json({ success: true, data: user });
});

Add pagination from the start

If your endpoint returns a list, add pagination immediately. Not later. Now.

I can’t count how many times I’ve seen an API that returns ALL records in one call. It works fine with 50 records in development. Then production hits 10,000 records and everything breaks.

app.get("/api/posts", async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 20;
  const skip = (page - 1) * limit;

  const [posts, total] = await Promise.all([
    db.post.findMany({ skip, take: limit, orderBy: { createdAt: "desc" } }),
    db.post.count(),
  ]);

  res.json({
    success: true,
    data: posts,
    pagination: {
      page,
      limit,
      total,
      pages: Math.ceil(total / limit),
    },
  });
});

It takes 5 extra minutes to add this. It saves you from rewriting your frontend later when things slow down.


Handle errors globally

Don’t write try/catch in every single route handler. Set up a global error handler and let errors bubble up.

// wrapper to catch async errors
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

app.get("/api/users", asyncHandler(async (req, res) => {
  const users = await db.user.findMany();
  res.json({ success: true, data: users });
}));

// global error handler
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({
    success: false,
    error: { message: "Internal server error" },
  });
});

Clean, centralized, and you never forget to handle an error.


Keep it boring

The best APIs are boring. Predictable responses, clear errors, consistent naming, proper status codes. Nobody wants to be surprised by an API. They want to call it, get what they expected, and move on.

Every minute you spend making your API consistent and predictable saves 10 minutes for the person consuming it. And that person is usually future you.