Skip to main content
The Tuturuuu platform uses Next.js App Router API routes with consistent patterns for versioning, authentication, error handling, and workspace isolation. During the TanStack Start migration, new backend API ownership should move to apps/backend instead of adding more long-lived apps/web route handlers. See platform/architecture/tanstack-rust-migration for the route manifest, OpenAPI, Docker, E2E, and benchmark gates.

Route Organization

Directory Structure

apps/web/src/app/api/
├── v1/                    # Versioned public API
│   ├── workspaces/
│   │   └── route.ts
│   └── users/
│       └── route.ts
├── ai/                    # AI-specific endpoints
│   ├── chat/
│   │   └── route.ts
│   └── generate/
│       └── route.ts
├── [wsId]/               # Workspace-scoped endpoints
│   ├── tasks/
│   │   └── route.ts
│   └── members/
│       └── route.ts
├── auth/                 # Authentication endpoints
│   ├── otp/
│   │   └── route.ts
│   └── mfa/
│       └── route.ts
└── workspaces/           # Workspace management
    └── route.ts

Upstream Proxy Routes

When an apps/web route proxies requests to an upstream service and needs low-level Undici transport controls such as a custom dispatcher or larger maxHeaderSize, prefer undici.request(...) over the global server fetch. On Next.js 16, the wrapped route-handler fetch can reject custom Undici dispatchers with UND_ERR_INVALID_ARG / invalid onRequestStart method. Keep custom transport configuration on the direct Undici request instead of passing it through fetch.

Authentication Wrappers

apps/web routes do not hand-roll try/catch + supabase.auth.getUser() membership checks. Two wrappers in apps/web/src/lib centralize IP blocking, pre-auth rate limiting, payload-size limits, suspension checks, and adaptive abuse controls so handlers only contain business logic.
WrapperModuleAuth sourceHandler context
withSessionAuth@/lib/api-authSigned-in Supabase session (cookie/Bearer JWT, optional AI temp auth / app-session){ user, supabase }
withApiAuth@/lib/api-middlewareWorkspace API key (Authorization: Bearer ttr_...){ context, params } where context is the WorkspaceContext
Both wrappers receive route params as a Promise (Next.js App Router behavior) and resolve them before invoking the handler. The session wrapper exposes a TypedSupabaseClient already scoped to the authenticated user, so RLS stays intact.
The older authorizeRequest/authorize helpers in @/lib/api-auth are @deprecated. Prefer withSessionAuth for new routes.

Versioned Public API

