API Design: REST and GraphQL Patterns That Scale
API design guide covering REST, GraphQL, tRPC, authentication, rate limiting, error handling, and testing with Node.js examples.

Bad API design costs more time than bad code
You can refactor bad code in an afternoon, or rewrite a messy function over lunch. A bad API is different. Once external clients depend on it, every change means coordinating across teams, apps and third-party integrations, version 1 hangs around far longer than anyone planned, and you end up maintaining two or three versions at once because nobody wants to migrate.
It plays out the same way on team after team: a developer builds an API quickly for a frontend feature, it works and ships on time, and six months later a mobile app needs the same data in a different shape while another team wants to integrate. The "quick and dirty" API is now a bottleneck. Refactoring it breaks every client that depends on it.
Good API design ages well. Stripe, GitHub's API and Twilio get praised because they follow consistent conventions that make them predictable and easy to work with, with no secret technology involved, so if you've used one Stripe endpoint you can usually guess how the next one works. That's my bar.
What follows is what I've learned about designing APIs that don't turn into liabilities, whether you're building REST, GraphQL or something in between.
REST conventions that matter
REST is a set of architectural constraints, not a protocol or a specification. Following them gets you predictable, cacheable APIs. Most "REST" APIs out there are JSON-over-HTTP that follow REST principles to varying degrees, and that's fine, because what you want is consistency far more than you want textbook-perfect REST.
Resource naming
URLs should name resources (nouns). The action (the verb) belongs in the HTTP method.
# Good
GET /api/users → List users
POST /api/users → Create a user
GET /api/users/123 → Get a specific user
PUT /api/users/123 → Replace a user
PATCH /api/users/123 → Partially update a user
DELETE /api/users/123 → Delete a user
# Bad
GET /api/getUsers
POST /api/createUser
POST /api/deleteUser/123
GET /api/getUserById?id=123
Use plural nouns for collections (/users, not /user) and kebab-case for multi-word resources (/order-items, not /orderItems or /order_items). Whatever you pick, never mix styles.
For nested resources, think about whether the nesting is necessary:
# Nested — use when the child resource doesn't make sense without the parent
GET /api/users/123/orders → Orders belonging to user 123
# Flat — use when the child resource has its own identity
GET /api/orders?user_id=123 → Orders filtered by user
GET /api/orders/456 → A specific order (regardless of user)
I generally prefer flat structures with query parameters for filtering. Deep nesting (/api/users/123/orders/456/items/789) gets unwieldy quickly, and by three levels your frontend developer is already cursing your name.
HTTP methods and status codes
Use the right HTTP method for each operation:
| Method | Purpose | Idempotent? | Request Body? |
|---|---|---|---|
| GET | Retrieve data | Yes | No |
| POST | Create new resource | No | Yes |
| PUT | Replace entire resource | Yes | Yes |
| PATCH | Partial update | Yes* | Yes |
| DELETE | Remove resource | Yes | Optional |
*PATCH is technically not required to be idempotent, but designing it to be idempotent avoids a category of bugs.
Please use proper status codes. Few things annoy API consumers more than a 200 OK whose body says {"error": "User not found"}. I've seen it in production. It drove me up the wall.
// Express.js example — proper status codes
app.get('/api/users/:id', async (req, res) => {
try {
const user = await db.users.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: 'NOT_FOUND',
message: `User with id ${req.params.id} does not exist`,
});
}
res.status(200).json({ data: user });
} catch (err) {
console.error('Error fetching user:', err);
res.status(500).json({
error: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred',
});
}
});
The status codes you should be using:
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST that creates a resource |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid input, validation failure |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but insufficient permissions |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate resource, conflicting update |
| 422 | Unprocessable Content | Valid syntax but semantic errors |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server failure |
Versioning
Your API will change. Plan for it from day one.
URL versioning is the most common approach and the most practical one:
GET /api/v1/users
GET /api/v2/users
Header versioning is cleaner, but harder for clients to implement:
GET /api/users
Accept: application/vnd.myapi.v2+json
I recommend URL versioning because every tutorial, tool and developer understands it straight away. Start at v1. Increment only for breaking changes; non-breaking additions (new fields, new optional parameters) don't need a version bump.
Pagination
Never return unbounded lists. An endpoint that returns 10,000 records because nobody added pagination will eventually take down your server or your client, and quite possibly both.
Offset-based pagination:
GET /api/products?page=2&limit=20
{
"data": [...],
"pagination": {
"page": 2,
"limit": 20,
"total": 156,
"totalPages": 8
}
}
Cursor-based pagination (better for large datasets and real-time data):
GET /api/products?cursor=eyJpZCI6MTAwfQ&limit=20
{
"data": [...],
"pagination": {
"nextCursor": "eyJpZCI6MTIwfQ",
"hasMore": true
}
}
Cursor-based pagination performs better (no OFFSET scans in the database) and handles insertions/deletions between pages correctly. For any dataset that could grow past a few thousand records, use cursors. I suspect most teams pick offset pagination out of habit, even though cursors aren't much harder to build and scale far better.
When REST breaks down
REST works really well when your data access is straightforward. Cracks show up in three situations.
-
Clients need different shapes of the same data. A mobile app might want a user's name and avatar, while an admin dashboard wants name, email, role, created date, last login and order count. With REST you either return everything (over-fetching) or build separate endpoints for each client (endpoint explosion).
-
Related data takes multiple round trips. To show a user's profile with their latest orders and shipping addresses, a REST client might have to hit
/users/123,/users/123/orders?limit=5and/users/123/addresses. That's three HTTP requests for one screen, which hurts on a flaky mobile connection in tier-2 India. -
You need real-time updates. REST is request-response, so live dashboards, chat or notifications need WebSockets or SSE layered on top.
That is where GraphQL comes in.
GraphQL fundamentals
With GraphQL, the client says exactly what data it needs, the server exposes a schema (a type system describing all the available data), clients query against it, and nobody has to guess which fields an endpoint returns, and nothing gets over-fetched.
Schema definition
type User {
id: ID!
name: String!
email: String!
role: Role!
orders(limit: Int = 10): [Order!]!
addresses: [Address!]!
createdAt: DateTime!
}
type Order {
id: ID!
total: Float!
status: OrderStatus!
items: [OrderItem!]!
createdAt: DateTime!
}
enum Role {
USER
ADMIN
MODERATOR
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
type Query {
user(id: ID!): User
users(page: Int, limit: Int): UserConnection!
order(id: ID!): Order
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
Queries
A client fetches exactly what it needs:
# Mobile app — minimal data
query {
user(id: "123") {
name
avatarUrl
}
}
# Admin dashboard — detailed data
query {
user(id: "123") {
name
email
role
createdAt
orders(limit: 5) {
id
total
status
}
addresses {
city
state
}
}
}
Both queries hit the same endpoint (POST /graphql), and the server returns exactly the fields requested, with no over-fetching or under-fetching, in one network request. I love this part.
Resolvers
// Node.js with Apollo Server
const resolvers = {
Query: {
user: async (_, { id }, context) => {
return context.db.users.findById(id);
},
users: async (_, { page = 1, limit = 20 }, context) => {
return context.db.users.findMany({ page, limit });
},
},
User: {
orders: async (parent, { limit }, context) => {
return context.db.orders.findByUserId(parent.id, { limit });
},
addresses: async (parent, _, context) => {
return context.db.addresses.findByUserId(parent.id);
},
},
Mutation: {
createUser: async (_, { input }, context) => {
return context.db.users.create(input);
},
},
};
Watch out: the N+1 problem and DataLoader
This is GraphQL's biggest performance trap. Fetch a list of 20 users where each user resolver runs a database query for their orders, and you've made 1 + 20 = 21 queries. Nested relationships multiply that further, and it gets ugly fast.
DataLoader fixes this by batching and caching database queries within a single request:
import DataLoader from 'dataloader';
// Create a loader that batches user IDs
const ordersByUserLoader = new DataLoader(async (userIds) => {
// One query instead of N queries
const orders = await db.orders.findByUserIds(userIds);
// Return results in the same order as the input IDs
return userIds.map(id => orders.filter(order => order.userId === id));
});
// In the resolver
const resolvers = {
User: {
orders: (parent) => ordersByUserLoader.load(parent.id),
},
};
DataLoader collects all .load() calls made within a single tick of the event loop into one database query. In the 20-user example, the 21 queries become 2: one for users and one for all their orders.
Every production GraphQL server should use DataLoader, without exception. I've seen servers grind to a halt because someone skipped it.
tRPC for full-stack TypeScript
If your frontend and backend are both TypeScript (Next.js, for example), tRPC gives you something neither REST nor GraphQL can: end-to-end type safety with no code generation. To get more out of tRPC, our advanced TypeScript patterns guide covers the type system techniques behind it.
// Server — define your API
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
user: t.router({
getById: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.users.findById(input.id);
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return user;
}),
create: t.procedure
.input(z.object({
name: z.string().min(2),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return db.users.create(input);
}),
}),
});
export type AppRouter = typeof appRouter;
// Client — fully typed, autocompletions everywhere
import { trpc } from '@/utils/trpc';
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading } = trpc.user.getById.useQuery({ id: userId });
// TypeScript knows `data` has `name`, `email`, etc.
// Autocomplete works. Type errors are caught at build time.
return <div>{data?.name}</div>;
}
Rename a field on the server and your IDE immediately flags every client still using the old name. The router definition is the contract. There's no API documentation to keep in sync and no types to generate, and for me it's probably the best developer experience I've had with any API approach, REST and GraphQL included.
tRPC doesn't work for public APIs (it needs a TypeScript client). For full-stack applications where you control both ends, though, it removes a whole category of bugs and busywork.
API authentication patterns
JWT (JSON Web Tokens)
JWTs are the most common approach for SPAs and mobile apps: the server issues a signed token after login, and the client sends it along with every request it makes until that token expires and has to be refreshed.
// Login endpoint
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await verifyCredentials(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Set refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ token });
});
// Auth middleware
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
try {
const token = authHeader.split(' ')[1];
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
A few practices I stick to:
- Short-lived access tokens (15-30 minutes)
- Long-lived refresh tokens stored in httpOnly cookies (never in localStorage)
- Minimal data in the JWT payload (user ID and role, not the entire user object)
Session-based authentication
It's old school. It still works great. The server creates a session, stores it (in memory, Redis, or database), and sends the client a session ID cookie.
import session from 'express-session';
import RedisStore from 'connect-redis';
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true,
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'strict',
},
}));
For traditional web apps, sessions win. The server stays in full control and can revoke a session instantly, which you can't do with a JWT (you either wait for it to expire or keep a blocklist). Teams often seem to default to JWTs when sessions would have suited them better, probably because JWTs get more blog posts.
API keys
API keys are for server-to-server communication and third-party integrations. They're simple. They're also less secure than tokens, since they're long-lived and grant access until someone revokes them.
function authenticateApiKey(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) return res.status(401).json({ error: 'Missing API key' });
const client = await db.apiKeys.findByKey(apiKey);
if (!client || client.revokedAt) {
return res.status(401).json({ error: 'Invalid API key' });
}
req.client = client;
next();
}
| Method | Best For | Statefulness | Revocation |
|---|---|---|---|
| JWT | SPAs, mobile apps | Stateless | Difficult (wait for expiry) |
| Sessions | Traditional web apps | Stateful (server-side) | Instant |
| API Keys | Server-to-server, integrations | Stateless | Instant (database check) |
Rate limiting
Build rate limiting in early. Without it, one misbehaving client can take your API down for everyone, and I've seen this bite teams who said "we'll add it later." Later never came. Then a bot hammered their endpoint for three hours.
import rateLimit from 'express-rate-limit';
// Global rate limit
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true, // Return rate limit info in headers
message: {
error: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests, please try again later',
retryAfter: 900, // seconds
},
});
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Too many login attempts' },
});
app.use('/api/', globalLimiter);
app.use('/api/auth/', authLimiter);
Return 429 Too Many Requests with a Retry-After header. The RateLimit-* headers are still an IETF draft rather than a finished standard, but they're widely supported, and they let clients implement backoff:
RateLimit-Limit: 100
RateLimit-Remaining: 23
RateLimit-Reset: 1708200000
Error handling patterns
Consistent error responses make life easier for every client developer, so define one standard error format and use it on every endpoint, including the odd ones you think nobody calls and the internal ones you assume only your own team will ever hit.
// Error response format
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request data",
"details": [
{
"field": "email",
"message": "Must be a valid email address"
},
{
"field": "name",
"message": "Must be at least 2 characters"
}
]
}
}
// Centralized error handler in Express
app.use((err, req, res, next) => {
console.error(`[${req.method}] ${req.path}:`, err);
if (err.name === 'ValidationError') {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request data',
details: err.details,
},
});
}
if (err.name === 'UnauthorizedError') {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Authentication required',
},
});
}
// Default — never expose internal error details in production
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production'
? 'An unexpected error occurred'
: err.message,
},
});
});
Documentation with OpenAPI / Swagger
An API is only as good as its documentation. OpenAPI (formerly Swagger) is the industry standard.
openapi: 3.0.3
info:
title: E-Commerce API
version: 1.0.0
description: API for managing products, orders, and users
paths:
/api/v1/products:
get:
summary: List products
parameters:
- name: category
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
default: 20
responses:
'200':
description: List of products
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Product'
If you're deploying your API using containers, our Docker and Kubernetes beginner's guide covers how to containerize Node.js services like this. To serve interactive documentation from your API server, use swagger-ui-express:
import swaggerUi from 'swagger-ui-express';
import swaggerDocument from './openapi.json';
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
Testing APIs
Postman
Postman is the most popular API testing tool. You build collections of requests, set up environments (development, staging, production), write test scripts and share collections with your team. The free tier is generous.
Bruno
Bruno is a newer, open-source alternative that keeps collections as plain files on your filesystem. It needs no cloud sync and no account. You can version-control collections with Git. If Postman's growing focus on cloud features and team plans bothers you, Bruno is a nice change, and I think more teams should try it.
httpie
A command-line HTTP client that's far more readable than curl:
# GET request
http GET localhost:3000/api/users
# POST request with JSON body
http POST localhost:3000/api/users name="Priya" email="priya@example.com"
# With authentication
http GET localhost:3000/api/users Authorization:"Bearer token123"
For quick tests and scripts, httpie is faster than opening Postman or Bruno, and it's probably what I use most for ad-hoc requests.
Choosing between REST, GraphQL, and tRPC
| Consideration | REST | GraphQL | tRPC |
|---|---|---|---|
| Public API | Best choice | Good | Not suitable |
| Internal API (full-stack TS) | Good | Good | Best choice |
| Multiple clients (web, mobile, third-party) | Good | Best choice | Not suitable |
| Simple CRUD | Best choice | Overkill | Good |
| Complex data relationships | Acceptable | Best choice | Good |
| Learning curve | Low | Medium | Low (if you know TS) |
| Caching | Excellent (HTTP caching) | Requires effort | Relies on React Query |
| Tooling ecosystem | Massive | Large | Growing |
Start with REST. It's the simplest, the most widely understood and the best supported option, with tooling, caching and documentation conventions that every developer you hire will already know from day one. Move to GraphQL when you have several clients with different data needs, or when over-fetching/under-fetching with REST is causing you real trouble rather than theoretical trouble. Use tRPC when you control both ends and both are TypeScript.
Consistency beats perfection
After a decade of building and consuming APIs, I think consistency matters most. A well-designed REST API will beat a poorly designed GraphQL API every time, and a GraphQL API with a sloppy schema runs into the same problems REST had.
Pick conventions and stick to them. Clear naming, proper error handling, authentication, rate limiting, documentation and testing apply whichever technology you choose.
Design for the developer who'll be using your API at 2 AM with a deadline closing in, because that person needs it to be predictable, documented and forgiving of mistakes. A perfectly "correct" API that's inconsistent across endpoints frustrates people more than a slightly unconventional one that behaves the same way everywhere.
If you're preparing for technical interviews, API design is a big part of system design interview preparation, since interviewers love asking how you'd design scalable APIs. Outside interviews, you'll use this stuff every day, probably more than most things you learn.
Anurag Sinha
Founder & Editor
Founder and editor of Tech Tips India. Writes tech guides, buying guides and explainers for Indian readers, with rupee pricing and local availability. Product roundups are researched from manufacturer specs and published reviews, not lab testing.
Stay Ahead in Tech
Get the latest tech news, tutorials, and reviews delivered straight to your inbox every week.
No spam ever. Unsubscribe anytime.
Comments (0)
Leave a Comment
All comments are moderated before appearing. Please be respectful and follow our community guidelines.
Related Articles

DSA Roadmap for Placements: What Matters in 2026
Practical DSA roadmap for campus placements in India: topic priorities, platform choices, company patterns, and time management.

Redis Caching: Speed Up Your App in Practice
Redis caching strategies, data structures, and cache invalidation with Node.js, Next.js, and Upstash integration guide.

TypeScript Advanced Patterns You Should Know
Advanced TypeScript: discriminated unions, template literals, branded types, builder pattern, and Zod validation with examples.