@jjdenhertog/ai-driven-development
Version:
AI-driven development workflow with learning capabilities for Claude
856 lines (715 loc) • 24.3 kB
Markdown
---
name: "API Design Standards"
category: "backend"
version: "1.0.0"
dependencies: ["technology-stack", "folder-structure", "testing"]
tags: ["api", "rest", "nextjs", "routes", "validation", "error-handling"]
priority: "critical"
ai-instructions:
- "ALWAYS use App Router pattern (/app/api/) for API routes"
- "NEVER use next-connect - use native Next.js route handlers"
- "MUST validate all inputs with Zod schemas before processing"
- "MUST use centralized error handling with handleError function"
- "ALWAYS return consistent error format: { error: string }"
- "PREFER direct JSON responses without unnecessary wrappers"
---
# API Design Standards
This document defines the API design patterns and conventions used across our Next.js applications. These standards align with our technology stack and emphasize native Next.js patterns.
<ai-context>
This preference file defines how to structure and implement API routes in Next.js App Router.
All API routes must follow these patterns for consistency and maintainability.
</ai-context>
## Framework & Router Configuration
<ai-rules category="routing">
<rule id="api-location" priority="critical">
<condition>Creating any API route</condition>
<action>Place in /app/api/ directory with route.ts filename</action>
<validation>Path matches: ^/app/api/.*/route\.ts$</validation>
<example>/app/api/users/route.ts</example>
</rule>
<rule id="router-pattern" priority="critical">
<condition>Implementing route handlers</condition>
<action>Export named functions (GET, POST, PUT, DELETE)</action>
<validation>No default export, only named HTTP method exports</validation>
</rule>
<rule id="no-next-connect" priority="critical">
<condition>Setting up API routes</condition>
<action>Use native Next.js handlers, NOT next-connect</action>
<validation>No import from 'next-connect'</validation>
</rule>
</ai-rules>
### Next.js App Router API Routes
- **Location**: `/app/api/` (App Router pattern)
- **Router**: Native Next.js route handlers (NO next-connect)
- **Error Handling**: Custom error handler function with consistent format
### Basic Route Template
<code-template id="api-route-basic">
<description>Basic API route with authentication and error handling</description>
<use-when>Creating any authenticated API endpoint</use-when>
<variables>
<var name="RESOURCE_NAME" type="string" example="user" />
<var name="RESOURCE_TYPE" type="string" example="User" />
<var name="SCHEMA_NAME" type="string" example="userSchema" />
</variables>
<imports>
<import>import { NextRequest, NextResponse } from 'next/server';</import>
<import>import { getServerSession } from 'next-auth';</import>
<import>import { authOptions } from '@/lib/auth';</import>
<import>import { prisma } from '@/lib/prisma';</import>
<import>import { z } from 'zod';</import>
<import>import { ${SCHEMA_NAME} } from '@/schemas/${RESOURCE_NAME}';</import>
</imports>
</code-template>
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
// Custom middleware helper
async function withAuth(
request: NextRequest,
handler: (req: NextRequest, session: any) => Promise<NextResponse>
) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
return handler(request, session);
}
// Custom error handler
function handleError(error: unknown): NextResponse {
console.error('API Error:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.errors },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json(
{ error: error.message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
// GET /api/resources
export async function GET(request: NextRequest) {
return withAuth(request, async (req, session) => {
try {
const resources = await prisma.resource.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' }
});
return NextResponse.json(resources);
} catch (error) {
return handleError(error);
}
});
}
// POST /api/resources
export async function POST(request: NextRequest) {
return withAuth(request, async (req, session) => {
try {
const body = await req.json();
// ALWAYS validate with Zod
const validatedData = resourceSchema.parse(body);
const resource = await prisma.resource.create({
data: {
...validatedData,
userId: session.user.id
}
});
// Clear cache after mutations
await invalidateResourceCache();
return NextResponse.json(resource, { status: 201 });
} catch (error) {
return handleError(error);
}
});
}
// PUT /api/resources
export async function PUT(request: NextRequest) {
return withAuth(request, async (req, session) => {
try {
const body = await req.json();
const { id, ...data } = body;
const validatedData = updateResourceSchema.parse(data);
const resource = await prisma.resource.update({
where: { id, userId: session.user.id },
data: validatedData
});
await invalidateResourceCache();
return NextResponse.json(resource);
} catch (error) {
return handleError(error);
}
});
}
// DELETE /api/resources
export async function DELETE(request: NextRequest) {
return withAuth(request, async (req, session) => {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
if (!id) {
throw new Error('Resource ID required');
}
await prisma.resource.delete({
where: { id, userId: session.user.id }
});
await invalidateResourceCache();
return NextResponse.json({ deleted: true });
} catch (error) {
return handleError(error);
}
});
}
```
## Route Naming Conventions
### URL Structure
- **RESTful resource-based paths**: `/api/users`, `/api/products`, `/api/orders`
- **Nested resources**: `/api/users/[userId]/orders/[orderId]/items`
- **Dynamic segments**: Use folders with square brackets `[param]/route.ts`
- **Special routes**:
- `/api/auth/[...nextauth]/route.ts` for NextAuth
- `/api/webhooks/*` for external webhooks
- `/api/public/*` for unauthenticated endpoints
### Naming Rules
- **Collections**: Plural nouns (`users`, `products`, `orders`)
- **Single resources**: Accessed via dynamic route segments
- **Actions**: Avoid action-based URLs; use HTTP methods instead
- **Case**: Lowercase with hyphens for multi-word resources
### File Organization
```
/app/api/
├── users/
│ ├── route.ts # GET all, POST new
│ └── [userId]/
│ ├── route.ts # GET one, PUT, DELETE
│ └── orders/
│ └── route.ts # Nested resource operations
├── auth/
│ └── [...nextauth]/
│ └── route.ts # NextAuth handler
└── webhooks/
└── stripe/
└── route.ts # Webhook handlers
```
## Request/Response Standards
<ai-rules category="response-format">
<rule id="direct-json" priority="high">
<condition>Returning successful response data</condition>
<action>Return data directly without success wrapper</action>
<validation>Response does not contain { success: true, data: ... }</validation>
<good>return NextResponse.json(user);</good>
<bad>return NextResponse.json({ success: true, data: user });</bad>
</rule>
<rule id="error-format" priority="critical">
<condition>Returning error response</condition>
<action>Use consistent { error: string } format</action>
<validation>All errors have 'error' property with string message</validation>
<example>{ error: "User not found" }</example>
</rule>
</ai-rules>
### Response Format
Responses should be direct JSON without unnecessary wrappers:
```typescript
// ✓ Good - Direct data response
return NextResponse.json(user);
return NextResponse.json({ users, total });
// ✗ Avoid - Unnecessary wrapping
return NextResponse.json({ success: true, data: user });
```
### Success Responses
```typescript
// GET - Return resource directly
return NextResponse.json(resource);
// POST - Return created resource with 201 status
return NextResponse.json(newResource, { status: 201 });
// PUT - Return updated resource
return NextResponse.json(updatedResource);
// DELETE - Simple confirmation
return NextResponse.json({ deleted: true });
// OR just status
return new NextResponse(null, { status: 204 });
// Operations with side effects
return NextResponse.json({ ok: true });
```
### Request Validation with Zod
<ai-rules category="validation">
<rule id="zod-validation" priority="critical">
<condition>Processing request body or query params</condition>
<action>ALWAYS validate with Zod schema before use</action>
<validation>Schema.parse() called before data usage</validation>
</rule>
<rule id="schema-location" priority="high">
<condition>Creating validation schemas</condition>
<action>Place in /lib/schemas/ or /schemas/ directory</action>
<validation>Import path includes 'schemas'</validation>
</rule>
</ai-rules>
<code-template id="zod-schema-basic">
<description>Basic Zod schema for request validation</description>
<variables>
<var name="MODEL_NAME" type="string" example="User" />
<var name="FIELD_NAME" type="string" example="email" />
</variables>
<template>
const create${MODEL_NAME}Schema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Invalid email format'),
role: z.enum(['admin', 'user', 'guest']),
${FIELD_NAME}: z.string().optional()
});
export type Create${MODEL_NAME}Input = z.infer<typeof create${MODEL_NAME}Schema>;
</template>
</code-template>
```typescript
// Define schema with Zod
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']),
age: z.number().min(18).optional()
});
// Validate in route handler
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validatedData = createUserSchema.parse(body);
// Use validatedData safely
const user = await prisma.user.create({
data: validatedData
});
return NextResponse.json(user, { status: 201 });
} catch (error) {
return handleError(error);
}
}
```
## Error Response Standards
### Error Response Format
Always return errors with consistent structure:
```typescript
{
error: "Error message string"
}
// With validation details
{
error: "Validation failed",
details: [
{
path: ["email"],
message: "Invalid email format"
}
]
}
```
### Centralized Error Handler with Sentry
```typescript
// utils/api/handleError.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
import * as Sentry from '@sentry/nextjs';
export function handleError(error: unknown): NextResponse {
console.error('API Error:', error);
// Add Sentry context
Sentry.withScope((scope) => {
scope.setLevel('error');
scope.setContext('error_details', {
type: error?.constructor?.name,
message: error instanceof Error ? error.message : 'Unknown error',
});
Sentry.captureException(error);
});
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Validation failed',
details: error.errors
},
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json(
{ error: error.message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
```
### Status Codes
- **200**: Success (GET, PUT)
- **201**: Created (POST)
- **204**: No Content (DELETE)
- **400**: Client errors (validation, bad request)
- **401**: Unauthenticated
- **403**: Unauthorized (authenticated but no permission)
- **404**: Resource not found
- **500**: Server errors (avoid exposing details)
## Type Safety Standards
### Response Type Definitions
Export response types for use with React Query:
```typescript
// app/api/users/types.ts
import { User, Prisma } from '@prisma/client';
export type GetUsersResponse = User[];
export type GetUserResponse = Prisma.UserGetPayload<{
include: {
profile: true,
posts: {
select: {
id: true,
title: true,
published: true
}
}
}
}>;
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
```
### Type Organization
```
/app/api/ # API routes
/lib/schemas/ # Zod schemas
/lib/types/ # Shared types
/prisma/ # Prisma schema and types
```
### Using Types in Client Components
```typescript
'use client';
import { useQuery } from '@tanstack/react-query';
import type { GetUsersResponse } from '@/app/api/users/types';
export function UsersList() {
const { data: users } = useQuery<GetUsersResponse>({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
}
});
// TypeScript knows users is User[] | undefined
}
```
## Authentication & Authorization
### NextAuth.js Integration
```typescript
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
// Protect routes with middleware
async function withAuth(
request: NextRequest,
handler: (req: NextRequest, session: any) => Promise<NextResponse>
) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
return handler(request, session);
}
// Role-based authorization
async function withRole(
request: NextRequest,
requiredRole: string,
handler: (req: NextRequest, session: any) => Promise<NextResponse>
) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
if (session.user.role !== requiredRole) {
return NextResponse.json(
{ error: 'Forbidden' },
{ status: 403 }
);
}
return handler(request, session);
}
```
## Pagination Standards
### Page-Based Pagination
```typescript
// API implementation
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = Number(searchParams.get('page') || '0');
const pageSize = Number(searchParams.get('pageSize') || '20');
const [items, total] = await Promise.all([
prisma.resource.findMany({
take: pageSize,
skip: page * pageSize,
orderBy: { createdAt: 'desc' }
}),
prisma.resource.count()
]);
return NextResponse.json({
items,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize)
}
});
}
```
## File Upload Patterns
### Multipart Form Handling
```typescript
// app/api/upload/route.ts
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
throw new Error('No file provided');
}
// Validate file
if (file.size > 5 * 1024 * 1024) { // 5MB limit
throw new Error('File too large');
}
// Convert to buffer
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Upload to S3 or save locally
const url = await uploadToS3(buffer, file.name);
return NextResponse.json({
filename: file.name,
url,
size: file.size
});
} catch (error) {
return handleError(error);
}
}
```
## Performance Patterns
### Redis Caching with Prisma
```typescript
// lib/cache.ts
import { redis } from '@/lib/redis';
export async function getCachedData<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 300 // 5 minutes default
): Promise<T> {
// Try cache first
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// Fetch fresh data
const data = await fetcher();
// Cache it
await redis.setex(key, ttl, JSON.stringify(data));
return data;
}
// Usage in API route
export async function GET(request: NextRequest) {
try {
const users = await getCachedData(
'users:all',
() => prisma.user.findMany({
where: { active: true },
orderBy: { createdAt: 'desc' }
}),
600 // 10 minutes
);
return NextResponse.json(users);
} catch (error) {
return handleError(error);
}
}
// Cache invalidation
export async function invalidateUserCache() {
const keys = await redis.keys('users:*');
if (keys.length > 0) {
await redis.del(...keys);
}
}
```
### Rate Limiting
```typescript
// middleware/rateLimit.ts
export async function rateLimit(
identifier: string,
limit: number = 10,
window: number = 60
): Promise<{ success: boolean; remaining: number }> {
const key = `rate_limit:${identifier}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, window);
}
return {
success: current <= limit,
remaining: Math.max(0, limit - current)
};
}
// Usage in API route
export async function POST(request: NextRequest) {
const ip = request.ip || 'unknown';
const { success, remaining } = await rateLimit(`api:users:${ip}`, 5, 60);
if (!success) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{
status: 429,
headers: {
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': new Date(Date.now() + 60000).toISOString()
}
}
);
}
// Continue with request...
}
```
## Middleware Patterns
### Reusable Middleware Functions
```typescript
// lib/api/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { z } from 'zod';
export const middleware = {
// Authentication check
auth: async (request: NextRequest) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error('Unauthorized');
}
return session;
},
// Rate limiting
rateLimit: async (request: NextRequest, key: string) => {
const count = await redis.incr(key);
await redis.expire(key, 60); // 1 minute window
if (count > 100) { // 100 requests per minute
throw new Error('Rate limit exceeded');
}
},
// Validation
validate: <T>(schema: z.ZodSchema<T>, data: unknown): T => {
return schema.parse(data);
},
// CORS
cors: (request: NextRequest, response: NextResponse) => {
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
}
};
```
## Environment Variables
### Naming Convention
- **Client-visible**: `NEXT_PUBLIC_*`
- **Server-only**: Standard names without prefix
- **Required variables**:
```env
# Authentication
NEXTAUTH_URL=
NEXTAUTH_SECRET=
# Database
DATABASE_URL=
# Redis
REDIS_URL=
# External Services
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_S3_BUCKET=
# Sentry
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
# OAuth Providers
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
```
## Migration Notes
When migrating from `next-connect` to native Next.js handlers:
1. **Replace router pattern**: Use individual export functions (GET, POST, etc.)
2. **Update middleware**: Create custom wrapper functions instead of `.use()`
3. **Error handling**: Use try/catch with centralized error handler
4. **Request parsing**: Use `request.json()` instead of `req.body`
5. **Query params**: Use `new URL(request.url).searchParams`
6. **Response format**: Use `NextResponse.json()` instead of `res.json()`
## Examples Repository
Reference implementations:
- **Basic CRUD**: `/app/api/users/route.ts`
- **Nested resources**: `/app/api/users/[userId]/orders/route.ts`
- **File uploads**: `/app/api/upload/route.ts`
- **Webhooks**: `/app/api/webhooks/stripe/route.ts`
- **Public endpoints**: `/app/api/public/health/route.ts`
<validation-schema for="api-route">
<check id="file-location">
<pattern>^/app/api/.*/route\.ts$</pattern>
<message>API routes must be in /app/api/ with route.ts filename</message>
</check>
<check id="exports">
<pattern>export (async )?function (GET|POST|PUT|DELETE|PATCH)</pattern>
<message>Must export named HTTP method functions</message>
</check>
<check id="imports">
<required>NextRequest, NextResponse from 'next/server'</required>
<forbidden>next-connect</forbidden>
</check>
<check id="validation">
<pattern>\.parse\(|\.safeParse\(</pattern>
<message>Must use Zod validation for request data</message>
</check>
<check id="error-handling">
<pattern>handleError|catch</pattern>
<message>Must handle errors with try/catch or error handler</message>
</check>
</validation-schema>
<ai-decision-tree id="api-implementation">
<question>Does the endpoint need authentication?</question>
<yes>
<question>Does it need role-based access?</question>
<yes>
<answer>Use withRole middleware</answer>
<template>api-route-with-role</template>
</yes>
<no>
<answer>Use withAuth middleware</answer>
<template>api-route-basic</template>
</no>
</yes>
<no>
<question>Is it a webhook endpoint?</question>
<yes>
<answer>Place in /app/api/webhooks/</answer>
<template>webhook-route</template>
</yes>
<no>
<answer>Place in /app/api/public/</answer>
<template>public-route</template>
</no>
</no>
</ai-decision-tree>