Skip to main content

API Overview

Patterns shared across every API route — auth, feature gating, rate limits, validation and the response contract.

Why this matters

Every directory you'll ever build needs the same five things on the server: an auth check, a feature flag, a rate limiter, input validation, and a database write. DirectoryLaunch ships them as composable helpers so each new endpoint takes 15 minutes to wire up — not 2 hours of plumbing. This page is the single source of truth for those helpers and the response contract every client (your own pages, third-party integrations, mobile apps) builds against.

If you're about to write a new route, scroll to the route skeleton and copy it. If you're integrating with the API from the outside, read Response shape and Status codes — they're stable across versions.

All API routes live under app/api/ and follow the Next.js App Router convention — each folder with a route.ts file is an endpoint. Most routes follow a common pattern. This page covers the shared building blocks.

Route categories

PathPurpose
/api/projects/*Project CRUD and listing
/api/categories/*Category management
/api/user/*Current user's profile and settings
/api/admin/*Admin-only operations
/api/payments/*Checkout creation
/api/webhooks/stripeStripe webhook handler
/api/ai/generateAI description generator (feature-flagged)
/api/analytics/*Event tracking endpoints
/api/cron/*Scheduled jobs (protected by CRON_SECRET)

Shared patterns

1. Auth check

Always verify with getUser(), never getSession():

import { getSupabaseAdmin } from '@/lib/supabase/client';
 
const supabase = getSupabaseAdmin();
const { data: { user } } = await supabase.auth.getUser();
 
if (!user) {
  return Response.json({ error: 'Unauthorized' }, { status: 401 });
}

2. Feature guard

Return 404 if the whole feature is disabled:

import { featureGuard } from '@/lib/features';
 
const guard = featureGuard('comments');
if (guard) return guard;  // 404 response if comments are off

3. Rate limit

Every write endpoint should rate-limit to prevent abuse:

import { checkRateLimit, createRateLimitResponse } from '@/lib/rate-limit';
 
const limit = await checkRateLimit(request, 'submission');
if (!limit.success) return createRateLimitResponse(limit);

Tiers available:

TierUse for
generalMost reads, safe writes
votingUpvotes, ratings
authLogin-adjacent endpoints
submissionCreating projects
adminAdmin actions
analyticsTracking events

4. Input validation (Zod)

Schemas live in lib/validations/schemas.ts:

import { ProjectSubmissionSchema } from '@/lib/validations/schemas';
 
const body = await request.json();
const parsed = ProjectSubmissionSchema.safeParse(body);
 
if (!parsed.success) {
  return Response.json(
    { error: 'Invalid input', details: parsed.error.format() },
    { status: 400 }
  );
}
 
// parsed.data is fully typed

5. Database access

Use the db singleton for most things:

import { db } from '@/lib/supabase/database';
 
const project = await db.insertOne('apps', { ... });

See Database Layer API.

6. Webhook/external notifications

Fire outbound webhooks after important events:

import { webhookEvents } from '@/lib/webhooks';
 
await webhookEvents.projectSubmitted(project);

Response shape

Successful responses:

{ "data": { ... } }

Error responses:

{ "error": "Something went wrong", "code": "INVALID_INPUT" }

Status codes to expect:

CodeMeaning
200OK
201Created
400Bad request (validation failed)
401Unauthorized (not logged in)
403Forbidden (logged in but no permission)
404Not found or feature disabled
429Rate-limited
500Unhandled error

Example route skeleton

app/api/example/route.ts
import { NextRequest } from 'next/server';
import { getSupabaseAdmin } from '@/lib/supabase/client';
import { checkRateLimit, createRateLimitResponse } from '@/lib/rate-limit';
import { featureGuard } from '@/lib/features';
import { db } from '@/lib/supabase/database';
import { z } from 'zod';
 
const BodySchema = z.object({ name: z.string().min(1) });
 
export async function POST(req: NextRequest) {
  const guard = featureGuard('comments');
  if (guard) return guard;
 
  const limit = await checkRateLimit(req, 'general');
  if (!limit.success) return createRateLimitResponse(limit);
 
  const { data: { user } } = await getSupabaseAdmin().auth.getUser();
  if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 });
 
  const body = BodySchema.safeParse(await req.json());
  if (!body.success) return Response.json({ error: 'Invalid input' }, { status: 400 });
 
  const { insertedId } = await db.insertOne('apps', {
    ...body.data,
    user_id: user.id,
    status: 'pending',
  });
 
  return Response.json({ data: { id: insertedId } }, { status: 201 });
}

A real-world walkthrough: building a "subscribe to category updates" endpoint

Suppose you want users to subscribe to email updates for a specific category — a new directory feature with its own route. The full implementation in 25 lines of business logic, plus the standard guards:

app/api/categories/[slug]/subscribe/route.ts
import { NextRequest } from 'next/server';
import { getSupabaseAdmin } from '@/lib/supabase/client';
import { checkRateLimit, createRateLimitResponse } from '@/lib/rate-limit';
import { featureGuard } from '@/lib/features';
import { db } from '@/lib/supabase/database';
import { z } from 'zod';
 
const Body = z.object({ frequency: z.enum(['weekly', 'monthly']) });
 
export async function POST(
  req: NextRequest,
  { params }: { params: Promise<{ slug: string }> }
) {
  const guard = featureGuard('emailNotifications');
  if (guard) return guard;
 
  const limit = await checkRateLimit(req, 'general');
  if (!limit.success) return createRateLimitResponse(limit);
 
  const supabase = getSupabaseAdmin();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 });
 
  const body = Body.safeParse(await req.json());
  if (!body.success)
    return Response.json({ error: 'Invalid input' }, { status: 400 });
 
  const { slug } = await params;
  const category = await db.findOne('categories', { slug });
  if (!category) return Response.json({ error: 'Not found' }, { status: 404 });
 
  const { insertedId } = await db.insertOne('category_subscriptions', {
    user_id: user.id,
    category_id: category.id,
    frequency: body.data.frequency,
  });
 
  return Response.json({ data: { id: insertedId } }, { status: 201 });
}

Five guards (feature, rate limit, auth, validation, existence) before a single line of business logic. That's the pattern; it scales to dozens of endpoints without each one re-inventing the wheel.

Common pitfalls

  • Using getSession() instead of getUser(). getSession() reads from the cookie without verifying — an attacker who steals a cookie keeps a valid session indefinitely. getUser() validates with the Supabase server every call. Always prefer getUser() for auth gates on API routes.
  • Skipping the feature guard. Without featureGuard('comments'), a flagged-off feature still serves data via the API even though the UI doesn't show it. Anyone with a curl command can hit your "private beta" comments endpoint and read everything.
  • Forgetting the rate limiter on read endpoints. Reads feel safe, but a scraping bot can bring your DB to a crawl with unlimited GET /api/projects?limit=100. Apply the general tier to every endpoint by default and only remove it with intent.
  • Returning sensitive errors verbatim. parsed.error.format() is fine for development, but in production it can leak schema internals. Filter to the field-level error messages you actually want to expose.
  • Mixing response shapes. Pick { data, error } and stick with it on every endpoint. Inconsistent shapes — sometimes { id: 5 }, sometimes { data: { id: 5 } } — force every client to add per-endpoint parsing logic.

FAQ

Why two response shapes ({ data: ... } vs { error: ... })?

Predictability. Every consumer of your API can do const { data, error } = await response.json() once and branch — no per-route schema knowledge. Stripe, Supabase and most modern APIs use this pattern for the same reason.

Can I use this API directly from a mobile app?

Yes — Supabase auth tokens work over HTTP from anywhere. Set the Authorization: Bearer <access_token> header on each request and the server's getUser() call will validate it. The rate limiter keys on IP by default; you may want to switch to user-keyed limits for mobile traffic to avoid carrier-level NAT issues.

Are the rate limit tiers customisable?

Yes — see Directory Config. Each tier has a per-window count and a window duration in seconds. Tune them per environment if needed; the defaults are sensible for most directories under 10K daily active users.

With AI

AI Prompt· Add a new API route

Add a new API route to this DirectoryKit project.

Endpoint: {METHOD} {/api/your-path} Purpose: {what the route does, 1 sentence} Auth: {public | logged-in | admin} Feature flag to guard (if any): {flag name from features.config.ts, or "none"} Rate limit tier: {general | voting | auth | submission | admin | analytics}

Request body (if any) — define a Zod schema. Fields:

  • {field name: type: constraints}

Database writes (if any): which table, which fields. Response shape: {what gets returned}

Follow the template in app/api/ and CLAUDE.md. Use:

  • getSupabaseAdmin().auth.getUser() for auth check (never getSession)
  • featureGuard(...) before anything else
  • checkRateLimit(request, '<tier>') right after
  • Zod .safeParse() for body validation with 400 on failure
  • db.findOne/insertOne/updateOne for DB ops
  • Response.json with consistent { data } / { error } shape