Memstack memstack-development-api-designer
Use this skill when the user says 'design API', 'API endpoints', 'REST API', 'API designer', 'route structure', 'API architecture', or is designing RESTful API routes, request/response schemas, and endpoint organization. Do NOT use for API security audits or database design.
git clone https://github.com/cwinvestments/memstack
T=$(mktemp -d) && git clone --depth=1 https://github.com/cwinvestments/memstack "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/api-designer" ~/.claude/skills/cwinvestments-memstack-memstack-development-api-designer && rm -rf "$T"
skills/development/api-designer/SKILL.md🔌 API Designer — Designing routes, validation, and handler patterns...
Produces production-ready Next.js App Router API routes with auth guards, Zod validation, typed responses, and consistent error handling.
Activation
When this skill activates, output:
🔌 API Designer — Designing API routes and handlers...
Then execute the protocol below.
| Context | Status |
|---|---|
| User says "design API" or "create API route" or "add endpoint" | ACTIVE |
| User says "REST API" or "route handler" | ACTIVE |
| Building a new feature that needs API routes | ACTIVE |
| Designing webhook endpoints | ACTIVE |
| Discussing API concepts or REST theory | DORMANT |
| Working on frontend components (not API layer) | DORMANT |
Anti-patterns
| Trap | Reality Check |
|---|---|
| "Auth is handled by middleware" | Middleware can be bypassed. Every route handler must verify auth independently. |
| "I'll validate input later" | Unvalidated input is the root of injection, type errors, and 500s. Zod first, logic second. |
| "Return 200 for everything" | Status codes are the API contract. 401 vs 403 vs 404 vs 422 mean different things to clients. |
| "Error details help debugging" | Stack traces and internal errors help attackers. Return safe messages, log details server-side. |
| "Rate limiting is a nice-to-have" | Public endpoints without rate limits get abused within hours of deployment. |
| "TypeScript types are documentation enough" | Types exist at build time. Zod schemas validate at runtime. You need both. |
Protocol
Step 1: Design Route Structure
Map features to Next.js App Router file paths:
app/ api/ auth/ login/route.ts POST - authenticate user logout/route.ts POST - clear session callback/route.ts GET - OAuth callback organizations/ route.ts GET - list user's orgs POST - create org [orgId]/ route.ts GET - get org details PATCH - update org DELETE - delete org members/ route.ts GET - list members POST - invite member [memberId]/ route.ts PATCH - update role DELETE - remove member projects/ route.ts GET - list projects POST - create project webhooks/ stripe/route.ts POST - Stripe webhook github/route.ts POST - GitHub webhook
Naming conventions:
| Convention | Rule | Example |
|---|---|---|
| Resource names | Plural nouns | , |
| Dynamic segments | camelCase | , |
| Nested resources | Parent/child path | |
| Actions (non-CRUD) | Verb sub-path | , |
| Webhooks | | |
Output route table:
Method Path Auth Description GET /api/organizations ✅ List user's organizations POST /api/organizations ✅ Create organization GET /api/organizations/[orgId] ✅+org Get organization details PATCH /api/organizations/[orgId] ✅+org Update organization ... POST /api/webhooks/stripe 🔑sig Handle Stripe webhook
Step 2: Auth Guard Pattern
Every protected route starts with the same two-step auth chain:
import { getAuthContext } from '@/lib/auth'; import { verifyOrgAccess } from '@/lib/auth'; import { NextRequest, NextResponse } from 'next/server'; export async function GET( req: NextRequest, { params }: { params: { orgId: string } } ) { // Step 1: Authenticate — who is this user? const auth = await getAuthContext(req); if (!auth) { return NextResponse.json( { error: 'Authentication required' }, { status: 401 } ); } // Step 2: Authorize — can they access this org? const access = await verifyOrgAccess(auth.userId, params.orgId); if (!access) { return NextResponse.json( { error: 'Access denied' }, { status: 403 } ); } // Now proceed with business logic... }
Auth decision matrix:
| Route Type | Auth Required | Org Check | Example |
|---|---|---|---|
| Public | ❌ | ❌ | |
| Authenticated | ✅ | ❌ | |
| Org-scoped | ✅ | ✅ | |
| Admin-only | ✅ | ✅ + role check | |
| Webhook | 🔑 Signature | ❌ | |
Step 3: Input Validation with Zod
Every route that accepts input validates it before any logic runs:
import { z } from 'zod'; // Define schema next to the route handler const createProjectSchema = z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), status: z.enum(['draft', 'active', 'archived']).default('draft'), settings: z.object({ isPublic: z.boolean().default(false), tags: z.array(z.string().max(50)).max(10).default([]), }).optional(), }); type CreateProjectInput = z.infer<typeof createProjectSchema>; export async function POST(req: NextRequest) { // ... auth checks ... // Validate input const body = await req.json(); const parsed = createProjectSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: 'Validation failed', details: parsed.error.flatten() }, { status: 422 } ); } // parsed.data is fully typed as CreateProjectInput const project = await createProject(parsed.data); return NextResponse.json({ data: project }, { status: 201 }); }
Zod patterns for common fields:
// Reusable schemas const paginationSchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), }); const sortSchema = z.object({ sortBy: z.string().default('created_at'), sortOrder: z.enum(['asc', 'desc']).default('desc'), }); const uuidParam = z.string().uuid('Invalid ID format'); // Query params validation (GET routes) export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const query = paginationSchema.merge(sortSchema).safeParse( Object.fromEntries(searchParams) ); // ... }
Step 4: Consistent Response Format
All responses follow a strict structure:
// Success responses — always wrap in { data } return NextResponse.json({ data: result }); return NextResponse.json({ data: results, meta: { total, page, limit } }); // Error responses — always wrap in { error } return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json( { error: 'Validation failed', details: zodError.flatten() }, { status: 422 } );
Type definitions for responses:
// types/api.ts export type ApiResponse<T> = { data: T; meta?: { total: number; page: number; limit: number; }; }; export type ApiError = { error: string; details?: unknown; }; // Helper function export function apiSuccess<T>(data: T, status = 200): NextResponse { return NextResponse.json({ data }, { status }); } export function apiError(error: string, status: number, details?: unknown): NextResponse { return NextResponse.json({ error, ...(details && { details }) }, { status }); }
Step 5: HTTP Status Codes
Use the correct status code for each situation:
| Code | Meaning | When to Use |
|---|---|---|
| OK | Successful GET, PATCH, or general success |
| Created | Successful POST that creates a resource |
| No Content | Successful DELETE (no response body) |
| Bad Request | Malformed request (invalid JSON, wrong Content-Type) |
| Unauthorized | Not authenticated (no token, expired session) |
| Forbidden | Authenticated but not authorized (wrong org, wrong role) |
| Not Found | Resource doesn't exist (or user can't see it — use 404 to avoid leaking existence) |
| Conflict | Duplicate resource (unique constraint violation) |
| Unprocessable Entity | Valid JSON but failed validation (Zod errors) |
| Too Many Requests | Rate limit exceeded |
| Internal Server Error | Unexpected server error (log details, return safe message) |
Key distinction — 401 vs 403 vs 404:
: "I don't know who you are" → redirect to login401
: "I know who you are, but you can't do this" → show permission error403
: "This doesn't exist (or you can't know it exists)" → use for privacy-preserving access denial404
Step 6: Rate Limiting
Protect public and sensitive endpoints:
import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(10, '60 s'), // 10 requests per minute analytics: true, }); // In route handler export async function POST(req: NextRequest) { const ip = req.headers.get('x-forwarded-for') ?? 'anonymous'; const { success, limit, remaining, reset } = await ratelimit.limit(ip); if (!success) { return NextResponse.json( { error: 'Rate limit exceeded. Try again later.' }, { status: 429, headers: { 'X-RateLimit-Limit': limit.toString(), 'X-RateLimit-Remaining': remaining.toString(), 'X-RateLimit-Reset': reset.toString(), }, } ); } // Continue with handler... }
Rate limit tiers:
| Endpoint Type | Limit | Window |
|---|---|---|
| Auth (login, register) | 5 requests | 15 minutes |
| Public API | 60 requests | 1 minute |
| Authenticated API | 120 requests | 1 minute |
| Webhooks | 1000 requests | 1 minute |
| File upload | 10 requests | 1 hour |
Step 7: Webhook Endpoints
Webhooks require signature verification instead of JWT auth:
import { headers } from 'next/headers'; import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); export async function POST(req: NextRequest) { const body = await req.text(); // Raw body for signature verification const signature = req.headers.get('stripe-signature'); if (!signature) { return NextResponse.json({ error: 'Missing signature' }, { status: 400 }); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (err) { console.error('Webhook signature verification failed:', err); return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); } // Process event by type switch (event.type) { case 'checkout.session.completed': await handleCheckoutComplete(event.data.object); break; case 'customer.subscription.updated': await handleSubscriptionUpdate(event.data.object); break; default: console.log(`Unhandled event type: ${event.type}`); } // Always return 200 quickly — process async if needed return NextResponse.json({ received: true }); }
Webhook rules:
- Always verify signatures before processing
- Return
quickly — do heavy processing async200 - Use
notreq.text()
— signature verification needs raw bodyreq.json() - Log unhandled event types (don't error on them — providers add new events)
- Implement idempotency — webhooks can be sent multiple times
Step 8: Generate TypeScript Interfaces
Produce type definitions that match the API contract:
// types/api/organizations.ts // Request types (match Zod schemas) export interface CreateOrganizationRequest { name: string; slug?: string; plan?: 'free' | 'starter' | 'professional' | 'enterprise'; } export interface UpdateOrganizationRequest { name?: string; settings?: Partial<OrganizationSettings>; } // Response types (match database + API transforms) export interface Organization { id: string; name: string; slug: string; plan: 'free' | 'starter' | 'professional' | 'enterprise'; createdAt: string; // ISO 8601 updatedAt: string; } export interface OrganizationWithMembers extends Organization { members: OrganizationMember[]; memberCount: number; } // List response with pagination export interface OrganizationListResponse { data: Organization[]; meta: { total: number; page: number; limit: number; }; }
Type generation rules:
- Request types match Zod schemas (single source of truth)
- Response types match database models + any API transforms (e.g.,
→snake_case
)camelCase - Use
for dates in API types (ISO 8601 format over the wire)string - Export all types from a barrel file:
types/api/index.ts
Step 9: Complete Route Handler Template
Full boilerplate for a standard CRUD route:
// app/api/organizations/[orgId]/projects/route.ts import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { getAuthContext, verifyOrgAccess } from '@/lib/auth'; import { apiSuccess, apiError } from '@/lib/api-response'; import { db } from '@/lib/db'; // --- Validation Schemas --- const createProjectSchema = z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), }); const listQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), status: z.enum(['draft', 'active', 'archived']).optional(), }); // --- GET /api/organizations/[orgId]/projects --- export async function GET( req: NextRequest, { params }: { params: { orgId: string } } ) { try { const auth = await getAuthContext(req); if (!auth) return apiError('Authentication required', 401); const access = await verifyOrgAccess(auth.userId, params.orgId); if (!access) return apiError('Access denied', 403); const query = listQuerySchema.safeParse( Object.fromEntries(new URL(req.url).searchParams) ); if (!query.success) return apiError('Invalid query', 422, query.error.flatten()); const { page, limit, status } = query.data; const offset = (page - 1) * limit; const [projects, total] = await Promise.all([ db.projects.list({ orgId: params.orgId, status, limit, offset }), db.projects.count({ orgId: params.orgId, status }), ]); return NextResponse.json({ data: projects, meta: { total, page, limit }, }); } catch (error) { console.error('GET /projects failed:', error); return apiError('Internal server error', 500); } } // --- POST /api/organizations/[orgId]/projects --- export async function POST( req: NextRequest, { params }: { params: { orgId: string } } ) { try { const auth = await getAuthContext(req); if (!auth) return apiError('Authentication required', 401); const access = await verifyOrgAccess(auth.userId, params.orgId); if (!access) return apiError('Access denied', 403); if (access.role === 'viewer') return apiError('Insufficient permissions', 403); const body = await req.json(); const parsed = createProjectSchema.safeParse(body); if (!parsed.success) return apiError('Validation failed', 422, parsed.error.flatten()); const project = await db.projects.create({ ...parsed.data, organizationId: params.orgId, createdBy: auth.userId, }); return apiSuccess(project, 201); } catch (error) { console.error('POST /projects failed:', error); return apiError('Internal server error', 500); } }
Output summary:
🔌 API Designer — Routes Complete Feature: [name] Routes: [count] endpoints across [count] resource groups Route Table: Method Path Auth Status GET /api/organizations ✅ List POST /api/organizations ✅ Create GET /api/organizations/[orgId] ✅+O Detail ... Files to create: - app/api/organizations/route.ts - app/api/organizations/[orgId]/route.ts - app/api/organizations/[orgId]/projects/route.ts - lib/validations/organizations.ts (Zod schemas) - types/api/organizations.ts (TypeScript interfaces) Zod schemas: [count] request validators Type definitions: [count] interfaces Webhooks: [count] with signature verification Rate-limited routes: [count]
Level History
- Lv.1 — Base: Route structure design, auth guard chain (getAuthContext + verifyOrgAccess), Zod validation, consistent response format, HTTP status codes, rate limiting, webhook signature verification, TypeScript interfaces, full CRUD handler template. Based on AdminStack API patterns. (Origin: MemStack Pro v3.2, Mar 2026)