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.

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.

Versioned Public API

Pattern: /api/v1/*

Use for public-facing APIs that external clients consume.
// app/api/v1/workspaces/route.ts
import { createClient } from '@tuturuuu/supabase/next/server';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

// GET /api/v1/workspaces
export async function GET(request: NextRequest) {
  try {
    const supabase = createClient();

    // Check authentication
    const {
      data: { user },
    } = await supabase.auth.getUser();

    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    // Fetch user's workspaces
    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),
    });
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

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

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

    const {
      data: { user },
    } = await supabase.auth.getUser();

    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    // 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 });
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

Workspace-Scoped API

Pattern: /api/[wsId]/*

Use for workspace-specific operations with automatic workspace context.
// app/api/[wsId]/tasks/route.ts
import { createClient } from '@tuturuuu/supabase/next/server';
import { hasPermission } from '@/lib/permissions';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

// GET /api/[wsId]/tasks
export async function GET(
  request: NextRequest,
  { params }: { params: { wsId: string } }
) {
  try {
    const supabase = createClient();

    const {
      data: { user },
    } = await supabase.auth.getUser();

    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    // Check workspace membership
    const isMember = await isWorkspaceMember(user.id, params.wsId);
    if (!isMember) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
    }

    // Parse query parameters
    const searchParams = request.nextUrl.searchParams;
    const listId = searchParams.get('listId');
    const completed = searchParams.get('completed');

    // Build query
    let query = supabase
      .from('workspace_tasks')
      .select('*')
      .eq('ws_id', params.wsId);

    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 });
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

// POST /api/[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 async function POST(
  request: NextRequest,
  { params }: { params: { wsId: string } }
) {
  try {
    const supabase = createClient();

    const {
      data: { user },
    } = await supabase.auth.getUser();

    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    // Check permission
    const canCreate = await hasPermission(user.id, params.wsId, 'manage_tasks');
    if (!canCreate) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
    }

    // Validate input
    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 }
      );
    }

    // Create task
    const { data: task, error } = await supabase
      .from('workspace_tasks')
      .insert({
        ws_id: params.wsId,
        name: parsed.data.name,
        description: parsed.data.description,
        list_id: parsed.data.listId,
        priority: parsed.data.priority,
        due_date: parsed.data.dueDate,
        created_by: user.id,
      })
      .select()
      .single();

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

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

// DELETE /api/[wsId]/tasks/[taskId]
export async function DELETE(
  request: NextRequest,
  { params }: { params: { wsId: string; taskId: string } }
) {
  try {
    const supabase = createClient();

    const {
      data: { user },
    } = await supabase.auth.getUser();

    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const canDelete = await hasPermission(user.id, params.wsId, 'manage_tasks');
    if (!canDelete) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
    }

    const { error } = await supabase
      .from('workspace_tasks')
      .delete()
      .eq('id', params.taskId)
      .eq('ws_id', params.wsId); // Ensure workspace isolation

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

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

AI Endpoints

Pattern: /api/ai/*

Use for AI-specific operations with model selection and token tracking.
// app/api/ai/chat/route.ts
import { createClient } from '@tuturuuu/supabase/next/server';
import { streamText } from 'ai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { NextRequest } from 'next/server';
import { z } from 'zod';

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

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

    const {
      data: { user },
    } = await supabase.auth.getUser();

    if (!user) {
      return new Response('Unauthorized', { status: 401 });
    }

    // 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: workspace } = await supabase
      .from('workspace_secrets')
      .select('value')
      .eq('ws_id', body.wsId)
      .eq('name', 'ENABLE_AI')
      .single();

    if (workspace?.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-exp';

    const result = streamText({
      model: google(model),
      messages: parsed.data.messages,
      onFinish: async ({ usage }) => {
        // Track token usage
        await supabase.from('workspace_ai_executions').insert({
          ws_id: body.wsId,
          model,
          input_tokens: usage.promptTokens,
          output_tokens: usage.completionTokens,
          total_cost: calculateCost(model, usage),
        });
      },
    });

    return result.toDataStreamResponse();
  } catch (error) {
    console.error('AI chat error:', error);
    return new Response('Internal server error', { status: 500 });
  }
}

function calculateCost(
  model: string,
  usage: { promptTokens: number; completionTokens: number }
): number {
  // Pricing per million tokens
  const pricing: Record<string, { input: number; output: number }> = {
    'gemini-2.0-flash-exp': { input: 0, output: 0 }, // Free tier
    'gemini-2.0-pro-exp': { input: 0, output: 0 }, // Free tier
  };

  const price = pricing[model] || { input: 0, output: 0 };

  return (
    (usage.promptTokens / 1_000_000) * price.input +
    (usage.completionTokens / 1_000_000) * price.output
  );
}

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 = 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 }
    );
  }
}

Middleware Pattern

Permission Middleware

// lib/api/middleware.ts
import { createClient } from '@tuturuuu/supabase/next/server';
import { hasPermission } from '@/lib/permissions';
import { NextRequest, NextResponse } from 'next/server';

type Handler = (
  req: NextRequest,
  context: { params: any; user: any }
) => Promise<NextResponse>;

export function withAuth(handler: Handler) {
  return async (req: NextRequest, context: any) => {
    const supabase = createClient();

    const {
      data: { user },
    } = await supabase.auth.getUser();

    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    return handler(req, { ...context, user });
  };
}

export function withPermission(permission: string, handler: Handler) {
  return withAuth(async (req, context) => {
    const wsId = context.params.wsId;

    const allowed = await hasPermission(context.user.id, wsId, permission);

    if (!allowed) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
    }

    return handler(req, context);
  });
}

Usage

// app/api/[wsId]/tasks/route.ts
import { withPermission } from '@/lib/api/middleware';

export const POST = withPermission('manage_tasks', async (req, { params, user }) => {
  // Implementation with guaranteed permission
});

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 300 requests per minute, 3000 per hour, and 20000 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. 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.
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. Always validate input
    const parsed = schema.safeParse(body);
    if (!parsed.success) return error(400);
    
  2. Check authentication first
    const { data: { user } } = await supabase.auth.getUser();
    if (!user) return error(401);
    
  3. Verify workspace permissions
    const allowed = await hasPermission(user.id, wsId, 'manage_tasks');
    if (!allowed) 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 use user client for mutations
    // ❌ Bad: Bypasses RLS
    const sbAdmin = await createAdminClient();