Stop 17 · System Design for Full-Stack AppsTopics · 7/7

API Design Best Practices

Part of the Full Stack Developer Roadmap

In short

Your frontend is the API's first customer. If one screen needs six calls and every endpoint returns a different shape, the React code turns into spaghetti. Design for the screen.

Designing endpoints your frontend will actually enjoy consuming — consistent shapes, sensible nesting, and avoiding chatty APIs that require many round trips to render one screen.

The purple blob happily holds a full thali plate in front of a long row of empty counters.

Think of it like this

One thali, not six counters

Imagine lunch where dal is at one counter, rice at another and roti at a third, each with its own queue. A thali brings everything on one plate, the same way every day. A good API serves each screen like that.

Watch it happen

Loading the 'My Orders' screen

Chatty API

  • GET /orders, then each item
  • Then GET /products/:id per item
  • Six shapes, six error formats

Screen-shaped API

  • One GET /orders with items included
  • Same { data, nextCursor } for every list
  • One error shape everywhere

Return what the screen needs in one call, keep shapes consistent across endpoints, and paginate every list. Your frontend code gets shorter and your app feels faster.

The big ideas

What you need to know

Include what the screen shows

If the orders list always shows product names, include them. Don't make the client fetch each product.

One shape for lists

Every list returns { data, nextCursor }. The frontend writes one pagination hook and reuses it.

One shape for errors

Every error returns { error: { code, message } } with an honest status code, so the UI can show it the same way.

Always paginate

A list with no limit works with 10 rows and falls over with 10 lakh. Cap the limit on the server.

See it in code

Walk through the code

ts
1app.get("/api/orders", requireAuth, async (req, res) => {2  const limit = Math.min(Number(req.query.limit) || 20, 100);3  const cursor = req.query.cursor as string | undefined;4  const rows = await db.order.findMany({5    where: { userId: req.user.id },6    take: limit + 1,7    ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),8    orderBy: { id: "desc" },9    include: { items: { include: { product: { select: { name: true, imageUrl: true } } } } },10  });11  const hasMore = rows.length > limit;12  const data = hasMore ? rows.slice(0, limit) : rows;13  res.json({ data, nextCursor: hasMore ? data[data.length - 1].id : null });14});15 16// Every error: res.status(422).json({ error: { code: "INVALID_QTY", message: "Qty must be 1 to 10" } });

1/4The client picks a page size, but the server caps it at 100 so nobody can ask for everything.

One call fills the whole orders screen: items and product names included, the limit capped, and the same list shape every other endpoint uses.

Save yourself the pain

Common mistakes

Oops

Returning 200 with { success: false } for errors.

Do this instead

Use real status codes (400, 401, 404, 409, 422, 500) plus one consistent error body.

Oops

Returning full database rows, password hashes and internal fields included.

Do this instead

Select only the fields the client needs. Treat the response as a public contract.

Your mission

Count your round trips

~30 min

Quick check

Your orders screen calls GET /orders, then GET /products/:id for each of 15 items. What's the best fix?

Go deeper

  1. 1Audit an API you've built for how many requests a single frontend screen needs.
  2. 2Consider adding a combined endpoint if a screen currently requires 3+ separate calls.
  3. 3Keep response shapes consistent so your frontend's data-fetching code doesn't need special cases everywhere.

More topics at this stop

Stuck on this topic? Ask an Insider

Get 1:1 guidance from people who've walked this exact path — on the InsideEdge app.

Download
InsideEdge