api-design
API Design - REST & GraphQL
Design consistent, intuitive APIs that scale
When to Use This Skill
Use this skill when:
- Designing new API endpoints (REST or GraphQL)
- Creating HTTP routes and handlers
- Implementing pagination, filtering, or sorting
- Versioning APIs for backward compatibility
- Handling API errors and validation
- Designing GraphQL schemas and resolvers
- Optimizing API performance (N+1 queries, caching)
Don't use this skill for:
- Frontend-only work with no API involvement
- Direct database queries without an API layer
- Internal function calls (not exposed as API)
Critical Patterns
Pattern 1: HTTP Methods and Status Codes
When: Building RESTful endpoints
Good:
// Correct HTTP methods and status codes
export async function GET(request: Request) {
const users = await db.user.findMany();
return NextResponse.json(users, { status: 200 });
}
export async function POST(request: Request) {
const body = await request.json();
if (!body.email || !body.name) {
return NextResponse.json(
{ error: 'Email and name are required' },
{ status: 400 } // Bad Request
);
}
const user = await db.user.create({ data: body });
return NextResponse.json(user, { status: 201 }); // Created
}
export async function DELETE(request: Request) {
await db.user.delete({ where: { id: '123' } });
return new Response(null, { status: 204 }); // No Content
}
Bad:
// ❌ Wrong: Using POST for everything
export async function POST(request: Request) {
const { action, userId } = await request.json();
if (action === 'get') {
const user = await db.user.findUnique({ where: { id: userId } });
return NextResponse.json(user); // Should be GET
}
if (action === 'delete') {
await db.user.delete({ where: { id: userId } });
return NextResponse.json({ success: true }); // Should be DELETE
}
}
// ❌ Wrong: Always returning 200
export async function GET(request: Request) {
const user = await db.user.findUnique({ where: { id: '999' } });
if (!user) {
return NextResponse.json({ error: 'Not found' }, { status: 200 }); // Should be 404
}
return NextResponse.json(user);
}
Why: Correct HTTP methods and status codes make APIs predictable and RESTful. Clients can rely on standard semantics.
HTTP Methods Quick Reference:
GET - Retrieve (Safe, Idempotent, Cacheable)
POST - Create (Not safe, Not idempotent)
PUT - Replace entire resource (Not safe, Idempotent)
PATCH - Partial update (Not safe, Usually idempotent)
DELETE - Remove (Not safe, Idempotent)
Status Codes Quick Reference:
2xx Success:
200 OK - Successful GET, PUT, PATCH, DELETE
201 Created - Successful POST (resource created)
204 No Content - Successful DELETE (no response body)
4xx Client Errors:
400 Bad Request - Invalid request data
401 Unauthorized - Authentication required
403 Forbidden - Authenticated but not authorized
404 Not Found - Resource doesn't exist
409 Conflict - Resource conflict (duplicate)
422 Unprocessable - Validation errors
5xx Server Errors:
500 Internal - Unexpected server error
503 Unavailable - Server temporarily unavailable
Pattern 2: Resource Naming and Nesting
When: Designing API URL structure
Good:
// ✅ Use nouns, not verbs
GET /api/users
POST /api/users
GET /api/users/123
DELETE /api/users/123
// ✅ Plural nouns for collections
GET /api/products
GET /api/orders
// ✅ Nested resources (max 2 levels)
GET /api/users/123/posts
POST /api/users/123/posts
GET /api/users/123/posts/456
Bad:
// ❌ Wrong: Verbs in URLs
GET /api/getUsers
POST /api/createUser
DELETE /api/deleteUser/123
// ❌ Wrong: Singular for collections
GET /api/user
GET /api/product
// ❌ Wrong: Too deeply nested (3+ levels)
GET /api/users/123/posts/456/comments/789/likes
Why: Nouns represent resources, verbs are implied by HTTP methods. Avoid deep nesting to keep URLs simple and predictable.
Nested Resources Pattern:
GET /api/users/123/posts - Get all posts by user 123
GET /api/users/123/posts/456 - Get specific post by user 123
POST /api/users/123/posts - Create post for user 123
// ⚠️ For deep relationships, use query params instead:
GET /api/comments?postId=456
GET /api/likes?commentId=789
Pattern 3: Pagination
When: Returning large collections
Good - Cursor-based (recommended for large datasets):
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const cursor = searchParams.get('cursor');
const limit = parseInt(searchParams.get('limit') || '20');
const users = await db.user.findMany({
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
orderBy: { id: 'asc' },
});
const hasMore = users.length > limit;
const data = hasMore ? users.slice(0, -1) : users;
return NextResponse.json({
data,
pagination: {
nextCursor: hasMore ? data[data.length - 1].id : null,
hasMore,
},
});
}
Good - Offset-based (simpler, for admin panels):
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const [users, total] = await Promise.all([
db.user.findMany({
skip: (page - 1) * limit,
take: limit,
}),
db.user.count(),
]);
return NextResponse.json({
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
}
Bad:
// ❌ No pagination - returns all records
export async function GET() {
const users = await db.user.findMany(); // Could be millions!
return NextResponse.json(users);
}
Why: Pagination prevents performance issues and timeouts. Cursor-based is more efficient for large datasets, offset-based is simpler for small datasets.
Pattern 4: API Versioning
When: Making breaking changes to existing APIs
Good - URL versioning (most explicit):
GET /api/v1/users
GET /api/v2/users
// app/api/v1/users/route.ts
export async function GET() {
const users = await db.user.findMany();
return NextResponse.json(users); // Old format
}
// app/api/v2/users/route.ts
export async function GET() {
const users = await db.user.findMany({
include: { profile: true }, // New: Include related data
});
return NextResponse.json(users);
}
Breaking vs Non-Breaking Changes:
// ✅ Non-breaking (no version bump needed):
// - Add new endpoint
// - Add optional field to request
// - Add new field to response
// ❌ Breaking (requires new version):
// - Remove endpoint
// - Remove field from response
// - Rename field
// - Change field type
// - Make optional field required
Why: Versioning allows backward compatibility while evolving the API. Existing clients continue working while new clients use improved versions.
Pattern 5: Consistent Error Handling
When: Handling errors and validation
Good:
interface ApiError {
code: string;
message: string;
details?: Array<{ field: string; message: string }>;
}
function errorResponse(status: number, error: ApiError) {
return NextResponse.json({ error }, { status });
}
export async function POST(request: Request) {
try {
const body = await request.json();
const errors = validateUser(body);
if (errors.length > 0) {
return errorResponse(400, {
code: 'VALIDATION_ERROR',
message: 'Invalid input data',
details: errors,
});
}
const user = await db.user.create({ data: body });
return NextResponse.json(user, { status: 201 });
} catch (error) {
if (error.code === 'P2002') {
return errorResponse(409, {
code: 'DUPLICATE_EMAIL',
message: 'Email already exists',
});
}
return errorResponse(500, {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
});
}
}
Bad:
// ❌ Inconsistent error formats
export async function POST(request: Request) {
try {
const body = await request.json();
if (!body.email) {
return NextResponse.json('Email required'); // Plain string
}
const user = await db.user.create({ data: body });
return NextResponse.json(user);
} catch (error) {
return NextResponse.json({
message: error.message, // Inconsistent format
stack: error.stack, // Leaks implementation details
});
}
}
Why: Consistent error format makes client error handling predictable. Never expose stack traces or internal details in production.
GraphQL Critical Patterns
Pattern 1: Schema Design with Types and Relationships
Good:
type User {
id: ID!
name: String!
email: String!
role: UserRole!
posts: [Post!]!
createdAt: DateTime!
}
enum UserRole {
ADMIN
USER
GUEST
}
type Post {
id: ID!
title: String!
content: String!
author: User!
published: Boolean!
}
type Query {
user(id: ID!): User
users(limit: Int, cursor: String): UserConnection!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
}
input CreateUserInput {
name: String!
email: String!
role: UserRole
}
Why: Clear types, non-null fields (the ! suffix), and input types make the API self-documenting and type-safe.
Pattern 2: Solving N+1 Queries with DataLoader
Problem:
// ❌ N+1 queries: 1 for users + N for posts
const resolvers = {
User: {
posts: async (parent, _args, context) => {
return context.db.post.findMany({
where: { authorId: parent.id },
});
},
},
};
// Querying 100 users = 101 database queries!
Solution:
// ✅ DataLoader batches queries
import DataLoader from 'dataloader';
const postLoader = new DataLoader(async (userIds: readonly string[]) => {
const posts = await db.post.findMany({
where: { authorId: { in: [...userIds] } },
});
const postsByUserId = userIds.map(userId =>
posts.filter(post => post.authorId === userId)
);
return postsByUserId;
});
const resolvers = {
User: {
posts: (parent, _args, context) => {
return context.loaders.post.load(parent.id);
},
},
};
// Querying 100 users = 2 queries (users + batched posts)!
Why: DataLoader batches and caches database queries, solving the N+1 problem and dramatically improving performance.
Anti-Patterns
❌ Anti-Pattern 1: Exposing Database Structure Directly
Don't do this:
// ❌ API mirrors database exactly
GET /api/users
Response: {
id: 123,
password_hash: "bcrypt...", // Exposing sensitive data!
created_at: "2024-01-15",
internal_notes: "VIP customer"
}
Do this instead:
// ✅ API has its own contract
GET /api/users/123
Response: {
id: 123,
name: "Alice",
email: "alice@example.com",
role: "admin",
joinedAt: "2024-01-15T10:00:00Z"
}
// Server-side: Transform before sending
export async function GET(request: Request, { params }) {
const user = await db.user.findUnique({ where: { id: params.id } });
return NextResponse.json({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
joinedAt: user.createdAt,
});
}
❌ Anti-Pattern 2: No Input Validation
Don't do this:
// ❌ Trusting all input
export async function POST(request: Request) {
const body = await request.json();
const user = await db.user.create({ data: body });
return NextResponse.json(user);
}
Do this instead:
// ✅ Validate all input
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(18).max(120),
});
export async function POST(request: Request) {
const body = await request.json();
const result = userSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: result.error.format() },
{ status: 400 }
);
}
const user = await db.user.create({ data: result.data });
return NextResponse.json(user, { status: 201 });
}
❌ Anti-Pattern 3: Ignoring Authentication
Don't do this:
// ❌ No auth checks
export async function DELETE(request: Request) {
const { id } = await request.json();
await db.user.delete({ where: { id } });
return NextResponse.json({ success: true });
}
Do this instead:
// ✅ Check authentication and authorization
export async function DELETE(request: Request) {
const session = await getSession(request);
if (!session) {
return new Response('Unauthorized', { status: 401 });
}
if (session.role !== 'ADMIN') {
return new Response('Forbidden', { status: 403 });
}
const { id } = await request.json();
await db.user.delete({ where: { id } });
return new Response(null, { status: 204 });
}
Code Examples
Example 1: RESTful CRUD Endpoint
// app/api/users/[id]/route.ts
export async function GET(request: Request, { params }: { params: { id: string } }) {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) {
return new Response('User not found', { status: 404 });
}
return NextResponse.json(user);
}
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
const body = await request.json();
const user = await db.user.update({
where: { id: params.id },
data: body,
});
return NextResponse.json(user);
}
Example 2: GraphQL Resolver with DataLoader
const resolvers = {
Query: {
users: async (_parent, { limit = 20, cursor }, context) => {
const users = await context.db.user.findMany({
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
const hasMore = users.length > limit;
const data = hasMore ? users.slice(0, -1) : users;
return {
edges: data.map(user => ({ node: user, cursor: user.id })),
pageInfo: {
hasNextPage: hasMore,
endCursor: hasMore ? data[data.length - 1].id : null,
},
};
},
},
User: {
posts: (parent, _args, context) => {
return context.loaders.posts.load(parent.id);
},
},
};
For comprehensive examples and detailed implementations, see the references/ folder.
Quick Reference
REST Checklist
- Use appropriate HTTP methods (GET, POST, PUT, PATCH, DELETE)
- Return correct status codes (2xx, 4xx, 5xx)
- Use nouns for resources, not verbs
- Implement pagination for collections
- Version API for breaking changes
- Validate all input
- Return consistent error format
- Add authentication/authorization checks
GraphQL Checklist
- Design clear schema with types and relationships
- Use DataLoader to prevent N+1 queries
- Validate inputs with Zod or similar
- Return meaningful error codes in extensions
- Implement authentication via context
- Use connection pattern for pagination
- Keep mutations simple and focused
Progressive Disclosure
For detailed implementations, see:
- REST Patterns - Pagination, filtering, versioning, rate limiting, HATEOAS
- GraphQL Design - Resolvers, DataLoader, subscriptions, directives, input validation
References
Maintained by dsmj-ai-toolkit