API Routes
Overview
API routes (app/api/**/route.ts) are used for:
- External service integrations that require credential proxying (Directus, Redis).
- Cache invalidation that the Edge Runtime cannot perform (
revalidateTag). - Heavy backend operations that should not run inline in a server component (chart generation).
Mutations driven by forms generally use Server Actions instead.
Route inventory
| Route | Method | Purpose |
|---|---|---|
/api/essearch | POST | Voter / person search via Directus, with per-request credentials |
/api/essearch-common | POST | Same as above with fixed env-based credentials |
/api/duplicate | POST | Per-campaign duplicate-signer check via Redis |
/api/charts/[id] | POST | Generate and persist a chart's data using a service-role Supabase client |
/api/permissions/revalidate | POST | Drop the cached permissions snapshot for the currently signed-in user |
See API Route Handlers for end-to-end implementation details.
Common pattern
// app/api/<feature>/route.ts
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs"; // most routes use the Node runtime (Redis / service-role)
export const dynamic = "force-dynamic"; // when you need fresh execution per request
export async function POST(req: NextRequest) {
try {
const body = await req.json();
// ...do work
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message },
{ status: 500 },
);
}
}
Voter search APIs
/api/essearch
- Accepts both query params and a
credentials: { name, emailKey, endpointKey, passwordKey }block. - Reads Directus credentials from
process.env[<key>]per request, falling back to the common envs. - Caches the Directus access token in an HTTP-only cookie (1-hour TTL,
sameSite: strict). - Retries once after a
401by deleting the cached cookie and re-authenticating. - Returns
{ persons, hitsCount, totalHits }.
/api/essearch-common
Same logic but reads DIRECTUS_API_EMAIL_SEARCH, DIRECTUS_API_PASSWORD_SEARCH, DIRECTUS_URL_SEARCH and uses a fixed cookie name (directus_token_search).
Duplicate detection
/api/duplicate performs a single Redis SISMEMBER against campaign:{campaignId}:signers:
const exists = await client.sIsMember(`campaign:${campaignId}:signers`, voterId);
return NextResponse.json({ exists: Boolean(exists) });
The Redis singleton (src/lib/redis.ts) lazily connects to Upstash with TLS. On Connection is closed / ECONNRESET, the route resets the singleton and returns 503 so the caller can retry.
Charts API
/api/charts/[id] loads the chart definition from charts.id == :id, runs generateChartData(config), and persists the result back. Uses createSpecialClient(SERVICE_ROLE_KEY) — must remain server-only. There is also an equivalent Supabase Edge Function (generate-chart-data) used by the refresh-all-charts job.
Permissions revalidation
/api/permissions/revalidate calls:
revalidateTag(`permissions-${user.id}`);
The user id is read from the session (never from the request body), so a client cannot invalidate someone else's cache.
Choosing API route vs Server Action
| Need | Use |
|---|---|
| Form submission with progressive enhancement | Server Action |
| Strongly typed, in-tree mutation | Server Action |
| External service that needs credentials proxied | API Route |
revalidateTag from outside RSC (e.g. middleware) | API Route |
| Service-role / heavy job | API Route |