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.

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
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
Quick check
Your orders screen calls GET /orders, then GET /products/:id for each of 15 items. What's the best fix?
Go deeper
- 1Audit an API you've built for how many requests a single frontend screen needs.
- 2Consider adding a combined endpoint if a screen currently requires 3+ separate calls.
- 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.