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
| Path | Purpose |
|---|---|
/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/stripe | Stripe webhook handler |
/api/ai/generate | AI 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 off3. 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:
| Tier | Use for |
|---|---|
general | Most reads, safe writes |
voting | Upvotes, ratings |
auth | Login-adjacent endpoints |
submission | Creating projects |
admin | Admin actions |
analytics | Tracking 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 typed5. 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:
| Code | Meaning |
|---|---|
| 200 | OK |
| 201 | Created |
| 400 | Bad request (validation failed) |
| 401 | Unauthorized (not logged in) |
| 403 | Forbidden (logged in but no permission) |
| 404 | Not found or feature disabled |
| 429 | Rate-limited |
| 500 | Unhandled error |
Example route skeleton
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:
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 ofgetUser().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 prefergetUser()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 thegeneraltier 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
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 elsecheckRateLimit(request, '<tier>')right after- Zod
.safeParse()for body validation with 400 on failure db.findOne/insertOne/updateOnefor DB ops- Response.json with consistent { data } / { error } shape