Next.js Server Action Validation
Concept of the skill
What it is: The validation and authorization discipline for Next.js Server Actions that accept client-originated input.
Mental model: A Server Action is a public POST endpoint wearing function-call syntax.
Why it exists: Convenience syntax can hide the HTTP boundary, leading developers to skip auth, input validation, or org scoping.
What it is NOT: It is not GET route handling, Server Component data fetching, or Stripe webhook verification.
Adjacent concepts: Zod schemas, auth checks, org-scoped mutations, controlled error surfaces.
One-line analogy: It is the security checkpoint every form submission crosses before touching the database.
Common misconception: Only the project UI can call the action; any HTTP client can call the generated endpoint.
Coverage
- The public endpoint reality —
'use server' functions are exposed as POST endpoints that any HTTP client can call directly; the Next.js call graph is not a security boundary
- Validation order — auth check BEFORE Zod parse, Zod parse BEFORE database access; any other order produces an exploitable window
- Zod schema design for actions — using
.safeParse() over .parse() to return structured errors rather than throwing; returning { error: ... } shapes that the calling Client Component can render
- Org-scoping after authentication — verifying that
input.orgId matches the session's org, not just that the user is logged in
- Error surface control — how
try/catch around database calls prevents internal errors from propagating to the client response
Philosophy of the skill
Server Actions are a convenience feature that makes form submission feel like a function call. That convenience obscures a critical fact: the action is a public HTTP POST endpoint. A developer who writes const { data } = await myAction(formData) in a Client Component is writing what looks like a local function call, but the runtime sends an HTTP request that any script can replicate. Skipping auth or validation because "it's called from our own UI" is the same reasoning that made getServerSideProps data-fetching functions leaky in the Pages Router — the server boundary does not restrict callers.
Standard Action Pattern
"use server";
import { z } from "zod";
import { getServerSession } from "@/lib/auth";
import { orgQuery } from "@/lib/db";
const CreateOrderSchema = z.object({
orgId: z.string().uuid(),
planId: z.string().min(1),
quantity: z.number().int().positive(),
});
export async function createOrder(input: unknown) {
// 1. Auth check — before anything else
const session = await getServerSession();
if (!session?.user) {
return { error: "Unauthorized" };
}
// 2. Zod parse — structured errors, not thrown exceptions
const parsed = CreateOrderSchema.safeParse(input);
if (!parsed.success) {
return { error: parsed.error.flatten() };
}
// 3. Org-scope check — user must belong to the org they are acting on
if (session.user.orgId !== parsed.data.orgId) {
return { error: "Forbidden" };
}
// 4. Database write — inside orgQuery for RLS enforcement
try {
const [order] = await orgQuery(parsed.data.orgId, (tx) =>
tx`INSERT INTO orders (org_id, plan_id, quantity) VALUES (
${parsed.data.orgId}, ${parsed.data.planId}, ${parsed.data.quantity}
) RETURNING *`
);
return { data: order };
} catch {
return { error: "Failed to create order" };
}
}
Validation Order Rationale
| Order |
Risk |
| Auth → Zod → orgScope → DB |
Correct — each gate eliminates the next attack surface |
| Zod → Auth → DB |
Attacker can probe the schema structure without authenticating |
| Auth → DB (no Zod) |
Malformed input reaches the query layer; injection risk |
| DB → Auth (anywhere) |
Unauthenticated database reads before rejection |
Verification
Do NOT Use When
| Use instead |
When |
stripe-webhook-signature-verification |
The entry point is a webhook route handler, not a Server Action |
| (a data-fetching skill) |
The function is a Server Component that fetches data, with no user-submitted input |
postgres-rls-pattern |
The task is defining the database-layer policy, not the action-layer validation |
1---2# name: stable skill identifier. Match the skill directory name or the final namespace segment.3# Lowercase letters/numbers with hyphen, slash, or colon separators.4name: nextjs-server-action-validation5# description: routing-facing summary of what the skill covers and when it activates.6# Include concrete triggers and an explicit negative boundary; keep routing semantics out of prose-only ambiguity.7description: "Use when writing a Next.js Server Action that accepts user-submitted form data, mutation parameters, or any client-originated input. Every Server Action is a public HTTP endpoint regardless of how it is called — validate with Zod and check authentication as the first two operations before touching the database. Do NOT use for GET route handlers or Server Components that fetch data (those have no user-supplied input); do NOT use for Stripe webhook handlers (use stripe-webhook-signature-verification instead)."89# === v8 Classification (subject + public; polyhierarchy via subjects[]) — see ADR-0017 ===10# subject: primary browse shelf — what the skill teaches. One of twelve closed values:11# backend-engineering / frontend-engineering / software-architecture / data-engineering / agent-ops / ai-engineering /12# quality-assurance / design / reasoning-strategy / software-engineering-method / knowledge-organization / product-domain.13subject: frontend-engineering14# public: publishability/private-data gate. Boolean.15# true = publishable/shareable; false = private and excluded from public export.16# Project anchoring is carried separately by non-empty `project[]` plus `grounding`.17public: true18# scope: free-text PRD-style statement of what the skill teaches and what it excludes.19# (v8 required; not an enum). Mirrors Coverage + Do NOT Use When at frontmatter level.20scope: "Server-side input validation for Next.js Server Actions in the saas-stripe-postgres example — schema-validate untrusted form/RPC payloads at the server boundary before any mutation. Excludes external-webhook signature verification (stripe-webhook-signature-verification) and database-layer RLS enforcement (postgres-rls-pattern)."21# taxonomy_domain: optional hierarchical sub-path within `subject`. Slash-delimited22# lowercase kebab-case segments. rename of the original v8 `domain`. Remove when the flat23# `subject` is sufficient.24taxonomy_domain: engineering/web2526# stability: lifecycle marker. One of:27# experimental (active development) / stable (production-ready) /28# frozen (no further changes expected) / deprecated.29# When `deprecated`, schema's allOf REQUIRES `superseded_by: <real-skill-name>`.30stability: experimental31# license: SPDX license identifier (e.g., MIT, Apache-2.0).32license: MIT33# compatibility: runtime compatibility object. Prefer structured fields34# (`agent_runtimes`, `node_version`) over free-text `notes`.35compatibility:36 agent_runtimes:37 - node38 node_version: ">=20"39 notes: "Next.js App Router >=14 with server actions enabled; Zod >=3."40allowed-tools: Read Grep41# keywords: semantic phrases for fuzzy router activation. v8 cap: max 10.42# Keep terms a user would actually type when starting a task in this skill's domain.43keywords:44 - next.js server action validation45 - zod input validation46 - server action security47 - server action auth check48 - server action public endpoint49 - use server directive50 - form action security51 - server action pattern52 - mutation validation53 - next.js app router actions54# triggers: explicit-match activation phrases the router fires on literally.55# Use when label-based routing is intended; usually keywords + examples are enough.56triggers:57 - nextjs-server-action-validation58# paths: glob array of code surfaces this skill governs. Supports gitignore-style59# negation. Each glob should map to ONE canonical skill. Omit if purely conceptual.60paths:61 - "app/actions/*.ts"62 - "lib/actions/*.ts"63# examples: 2-5 realistic user prompts the skill SHOULD activate for.64# Written in the user's voice. Improves retrieval recall beyond keywords alone.65examples:66 - "write a Server Action for the checkout form that validates the selected plan before creating a Stripe session"67 - "secure a Server Action so it rejects unauthenticated requests"68 - "validate user input with Zod in a Server Action before writing to Postgres"69 - "my Server Action is being called directly via fetch — is that safe?"70# anti_examples: near-miss prompts that should route ELSEWHERE.71# Pair with relations.suppresses (or legacy boundary alias) to name the confusable territory's owner.72anti_examples:73 - "validate the stripe-signature header in a webhook route handler"74 - "fetch data in a Server Component to display on a page"75 - "write a GET route handler that returns public product data"76# relations: typed graph edges to sibling skills. Current fields:77# related (adjacency for browse / co-routing expansion) /78# suppresses (exclude listed skills from co-routing when THIS skill wins; write reason79# as "I own this exclusively over X", not "use X instead") /80# boundary (DEPRECATED alias of suppresses, retained for unmigrated skills) /81# verify_with (cross-check; co-loaded as one-hop expansion) /82# depends_on (composition; transitive — A→B→C loads all three) /83# broader / narrower (SKOS-style generalization) /84# disjoint_with (mutual exclusion for incompatible ownership).85relations:86 related:87 - stripe-webhook-signature-verification88 - postgres-rls-pattern89 depends_on:90 - postgres-rls-pattern91 verify_with:92 - stripe-webhook-signature-verification93---9495# Next.js Server Action Validation9697## Concept of the skill9899**What it is:** The validation and authorization discipline for Next.js Server Actions that accept client-originated input.100**Mental model:** A Server Action is a public POST endpoint wearing function-call syntax.101**Why it exists:** Convenience syntax can hide the HTTP boundary, leading developers to skip auth, input validation, or org scoping.102**What it is NOT:** It is not GET route handling, Server Component data fetching, or Stripe webhook verification.103**Adjacent concepts:** Zod schemas, auth checks, org-scoped mutations, controlled error surfaces.104**One-line analogy:** It is the security checkpoint every form submission crosses before touching the database.105**Common misconception:** Only the project UI can call the action; any HTTP client can call the generated endpoint.106107## Coverage108109- The public endpoint reality — `'use server'` functions are exposed as POST endpoints that any HTTP client can call directly; the Next.js call graph is not a security boundary110- Validation order — auth check BEFORE Zod parse, Zod parse BEFORE database access; any other order produces an exploitable window111- Zod schema design for actions — using `.safeParse()` over `.parse()` to return structured errors rather than throwing; returning `{ error: ... }` shapes that the calling Client Component can render112- Org-scoping after authentication — verifying that `input.orgId` matches the session's org, not just that the user is logged in113- Error surface control — how `try/catch` around database calls prevents internal errors from propagating to the client response114115## Philosophy of the skill116117Server Actions are a convenience feature that makes form submission feel like a function call. That convenience obscures a critical fact: the action is a public HTTP POST endpoint. A developer who writes `const { data } = await myAction(formData)` in a Client Component is writing what looks like a local function call, but the runtime sends an HTTP request that any script can replicate. Skipping auth or validation because "it's called from our own UI" is the same reasoning that made `getServerSideProps` data-fetching functions leaky in the Pages Router — the server boundary does not restrict callers.118119## Standard Action Pattern120121```typescript122"use server";123import { z } from "zod";124import { getServerSession } from "@/lib/auth";125import { orgQuery } from "@/lib/db";126127const CreateOrderSchema = z.object({128 orgId: z.string().uuid(),129 planId: z.string().min(1),130 quantity: z.number().int().positive(),131});132133export async function createOrder(input: unknown) {134 // 1. Auth check — before anything else135 const session = await getServerSession();136 if (!session?.user) {137 return { error: "Unauthorized" };138 }139140 // 2. Zod parse — structured errors, not thrown exceptions141 const parsed = CreateOrderSchema.safeParse(input);142 if (!parsed.success) {143 return { error: parsed.error.flatten() };144 }145146 // 3. Org-scope check — user must belong to the org they are acting on147 if (session.user.orgId !== parsed.data.orgId) {148 return { error: "Forbidden" };149 }150151 // 4. Database write — inside orgQuery for RLS enforcement152 try {153 const [order] = await orgQuery(parsed.data.orgId, (tx) =>154 tx`INSERT INTO orders (org_id, plan_id, quantity) VALUES (155 ${parsed.data.orgId}, ${parsed.data.planId}, ${parsed.data.quantity}156 ) RETURNING *`157 );158 return { data: order };159 } catch {160 return { error: "Failed to create order" };161 }162}163```164165## Validation Order Rationale166167| Order | Risk |168|---|---|169| Auth → Zod → orgScope → DB | Correct — each gate eliminates the next attack surface |170| Zod → Auth → DB | Attacker can probe the schema structure without authenticating |171| Auth → DB (no Zod) | Malformed input reaches the query layer; injection risk |172| DB → Auth (anywhere) | Unauthenticated database reads before rejection |173174## Verification175176- [ ] Every `'use server'` function calls `getServerSession()` as its first statement177- [ ] Every `'use server'` function runs `Schema.safeParse()` before any database call178- [ ] The session org ID is compared to the input org ID before the database call179- [ ] Database calls are wrapped in `orgQuery`, not bare SQL180- [ ] Errors returned to the client do not include stack traces or query text181182## Do NOT Use When183184| Use instead | When |185|---|---|186| `stripe-webhook-signature-verification` | The entry point is a webhook route handler, not a Server Action |187| (a data-fetching skill) | The function is a Server Component that fetches data, with no user-submitted input |188| `postgres-rls-pattern` | The task is defining the database-layer policy, not the action-layer validation |