Skip to main content

Zod at Trust Boundaries: Env Gates, tRPC, and When Not to Parse Twice

Cowpin

Cowpin

10/18/2026

#zod #typescript #trpc #devops #validation #saas
Zod at Trust Boundaries: Env Gates, tRPC, and When Not to Parse Twice

TypeScript types disappear at runtime. as SomeType on a webhook body, a Pinboard query string, or a model’s JSON output is a promise you cannot keep. Zod is our default answer at trust boundaries — places where bytes cross from “someone else” into your process — not a mandate to schema every internal function.

Cowpin is a bookmark library with a Pinboard-compatible API, MCP gateway, and normal web app. Strangers hit HTTP edges; agents hit JSON-RPC; Vercel runs a production build that must not promote without a database URL. This post is the pattern we use, with enough detail to copy without adopting our whole stack.

Boundary 1: Production build env (fail the deploy, not 3 a.m.)

Preview deploys can limp along with missing config. Production builds should not.

We parse a small z.object when VERCEL_ENV=production:

  • DATABASE_URL — required non-empty string (Prisma + deploy-time drift checks).
  • NUXT_PUBLIC_SITE_URL — optional URL (empty string coerced to “unset”).

Non-production builds skip the gate so contributors are not blocked locally. The check runs in validate-deploy-env before nuxt build, alongside unit tests in the same Vercel buildCommand — so a bad env never becomes the live artifact.

export const productionBuildEnvSchema = z.object({
  DATABASE_URL: z
    .string()
    .min(1, "DATABASE_URL is required on production builds"),
  NUXT_PUBLIC_SITE_URL: z
    .string()
    .url()
    .optional()
    .or(z.literal("").transform(() => undefined)),
});

Lesson: keep env schemas tiny. Do not mirror every Lambda secret in Zod on the web build — validate secrets where those binaries boot (monitor, cron workers).

Boundary 2: tRPC procedure inputs (strangers + your own UI)

Most packages/api procedures take z.object({ … }) inputs. That gives you:

  1. Runtime rejection of garbage before business logic.
  2. Shared truth between server and generated client types.
  3. Field paths that map to form errors on the web app (@vee-validate/zod).

Anti-pattern: calling .parse again on the same payload inside the procedure body. tRPC already validated it once. Re-parsing is CPU and drift (two schemas diverge).

Still stringly at some edges: legacy Pinboard v1 query params and a few MCP paths use manual checks — we extend Zod incrementally there rather than pretending one big-bang migration.

Boundary 3: LLM structured output (JSON is lying until parsed)

When a model returns JSON for tags, summaries, or tool plans, JSON.parse is not enough. We use Zod (or provider helpers typed with z.infer) and retry or fall back when parse fails — cheaper than debugging poisoned rows in Postgres.

Same rule as tRPC: validate once at the boundary between model output and your domain types, not at every helper.

Boundary 4: Webhooks (after crypto, before business logic)

Stripe (and similar) must pass signature verification first. Zod belongs after you trust the sender — to shape-check event.data.object fields you actually use. Narrow objects beat mega-schemas copied from vendor docs that change quarterly.

Where we deliberately do not use Zod

SkipWhy
Mirror every Prisma modelDB types already exist; duplication rots.
Hot inner loopsParse at the edge; pass typed values inward.
Huge schemas in client bundlesSplit minimal DTOs for Vue; keep heavy rules server-side.
“All handlers must use Zod” policiesIntegration glue (Zapier, one-off scripts) can stay tested JS until inputs stabilize.

Pros and cons (honest)

Worth it: untrusted HTTP, deploy env, forms, LLM JSON, anything a security review would label “input validation.”

Not worth it: re-validating tRPC input, replacing Prisma with parallel Zod entities, or validating the same webhook in middleware + handler + repository.

If you are building agents on top of Cowpin

Validation on your side still matters: MCP tool args and Pinboard-shaped scripts should treat the gateway as untrusted for your downstream systems. Cowpin exposes HTTP MCP and a verifiable stdio bridge (npx @cowpin/mcp-server --self-test when published); procedure-level rules live in our API layer.

For bookmark automation without agents, start with the Pinboard API habits post and Connect Cursor when you want memory in the IDE.


Further reading (engineering backlog on our site): deploy gates without GitHub Actions, staged releases that never roll back, and offline MCP contract tests — same “trust boundary” theme, different layer.