API Design Best Practices: REST, GraphQL, gRPC — How to Choose and How to Build Right
A practical guide to designing APIs that developers actually want to use. Covers REST conventions, GraphQL schemas, gRPC services, pagination, error handling, auth, and when to use each approach.
I've consumed hundreds of APIs and built a few dozen. The best ones share common traits: consistent naming, predictable error responses, sensible pagination, and clear documentation. The worst ones feel like the developer made it up as they went along — which they probably did.
Good API design isn't about following a spec religiously. It's about making your API predictable so that consumers can guess how things work before reading the docs.
REST: The Standard (When Done Right)
REST is the default choice for most APIs, and for good reason. It maps cleanly to HTTP, it's cacheable, every language has HTTP clients, and developers already understand it.
The key principle: resources, not actions. Your URLs are nouns, HTTP methods are verbs.
// Good: resource-based
GET /users // List users
GET /users/123 // Get user 123
POST /users // Create a user
PATCH /users/123 // Update user 123
DELETE /users/123 // Delete user 123
// Bad: action-based
GET /getUsers
POST /createUser
POST /getUserOrders
GET /deleteUser?id=123
Nested resources for relationships:
GET /users/123/orders // Orders belonging to user 123
GET /users/123/orders/456 // Order 456 of user 123
POST /users/123/orders // Create order for user 123
Here's a real Express implementation:
import express from "express";
const router = express.Router();
// List users with filtering and sorting
router.get("/users", async (req, res) => {
const { role, sort = "created", order = "desc", limit = 20, cursor } = req.query;
const filters = {};
if (role) filters.role = role;
const users = await UserService.list({
filters,
sort: { field: sort, order },
pagination: { limit: Math.min(Number(limit), 100), cursor },
});
res.json({
data: users.items,
pagination: {
nextCursor: users.nextCursor,
hasMore: users.hasMore,
},
});
});
// Get single user
router.get("/users/:id", async (req, res) => {
const user = await UserService.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: { code: "USER_NOT_FOUND", message: "User not found" },
});
}
res.json({ data: user });
});
// Create user
router.post("/users", async (req, res) => {
const result = await UserService.create(req.body);
if (!result.ok) {
return res.status(400).json({
error: { code: "VALIDATION_ERROR", message: result.error.message, details: result.error.details },
});
}
res.status(201).json({ data: result.value });
});
Pagination: Cursor-Based Beats Offset
Offset pagination (?page=3&limit=20) is simple but broken at scale. If someone inserts a row while you're paginating, you'll see duplicates or miss items. It also gets slower on large tables because the database still scans all skipped rows.
Cursor-based pagination fixes both problems:
// Request
GET /users?limit=20&cursor=eyJpZCI6MTIzfQ
// Response
{
"data": [...],
"pagination": {
"nextCursor": "eyJpZCI6MTQzfQ",
"hasMore": true
}
}
The cursor is an opaque token (usually base64-encoded) that points to the last item returned. The database query uses it as a WHERE clause:
async function listUsers({ limit, cursor }) {
let query = db.select("*").from("users").orderBy("id", "asc").limit(limit + 1);
if (cursor) {
const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString());
query = query.where("id", ">", decoded.id);
}
const rows = await query;
const hasMore = rows.length > limit;
const items = hasMore ? rows.slice(0, -1) : rows;
const nextCursor = hasMore
? Buffer.from(JSON.stringify({ id: items.at(-1).id })).toString("base64url")
: null;
return { items, nextCursor, hasMore };
}
Filtering and Sorting
Keep it simple and consistent:
GET /users?role=admin&status=active // Filtering
GET /users?sort=-created // Sort descending by created
GET /users?sort=name // Sort ascending by name
GET /users?fields=id,name,email // Sparse fields
The - prefix for descending sort is a common convention that works well. For more complex filtering, some APIs use a bracket syntax: ?filter[age][gte]=21&filter[role]=admin.
Error Responses: Be Consistent
Nothing is more frustrating than an API that returns errors in different formats depending on which endpoint you hit. Pick a format and use it everywhere:
// Standard error envelope
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" },
{ "field": "password", "message": "Must be at least 8 characters" }
]
}
}
// HTTP status codes that matter:
// 200 OK - Success
// 201 Created - Resource created (POST)
// 204 No Content - Success, no body (DELETE)
// 400 Bad Request - Client sent invalid data
// 401 Unauthorized - Not authenticated
// 403 Forbidden - Authenticated but not allowed
// 404 Not Found - Resource doesn't exist
// 409 Conflict - Duplicate or state conflict
// 422 Unprocessable Entity - Valid syntax but semantic errors
// 429 Too Many Requests - Rate limited
// 500 Internal Server Error - Server broke
A middleware to enforce consistent error responses:
function errorHandler(err, req, res, next) {
if (err.name === "ValidationError") {
return res.status(400).json({
error: { code: "VALIDATION_ERROR", message: err.message, details: err.details },
});
}
if (err.name === "NotFoundError") {
return res.status(404).json({
error: { code: "NOT_FOUND", message: err.message },
});
}
// Don't leak internal errors to clients
console.error("Unhandled error:", err);
res.status(500).json({
error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred" },
});
}
app.use(errorHandler);
Authentication
Three common approaches, each with its place:
API keys — simple, good for server-to-server. Send in a header:X-API-Key: sk_live_abc123. Easy to implement, easy to rotate. No user context.
JWT (JSON Web Tokens) — stateless auth for user-facing APIs. The server issues a signed token containing user claims:
import jwt from "jsonwebtoken";
// Issue token on login
function generateTokens(user) {
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
const refreshToken = jwt.sign(
{ sub: user.id, type: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
);
return { accessToken, refreshToken };
}
// Verify on each request
function authenticate(req, res, next) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Missing token" } });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Invalid or expired token" } });
}
}
OAuth 2.0 — when users authorize third-party access. More complex, but the right choice when you need "Login with Google" or third-party API access.
Rate Limiting
Always include rate limit headers so consumers can self-throttle:
function rateLimit({ windowMs = 60000, max = 100 } = {}) {
const hits = new Map();
return (req, res, next) => {
const key = req.ip;
const now = Date.now();
const record = hits.get(key) || { count: 0, resetAt: now + windowMs };
if (now > record.resetAt) {
record.count = 0;
record.resetAt = now + windowMs;
}
record.count++;
hits.set(key, record);
res.set("X-RateLimit-Limit", String(max));
res.set("X-RateLimit-Remaining", String(Math.max(0, max - record.count)));
res.set("X-RateLimit-Reset", String(Math.ceil(record.resetAt / 1000)));
if (record.count > max) {
return res.status(429).json({
error: { code: "RATE_LIMITED", message: "Too many requests" },
});
}
next();
};
}
Versioning
Two schools of thought, both valid:
URL versioning (/v1/users) — simple, explicit, easy to route. The pragmatic choice.
Header versioning (Accept: application/vnd.myapi.v2+json) — cleaner URLs, but harder to test in a browser and more complex to implement.
In my experience, URL versioning wins for public APIs because it's obvious and easy to work with. Header versioning works for internal APIs where you control both sides.
GraphQL: When REST Gets Awkward
REST works great until you have mobile clients fetching nested data. A mobile app showing a user profile with their recent orders and order items might need three REST calls: /users/123, /users/123/orders, /orders/456/items. GraphQL solves this with a single query:
query UserProfile($id: ID!) {
user(id: $id) {
name
email
orders(last: 5) {
id
total
status
items {
product { name, price }
quantity
}
}
}
}
One request, exactly the fields you need, no over-fetching. Here's a basic schema and resolver in Node.js:
const typeDefs =
type User {
id: ID!
name: String!
email: String!
orders(last: Int): [Order!]!
}
type Order {
id: ID!
total: Float!
status: String!
items: [OrderItem!]!
}
type Query {
user(id: ID!): User
}
;
const resolvers = {
Query: {
user: (_, { id }) => UserService.findById(id),
},
User: {
orders: (user, { last }) => OrderService.findByUserId(user.id, { limit: last }),
},
Order: {
items: (order) => OrderItemService.findByOrderId(order.id),
},
};
GraphQL's strength is flexibility for the client. Its weakness is complexity on the server: N+1 queries (use DataLoader), caching is harder (no HTTP caching by default), and query complexity can spiral without limits.
gRPC: When Performance Matters
gRPC uses Protocol Buffers for binary serialization and HTTP/2 for transport. It's significantly faster than JSON over REST for high-throughput internal services.
// user.proto
syntax = "proto3";
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (stream User); // Server streaming
}
message GetUserRequest {
string id = 1;
}
message User {
string id = 1;
string name = 2;
string email = 3;
}
Use gRPC for service-to-service communication in microservices where latency matters. Don't use it for browser-facing APIs (browser support is limited, though gRPC-Web exists).
REST vs GraphQL vs gRPC
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Best for | Public APIs, CRUD | Flexible client queries | Internal microservices |
| Data format | JSON | JSON | Protobuf (binary) |
| Caching | HTTP caching built-in | Complex (no GET) | Manual |
| Learning curve | Low | Medium | High |
| Tooling | Massive | Good (Apollo, Relay) | Language-specific |
| Browser support | Native | Native | Limited (gRPC-Web) |
| Type safety | Optional (OpenAPI) | Built-in (schema) | Built-in (protobuf) |
The Pragmatic Take
Start with REST. It covers 90% of use cases, every developer understands it, and the tooling is unmatched. Add GraphQL if your clients need flexible queries across complex data relationships. Use gRPC for internal service-to-service communication where throughput and latency matter.
The worst API isn't the one with the wrong protocol — it's the inconsistent one. Consistent naming, consistent error handling, consistent pagination. Those matter more than REST vs GraphQL debates.
If you're building APIs and want hands-on practice designing endpoints, handling auth, and choosing the right patterns, CodeUp has projects that walk you through real-world API design from scratch.