TLDR: Validating at the form layer feels like enough — until you ship. Validate once, at the trust boundary, every time.

The Wall I Kept Hitting

I was deep in two projects at once — a practice-management app I built for a law firm client, and a patient intake tool I built for oncology facilities.

Both apps had forms. Both apps had validation on those forms.

And both apps kept surprising me with garbage in the database.

NaN in an ID route param. A facility.website value rendering a broken iframe. A contact lookup silently turning a free-text email into a wildcard LIKE query that matched way more rows than intended. Unbounded pagination inputs. Free-text fields with no length cap eating into the database and the AI context window.

The forms weren't broken. The validation was just… in the wrong place.

What I Tried First (That Didn't Work)

My first instinct — embarrassingly — was whack-a-mole.

Validate in this handler. Cap the length in that component. Fix the one form that's obviously broken, ship, move on.

Then Apollo, my AI agent (Apollo, the Claude Code harness I run all day), suggested a related shortcut: a field was causing friction on the KOL CRM I built for an ecommerce business, so it proposed making audienceSize globally optional to unblock the form.

I pushed back on that one immediately.

Making a field blanket optional erodes data quality — audienceSize matters for KOL scoring on YouTube, Instagram, and podcasts. The right answer was to make it optional only when someone picks "Website," because websites genuinely don't have audience counts. So we used a Zod .superRefine() to require it conditionally, and hid the UI input only for that one platform.

That instinct — narrow the fix to the specific case that needs it, don't loosen the whole rule — turned out to be the exact principle I needed to apply everywhere else.

The Fix That Actually Worked

Instead of patching handlers one by one, I started thinking about the trust boundary (the edge where outside input first enters the system) and centralizing validation there.

Concretely:

  • Route param validationparseRouteId() extracted and shared; any :id that's NaN, zero, or negative gets rejected before it touches the DB
  • Pagination inputs — clamped centrally in one utility, not in each list handler separately
  • Free-text fields — length-capped at the server action layer, not just in the <input maxLength>
  • URL fields — a shared validateUrlScheme() guard that runs before anything renders a link, an anchor, or an iframe src — extracted once, used in three places
  • Email lookupsilike wildcard characters stripped from the free-text input before the query runs, not after

The pattern is always the same: one place that owns the rule, called at the edge.

Why This Matters to Me

I used to think of input validation as a UX concern — you validate at the form so the user gets a nice error message.

It's not. It's a systems concern.

The form can be bypassed. The UI can be wrong. Your AI agent will hand you suggestions that loosen the rule when it should tighten it. The only place you can truly trust is the boundary where outside data first becomes inside data.

Validate there, centrally, and you stop playing whack-a-mole forever.