CentercodePorygon
DittoPorygonAPI

API Architecture

REST patterns, versioning, and response envelopes

SafeFastReliable

Overview

All data flows through versioned REST endpoints under /api/v1/. Routes export a spec object that drives OpenAPI documentation (/admin/api-docs) and automatic input validation.

What it is

Next.js App Router API routes with standardized patterns for request handling, response formatting, and error management.

Why we use it

Consistency across all endpoints, type safety, predictable error handling, and clear separation of concerns.

When to use

Any server-side data operation that needs to be accessible from client components or external systems.

Key Features

  • Spec-based validation - define once, validate automatically
  • OpenAPI docs auto-generated from spec schemas
  • Consistent response envelope pattern
  • withApiLogging + withAuth HOF composition pattern

Quick Start

Minimal API Route

A complete GET endpoint with validation and error handling.

src/app/api/v1/items/route.ts
// src/app/api/v1/items/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withApiLogging } from '@/lib/api-logging';
import { withAuth } from '@/lib/api-auth';
import type { RouteSpec } from '@/lib/api-docs';

// 1. Define spec for OpenAPI docs + auto-validation
export const spec = {
  GET: {
    summary: 'List items',
    tags: ['Items'],
    queryParams: z.object({
      limit: z.coerce.number().min(1).max(100).default(20),
    }),
    response: z.array(itemSchema),
    requiresAuth: true,
  },
} satisfies RouteSpec;

// 2. Handler receives pre-validated query/body
export const GET = withApiLogging(
  withAuth({ scope: 'project' }, spec.GET, async (_request, { query }) => {
    const { limit } = query; // Already validated!
    const items = await getItems(limit);
    return NextResponse.json({ ok: true, data: items });
  })
);

Tip: See working examples at src/app/api/v1/programs/route.ts or src/app/api/v1/files/route.ts

Patterns

Response Envelope

All API responses use this format for consistency.

// Success response
{ ok: true, data: T }

// Error response
{
  ok: false,
  error: {
    code: 'VALIDATION_ERROR',
    message: 'Email is required'
  }
}

POST Route with Validation

Creating resources with input validation.

// POST with body validation via spec
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withApiLogging } from '@/lib/api-logging';
import { withAuth } from '@/lib/api-auth';
import { logger } from '@/lib/logger';
import type { RouteSpec } from '@/lib/api-docs';

export const spec = {
  POST: {
    summary: 'Create item',
    tags: ['Items'],
    requestBody: z.object({
      name: z.string().min(1).max(100),
      description: z.string().optional(),
    }),
    response: itemSchema,
    requiresAuth: true,
  },
} satisfies RouteSpec;

export const POST = withApiLogging(
  withAuth({ scope: 'project' }, spec.POST, async (_request, { body }) => {
    const { name, description } = body; // Pre-validated!
    const item = await createItem({ name, description });

    logger.info('Item created', { itemId: item.id });

    return NextResponse.json({ ok: true, data: item }, { status: 201 });
  })
);

Middleware Composition

Combining auth, logging, and rate limiting.

// Composing middleware with spec-based validation
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withApiLogging } from '@/lib/api-logging';
import { withAuth } from '@/lib/api-auth';
import type { RouteSpec } from '@/lib/api-docs';

// withApiLogging: Request logging, rate limiting, RLS context
// withAuth: Authentication + authorization + auto-validation

export const spec = {
  GET: {
    summary: 'Get project items',
    tags: ['Items'],
    queryParams: z.object({ search: z.string().optional() }),
    response: z.array(itemSchema),
    requiresAuth: true,
  },
} satisfies RouteSpec;

export const GET = withApiLogging(
  withAuth(
    { scope: 'project', requiredAccess: 'read' },
    spec.GET, // Pass spec for auto-validation
    async (_request, { auth, actor, projectId, query }) => {
      // auth: { userId, apiKey, type: 'session' | 'api_key' }
      // actor: for audit logging
      // projectId: extracted from route params
      // query: pre-validated via spec.GET.queryParams

      return NextResponse.json({ ok: true, data: {} });
    }
  )
);

Watch Out

Returning raw data without envelope wrapper

Don't

// Returning raw data
return NextResponse.json(items);

Do

// Using response envelope
return NextResponse.json({
  ok: true,
  data: items
});

Trusting request body without Zod validation

Don't

// Trusting request body without spec
const { email } = await request.json();
await createUser({ email }); // No validation!

Do

// Let spec handle validation automatically
withAuth({ scope: 'user' }, spec.POST, async (_req, { body }) => {
  const { email } = body; // Pre-validated via spec!
});
  • Using different error formats across routes
  • Missing request logging for debugging

Related

Validation

Zod schemas for input validation

Error Handling

Typed error classes

Authentication

Authentication patterns

External Documentation

Next.js Route Handlers|Zod Documentation