API + Supabase Route Conventions
Reuse this skill
Copy api-supabase-routes into your project's .agents/skills/api-supabase-routes/ (or your Cursor skills folder). The JustHold app source stays private — these playbooks are published so you can reuse our engineering conventions.
Shared handler recipe for both apps. Project overlays may add namespaces, CORS details, or stricter “no Server Actions” rules.
Handler flow
- Auth — reject unsigned users with 401
- Parse body — JSON → 400 on failure
- Validate — Zod
safeParse()→ 422 on failure - Query — Supabase scoped by owner (
user_id/ profile id) - Respond — entity-named JSON or standard error body
export async function POST(req: NextRequest) {
// 1. auth (project helper — see below)
// 2. body = await req.json() with try/catch → 400
// 3. Schema.safeParse(body) → 422
// 4. supabase.from(...).eq('user_id', ownerId)
// 5. return NextResponse.json({ entries: data })
}
Auth helpers (both styles OK)
| Style | Example | Used by |
|---|---|---|
{ user, error } tuple | requireUser() / requireAdmin() | 1chooo.com |
| Profile / null | getCurrentProfile() → 401 if missing | JustHold |
// Tuple style
const { user, error: authError } = await requireUser()
if (authError) return authError
// Profile style
const profile = await getCurrentProfile()
if (!profile) {
return NextResponse.json({ error: 'Unauthorized.' }, { status: 401 })
}
Never import server auth helpers into Client Components.
Validation
const parsed = CreateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 422 })
}
Prefer shared types under types/ (or project equivalent) over large inline schemas in route files.
Supabase in handlers
| Need | Client |
|---|---|
| User-scoped reads/writes under RLS | createClient() from utils/supabase/server |
| Bypass RLS (admin, trusted server writes) | createServiceClient() from utils/supabase/service |
Always scope user-owned rows: .eq('user_id', ownerId).
- Supabase
error→ 502 witherror.message - Missing row → 404
{ error: 'not found' }(or project casing)
Response shapes
| Status | Body |
|---|---|
| 200 / 201 | { <entityName>: data } — e.g. { entries }, { application }, { ok: true } |
| 204 | No body (deletes) |
| 400 | { error: 'invalid json' } |
| 401 | { error: 'unauthorized' } (casing may vary slightly by project) |
| 403 | { error: 'forbidden' } |
| 404 | { error: 'not found' } |
| 422 | { error: parsed.error.flatten() } |
| 502 | { error: error.message } |
Prefer lowercase error strings.
Body parsing
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'invalid json' }, { status: 400 })
}
Async route params
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
}