API Standards
All API routes follow a three-tier security model enforced by directory structure. This page covers route patterns, security classification, error handling, and middleware.
The Three Tiers
/api/internal/*
Service-to-service communication only. Requires the SUPABASE_SERVICE_ROLE_KEY. Never exposed to browsers. Used for webhooks, cron jobs, and admin operations.
// Access control Header: x-service-key = process.env.SUPABASE_SERVICE_ROLE_KEY // Rate limit: None (trusted internal traffic) // Auth: Service role key validation
/api/v1/*
Standard authenticated endpoints. Requires a valid user session via Supabase Auth. Per-user rate limiting prevents abuse.
// Access control Auth: Supabase session (JWT in cookie) // Rate limit: 100 requests/minute per user // RLS: Row-Level Security enforced via user context
/api/public/*
Unauthenticated endpoints for public data. Aggressive rate limiting and careful data exposure. Think: public profile pages, health checks, webhooks from external services.
// Access control Auth: None required // Rate limit: 10 requests/minute per IP // Data: Only explicitly public fields — no PII
Directory Structure
The tier is enforced by where the route file lives:
src/app/api/
├── internal/ # 🔴 Service role key required
│ ├── webhooks/
│ └── cron/
├── v1/ # 🔵 Authenticated user session required
│ ├── users/
│ ├── invoices/
│ └── settings/
└── public/ # 🟢 No auth required, aggressive rate limiting
├── health/
└── profiles/Route Handler Pattern
// src/app/api/v1/invoices/route.ts
import { NextRequest } from 'next/server';
import { createClient } from '@frequencyads/auth/server';
import { apiSuccess, apiError } from '@/lib/api';
import { invoiceSchema } from '@/features/invoices';
export async function GET(req: NextRequest) {
try {
const supabase = await createClient();
const { data, error } = await supabase
.from('invoices')
.select('*')
.order('created_at', { ascending: false });
if (error) return apiError('FETCH_FAILED', error.message, 500);
return apiSuccess(data);
} catch (err) {
return apiError('INTERNAL_ERROR', 'An unexpected error occurred', 500);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const parsed = invoiceSchema.safeParse(body);
if (!parsed.success) {
return apiError('VALIDATION_ERROR', parsed.error.message, 400);
}
const supabase = await createClient();
const { data, error } = await supabase
.from('invoices')
.insert(parsed.data)
.select()
.single();
if (error) return apiError('CREATE_FAILED', error.message, 500);
return apiSuccess(data, 201);
} catch (err) {
return apiError('INTERNAL_ERROR', 'An unexpected error occurred', 500);
}
}Error Handling
The Result Type
For expected failures, use the Result type instead of throwing:
import { AppError, Result, ok, err } from '@/lib/errors';
async function fetchUser(id: string): Promise<Result<User>> {
try {
const supabase = await createClient();
const { data, error } = await supabase
.from('users')
.select('*')
.eq('id', id)
.single();
if (error) return err(new AppError('NOT_FOUND', 'User not found'));
return ok(data);
} catch (e) {
return err(new AppError('INTERNAL_ERROR', 'Unexpected failure'));
}
}
// Usage
const result = await fetchUser(id);
if (!result.ok) {
return apiError(result.error.code, result.error.message);
}
return apiSuccess(result.data);Custom Error Classes
Extend AppError for domain-specific errors:
type BillingErrorCode = 'PAYMENT_FAILED' | 'CARD_EXPIRED' | 'INSUFFICIENT_FUNDS';
class BillingError extends AppError<BillingErrorCode> {
constructor(code: BillingErrorCode, message: string) {
super(code, message);
}
}API Response Format
All API responses follow a consistent envelope:
// Success
{
"ok": true,
"data": { ... }
}
// Error
{
"ok": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Email is required"
}
}OpenAPI Documentation
All APIs are documented with OpenAPI/Swagger, viewable at /docs/api:
- Define your schema using Zod
- Register the endpoint with the OpenAPI registry
- Implement the handler with proper types
- Documentation auto-generates from the registry
Middleware Stack
Every request passes through these middleware layers in order:
| Order | Middleware | Purpose |
|---|---|---|
| 1 | checkMaintenance() | Return 503 if maintenance mode enabled |
| 2 | addRequestId() | Attach UUID for request tracing |
| 3 | Session refresh | Refresh Supabase auth tokens |
| 4 | Route protection | Redirect unauthenticated users |
| 5 | addSecurityHeaders() | CSP, HSTS, X-Frame-Options |
| 6 | rateLimit() | Per-tier rate limiting |
Available Middleware Utilities
import {
rateLimit,
addSecurityHeaders,
addRequestId,
checkMaintenance,
cors,
logRequest,
} from '@/lib/middleware';Best Practices
- Validate all input with Zod before processing
- Use
apiSuccess()andapiError()— never construct raw responses - Log errors with context — include request ID, user ID, and operation name
- Never expose internal errors to clients — wrap in generic messages
- Use the Result type for expected failures — reserve
throwfor truly unexpected errors - Keep routes thin — business logic lives in feature modules, not route handlers