TLDR: I ran a full audit on two production Next.js apps and found 12 of 24 server action files were passing raw form data straight to Postgres. Zero Zod. We fixed all of it the same day — here's exactly what that looked like.
The Setup
I've been building two production apps for an ecommerce business — a KOL CRM (Key Opinion Leader management, think influencer + partner tracking) and a supply-chain portal.
Both are Next.js. Both talk to Supabase. Both had been shipping features for months.
The Wall We Hit
Here's the embarrassing thing: I thought we were fine.
We had TypeScript types everywhere. We had Postgres constraints at the DB layer. We had Supabase's RLS policies. I had mentally decided that was enough coverage.
It wasn't.
TypeScript types vanish at runtime. Completely. The type string in your action signature does absolutely nothing to stop a 50,000-character blob from landing in a text column, or a missing required field from silently inserting a null. Postgres constraints are the last-resort backstop — they're not a validation strategy.
What the Audit Found
We did a full sweep on June 5th. 204 tests across all 24 action files in the KOL CRM.
12 of 24 action files had zero Zod validation.
Half.
The supply-chain app had the same gap — 5 more unvalidated mutations on top of that.
But the one that stung most was this: in supplier-details, we were validating before we authenticated. Auth check was further down the handler. So an unauthenticated request would trigger a Zod parse before we'd ever confirmed the caller was allowed in. Not catastrophic, but completely backwards — and sloppy enough that it made me question all the other ordering assumptions.
The Fix That Worked
Every action file got the same three-step pattern, non-negotiably:
- Auth first.
requireAuth()before anything else. If the caller isn't verified, you don't parse their payload, period. - Zod parse second. Schema declared at the top of the file,
.safeParse()on the raw input, return early on failure. - Bounded schemas. Not just shape validation — actual bounds.
.max(500)on text fields. Enum checks on status values. The schema captures your intent, not just "is this a string."
The wrapped upload errors were part of this too — raw Postgres 500s were leaking schema hints in the error body. Wrapping the DB calls with proper error handling was the same pass.
All 22 audit findings were resolved the same day they were found.
Why This Matters to Me
The reason half the actions were unvalidated wasn't laziness. It was process gap. No checklist item that said "new action file → add Zod schema." So some got it and some didn't, and the pattern was invisible until we looked.
The same gap showed up in both apps independently. That's the tell — it wasn't a one-file miss. It was an assumption baked into how we were building.
The rule I'm running with now: every server action that touches the DB gets a Zod schema. Parsed after auth, before the DB call, with explicit bounds on every string field. Not optional. Not "we'll add it later."
Later is how you end up with 12 files like that.