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

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.string().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';

export const runtime = 'edge';
export const maxDuration = 60;

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';

export const runtime = 'edge';

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

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);
    
  4. Use edge runtime for auth
    export const runtime = 'edge';
    
  5. Set max duration for long operations
    export const maxDuration = 60; // seconds
    

❌ 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 supabase = createAdminClient();