Pattern: /api/v1/*

Use for product and public-facing APIs. For session-authenticated dashboard surfaces, wrap the handler with withSessionAuth. The wrapper provides the authenticated user and a request-scoped supabase client; the third handler argument is the resolved route params.
// app/api/v1/workspaces/route.ts
import { withSessionAuth } from '@/lib/api-auth';
import { NextResponse } from 'next/server';
import { z } from 'zod';

// GET /api/v1/workspaces
export const GET = withSessionAuth(async (_request, { user, supabase }) => {
  // Fetch the caller's workspaces (RLS-scoped via `supabase`)
  const { data: workspaces, error } = await supabase
    .from('workspace_members')
    .select(
      `
      ws_id,
      role,
      workspaces (
        id,
        name,
        logo_url
      )
    `
    )
    .eq('user_id', user.id)
    .eq('pending', false);

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }

  return NextResponse.json({
    data: workspaces?.map((w) => w.workspaces),
  });
});

// POST /api/v1/workspaces
const createWorkspaceSchema = z.object({
  name: z.string().min(1).max(100),
  logo_url: z.url().optional(),
});

export const POST = withSessionAuth(async (request, { user, supabase }) => {
  // Validate request body
  const body = await request.json();
  const parsed = createWorkspaceSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: 'Invalid input', details: parsed.error.issues },
      { status: 400 }
    );
  }

  // Create workspace
  const { data: workspace, error } = await supabase
    .from('workspaces')
    .insert({
      name: parsed.data.name,
      logo_url: parsed.data.logo_url,
    })
    .select()
    .single();

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }

  // Add creator as owner
  await supabase.from('workspace_members').insert({
    ws_id: workspace.id,
    user_id: user.id,
    role: 'owner',
  });

  return NextResponse.json({ data: workspace }, { status: 201 });
});
When you do need a Supabase client outside a wrapper (Server Components, background jobs), remember the factory async-ness in @tuturuuu/supabase/next/server: createClient() and createDynamicClient() are async and must be awaited; createAdminClient() is synchronous (do not await it unless you pass it through another async helper).

Workspace-Scoped API

Pattern: /api/v1/workspaces/[wsId]/*

Use for workspace-scoped operations. Route params are a Promise; the wrapper resolves them and passes them as the third handler argument. Authorize with the workspace helpers from @tuturuuu/utils/workspace-helper:
  • normalizeWorkspaceId(wsId, supabase) — resolves personal/handle aliases to a concrete UUID.
  • getPermissions({ wsId, user }) — returns a PermissionsResult with containsPermission(permissionId) / withoutPermission(permissionId). It returns null when the caller is not a member, which doubles as the membership check.
// app/api/v1/workspaces/[wsId]/tasks/route.ts
import { withSessionAuth } from '@/lib/api-auth';
import {
  getPermissions,
  normalizeWorkspaceId,
} from '@tuturuuu/utils/workspace-helper';
import { NextResponse } from 'next/server';
import { z } from 'zod';

type Params = { wsId: string };

// GET /api/v1/workspaces/[wsId]/tasks
export const GET = withSessionAuth<Params>(
  async (request, { user, supabase }, { wsId }) => {
    const workspaceId = await normalizeWorkspaceId(wsId, supabase);

    // getPermissions returns null for non-members → 403
    const permissions = await getPermissions({ wsId: workspaceId, user });
    if (!permissions) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
    }

    const searchParams = new URL(request.url).searchParams;
    const listId = searchParams.get('listId');
    const completed = searchParams.get('completed');

    let query = supabase
      .from('workspace_tasks')
      .select('*')
      .eq('ws_id', workspaceId);

    if (listId) query = query.eq('list_id', listId);
    if (completed !== null) query = query.eq('completed', completed === 'true');

    const { data: tasks, error } = await query;

    if (error) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }

    return NextResponse.json({ data: tasks });
  }
);

// POST /api/v1/workspaces/[wsId]/tasks
const createTaskSchema = z.object({
  name: z.string().min(1).max(255),
  description: z.string().optional(),
  listId: z.string(),
  priority: z.number().int().min(0).max(5).optional(),
  dueDate: z.string().datetime().optional(),
});

export const POST = withSessionAuth<Params>(
  async (request, { user, supabase }, { wsId }) => {
    const workspaceId = await normalizeWorkspaceId(wsId, supabase);

    const permissions = await getPermissions({ wsId: workspaceId, user });
    if (!permissions?.containsPermission('manage_projects')) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
    }

    const body = await request.json();
    const parsed = createTaskSchema.safeParse(body);

    if (!parsed.success) {
      return NextResponse.json(
        { error: 'Invalid input', details: parsed.error.issues },
        { status: 400 }
      );
    }

    const { data: task, error } = await supabase
      .from('workspace_tasks')
      .insert({
        name: parsed.data.name,
        description: parsed.data.description,
        list_id: parsed.data.listId,
        priority: parsed.data.priority,
        end_date: parsed.data.dueDate,
        creator_id: user.id,
      })
      .select()
      .single();

    if (error) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }

    return NextResponse.json({ data: task }, { status: 201 });
  }
);
Real task-board routes (for example apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts) keep handler logic in @tuturuuu/apis/tu-do/tasks/route and only wire the wrapper in the route file. Extract shared handler logic the same way when a route grows past a simple CRUD shape. Use containsPermission() with a real PermissionId (such as 'manage_projects'); there is no manage_tasks permission and no @/lib/permissions module.

AI Endpoints

Pattern: /api/ai/*

Use for AI-specific operations with model selection and token tracking. The repo uses AI SDK v6 (ai ^6.x, @ai-sdk/google ^3.x). On v6, streamText results expose result.toUIMessageStreamResponse() (the v4/v5 toDataStreamResponse() no longer exists), and onFinish usage is reported as usage.inputTokens / usage.outputTokens (not promptTokens / completionTokens). Wrap the route with withSessionAuth and pass allowAiTempAuth if the endpoint must accept short-lived AI temp tokens.
// app/api/ai/chat/route.ts
import { withSessionAuth } from '@/lib/api-auth';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { streamText } from 'ai';
import { z } from 'zod';

const chatRequestSchema = z.object({
  wsId: z.string(),
  messages: z.array(
    z.object({
      role: z.enum(['user', 'assistant', 'system']),
      content: z.string(),
    })
  ),
  model: z.string().optional(),
});

export const POST = withSessionAuth(async (request, { supabase }) => {
  // Validate input
  const body = await request.json();
  const parsed = chatRequestSchema.safeParse(body);

  if (!parsed.success) {
    return new Response('Invalid input', { status: 400 });
  }

  // Check AI feature flag
  const { data: secret } = await supabase
    .from('workspace_secrets')
    .select('value')
    .eq('ws_id', parsed.data.wsId)
    .eq('name', 'ENABLE_AI')
    .single();

  if (secret?.value !== 'true') {
    return new Response('AI not enabled for workspace', { status: 403 });
  }

  const google = createGoogleGenerativeAI({
    apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
  });

  const model = parsed.data.model || 'gemini-2.0-flash';

  const result = streamText({
    model: google(model),
    messages: parsed.data.messages,
    onFinish: async ({ usage }) => {
      // Track token usage (AI SDK v6 field names)
      await supabase.from('workspace_ai_executions').insert({
        ws_id: parsed.data.wsId,
        model,
        input_tokens: usage.inputTokens ?? 0,
        output_tokens: usage.outputTokens ?? 0,
      });
    },
  });

  return result.toUIMessageStreamResponse();
});
Token-usage persistence across the live AI routes (chat, task journal, suggestions, quizzes) consistently reads usage.inputTokens ?? 0 and usage.outputTokens ?? 0 — mirror that when adding new AI endpoints.

Authentication Endpoints

Pattern: /api/auth/*

Use edge runtime for auth endpoints.
// app/api/auth/otp/route.ts
import { createClient } from '@tuturuuu/supabase/next/server';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const otpRequestSchema = z.object({
  email: z.email(),
});

export async function POST(request: NextRequest) {
  try {
    const supabase = await createClient();

    const body = await request.json();
    const parsed = otpRequestSchema.safeParse(body);

    if (!parsed.success) {
      return NextResponse.json(
        { error: 'Invalid email' },
        { status: 400 }
      );
    }

    const { error } = await supabase.auth.signInWithOtp({
      email: parsed.data.email,
      options: {
        emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
      },
    });

    if (error) {
      return NextResponse.json({ error: error.message }, { status: 400 });
    }

    return NextResponse.json({ success: true });
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

Error Response Standards

Standard Error Format

interface ErrorResponse {
  error: string;
  details?: any;
  code?: string;
}

HTTP Status Codes

  • 200 - Success
  • 201 - Created
  • 204 - No Content
  • 400 - Bad Request (validation errors)
  • 401 - Unauthorized (not authenticated)
  • 403 - Forbidden (not authorized)
  • 404 - Not Found
  • 409 - Conflict
  • 422 - Unprocessable Entity
  • 429 - Too Many Requests
  • 500 - Internal Server Error

Error Handling Pattern

export async function POST(request: NextRequest) {
  try {
    // Implementation
  } catch (error) {
    console.error('API error:', error);

    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: 'Validation failed', details: error.issues },
        { status: 400 }
      );
    }

    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

API-Key Authenticated Routes

Pattern: withApiAuth

External SDK clients authenticate with a workspace API key (Authorization: Bearer ttr_...) instead of a Supabase session. Wrap those routes with withApiAuth from @/lib/api-middleware. It validates the key, enforces IP blocks, applies pre-auth and adaptive rate limits, logs API key usage, optionally checks workspace permissions, and passes the resolved WorkspaceContext plus route params to the handler.
// app/api/v1/workspaces/[wsId]/storage/route.ts
import { withApiAuth } from '@/lib/api-middleware';
import { NextResponse } from 'next/server';

type Params = { wsId: string };

export const GET = withApiAuth<Params>(
  async (_request, { params, context }) => {
    // `context.wsId` is the validated workspace from the API key
    const { wsId } = context;
    // params.wsId is the resolved (awaited) route segment
    return NextResponse.json({ wsId, route: params.wsId });
  },
  {
    permissions: ['manage_drive'], // checked via the API key's permission set
    rateLimit: { windowMs: 60000, maxRequests: 100 },
  }
);
Key points that differ from withSessionAuth:
  • Permissions are declared in the wrapper options.permissions (a PermissionId[]) and checked against the API key’s granted scopes via hasAnyPermission / hasAllPermissions. Set requireAll: true to require every listed permission.
  • The handler context is { context, params } (not { user, supabase }); the API-key path does not hand you a session-scoped Supabase client.
  • Rate-limit defaults: GET/HEAD reads are open, mutations default to 100 req/min, and workspace-specific overrides from workspace_secrets take precedence.
The helper module also exports validateQueryParams(request, schema) and validateRequestBody(request, schema, maxBytes) for Zod-validated, byte-size-capped input handling.

CORS Configuration

// app/api/v1/workspaces/route.ts
export async function OPTIONS(request: NextRequest) {
  return new NextResponse(null, {
    status: 200,
    headers: {
      'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN || '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    },
  });
}

Rate Limiting

For signed-in product APIs, prefer withSessionAuth(...) over manual supabase.auth.getUser() calls. The wrapper resolves the session through local JWT claims first and only falls back to getUser() when claims are unavailable, which avoids exhausting Supabase Auth limits on request-heavy dashboard surfaces such as task boards. Default GET/HEAD requests are not rate-limited by the session wrapper; default mutations use the shared mutation budget unless the route provides an explicit rateLimit option. The API proxy still protects request-heavy dashboard reads before route auth is validated. High-fanout task-board reads use a dedicated task-board-read proxy bucket so normal board loading, per-list pagination, and focused revalidation do not exhaust the generic anonymous read budget. The defaults are 600 requests per minute, 12000 per hour, and 80000 per day, and can be tuned with API_PROXY_TASK_BOARD_READ_LIMIT_MINUTE, API_PROXY_TASK_BOARD_READ_LIMIT_HOUR, and API_PROXY_TASK_BOARD_READ_LIMIT_DAY. Proxy-side 429 responses include support diagnostics when available: X-RateLimit-Client-IP, X-RateLimit-User-Id, and X-RateLimit-User-Email, plus the selected policy/window headers emitted by the proxy guard. Exact server-verified browser sessions ending in @tuturuuu.com are allowed through proxy-side rate-limit blocks for debugging with X-RateLimit-Warning: staff-debug-bypass, X-RateLimit-Debug-Bypass: tuturuuu-staff, and X-RateLimit-Original-Status: 429. This bypass only applies after the server revalidates the Supabase session, only to proxy guard rate-limit blocks, and does not bypass malformed auth, permissions, payload-size checks, route-handler errors, or non-staff/anonymous requests. Finance invoice creation support reads use a separate finance-invoice-create-read proxy bucket for the shared-IP burst created by the new invoice flow. It covers GET/HEAD reads for customer search, products, wallets, transaction categories, promotions, invoice default settings, linked products, user group data, user promotion data, invoice history, and subscription context. Invoice creation mutations remain on the default mutation policy. The defaults are 600 requests per minute, 6000 per hour, and 40000 per day, and can be tuned with API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_MINUTE, API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_HOUR, and API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_DAY.

Verified sessions and trusted read uplift

Proxy limits default to per-IP anonymous buckets because auth headers and cookies are forgeable at the edge — their presence alone must never raise a budget. To separate genuinely signed-in browser sessions and give legitimate teams (often many people behind one office NAT/VPN IP) higher read throughput, the proxy consults a server-written trust cache in Redis, keyed by the same subject keys as the abuse-reputation system (session:<hash>, cidr:<network>, ip:<addr>):
  • A genuinely trusted session (its key is a hash of the real auth cookie, so it cannot be forged) gets its own per-session read and mutation buckets, so signed-in teammates behind one shared IP no longer collide on the pre-auth proxy budget.
  • A trusted location (office cidr/ip, learned automatically from organic reputation or set explicitly via an abuse_trust_overrides cidr entry in the abuse-intelligence admin API) uplifts the shared per-IP read limit for the whole team.
Untrusted traffic keeps the legacy per-IP limits, preserving single-IP abuse caps. The cache only ever uplifts read limits (never mutation limits, and never lowers limits — restrictive decisions stay server-side) and fails open to neutral when Redis is unavailable. It is populated by the session write-through in recordResponseAbuseSignal and direct session-auth helpers, and reconciled every 10 minutes by the /api/cron/infrastructure/sync-trust-cache cron (which also propagates admin trusted-location overrides). Set API_PROXY_EDGE_TRUST_ENABLED=0 to disable the edge trust cache and fall back to per-IP keying; tune the cache lifetime with EDGE_TRUST_CACHE_TTL_SECONDS.
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
});

export async function POST(request: NextRequest) {
  const identifier = request.headers.get('x-forwarded-for') || 'anonymous';
  const { success } = await ratelimit.limit(identifier);

  if (!success) {
    return NextResponse.json(
      { error: 'Rate limit exceeded' },
      { status: 429 }
    );
  }

  // Continue with request
}

Best Practices

✅ DO

  1. Wrap handlers with the auth wrapper
    // Session routes: getUser/membership/rate limits handled for you
    export const POST = withSessionAuth(async (request, { user, supabase }) => {
      /* ... */
    });
    // API-key routes: key validation + permission scopes
    export const GET = withApiAuth(handler, { permissions: ['manage_drive'] });
    
  2. Always validate input
    const parsed = schema.safeParse(body);
    if (!parsed.success) return error(400);
    
  3. Verify workspace permissions
    const workspaceId = await normalizeWorkspaceId(wsId, supabase);
    const permissions = await getPermissions({ wsId: workspaceId, user });
    if (!permissions?.containsPermission('manage_projects')) return error(403);
    

❌ DON’T

  1. Don’t expose sensitive errors
    // ❌ Bad
    return NextResponse.json({ error: error.stack });
    
  2. Don’t skip workspace isolation
    // ❌ Bad: Can access any workspace
    .delete().eq('id', taskId)
    
    // ✅ Good: Workspace-scoped
    .delete().eq('id', taskId).eq('ws_id', wsId)
    
  3. Don’t reach for the admin client to skip authorization
    // ❌ Bad: bypasses RLS and the caller's permission set
    const sbAdmin = createAdminClient(); // sync — do not await
    await sbAdmin.from('workspace_tasks').delete().eq('id', taskId);
    
    // ✅ Good: use the session-scoped `supabase` from withSessionAuth so RLS
    //         and getPermissions() still gate the write
    await supabase
      .from('workspace_tasks')
      .delete()
      .eq('id', taskId)
      .eq('ws_id', workspaceId);
    
    Use the admin client only for trusted server-side work that has already performed its own authorization (e.g. inside getPermissions), and remember it is synchronous.