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
-
REST Patterns Reference
-
GraphQL Design Reference
-
GraphQL Documentation
-
REST API Tutorial
Maintained by dsmj-ai-toolkit