← Back to Blog

Case Study: ~100 API Routes in civitai/civitai Return 500 Instead of 400

By Nark Team

civitai/civitai is the largest open-source Stable Diffusion model-sharing platform, with over 1.2 million registered users. We scanned their TypeScript codebase with Nark and found approximately 100 API routes that call zod.parse() on request input without a try-catch. Every one of those routes returns a 500 Internal Server Error when a client sends malformed input — instead of a 400 Bad Request with a structured validation error.


The Finding

Civitai uses zod for input validation across their Next.js API routes. The standard pattern throughout the codebase is:

export default PublicEndpoint(async function handler(req, res) {
  const { modelId, modelVersionId } = querySchema.parse(req.query);
  // ...
});

zod.parse() throws a ZodError when input does not match the schema. If the caller does not catch it, the error propagates to Next.js's default error handler, which returns a generic 500.

This means any client sending a request with missing, misspelled, or wrongly-typed query parameters gets a 500 — an error that signals a server bug, not a client input problem.


Why This Matters

Public endpoints are affected. Several of the unguarded routes use PublicEndpoint, which requires no authentication. Anyone on the internet sending a malformed query string triggers the 500.

Error monitoring pollution. Every malformed client request appears in Sentry/Axiom as an unhandled server exception. The signal-to-noise ratio in error tracking degrades because client input problems are indistinguishable from actual server bugs.

The structured error is lost. ZodError carries a .issues array with the exact fields that failed, the expected types, and human-readable messages. When the error bubbles up unhandled, all of that structured detail is replaced by a generic 500 status with no body.


Specific Routes Affected

The scan identified violations across every endpoint type in the codebase:

FileEndpoint TypeInput SourceRisk
src/pages/api/generation/history/callback.tsPublicEndpointreq.queryNo auth required to trigger 500
src/pages/api/auth/freshdesk.tsMixedAuthEndpointreq.queryFreshdesk SSO callback with malformed params crashes
src/pages/api/image/ingestion-results.tsAuthedEndpointreq.queryMalformed ids (expects comma-delimited numbers)
src/pages/api/import.tsModEndpointreq.querySchema requires z.string().trim().url()
src/pages/api/internal/get-presigned-url.tsJobEndpointreq.queryMissing numeric id from job runner
src/pages/api/mod/action-report.tsWebhookEndpointreq.bodyMissing reportId or invalid status enum
src/pages/api/webhooks/reingest-images.tsWebhookEndpointreq.bodyEmpty array fails z.array(z.number()).min(1)

These 7 routes are a representative sample. The full breakdown across the codebase:

AreaApproximate Count
api/admin/temp/ (migration/backfill scripts)~25
api/mod/ (moderator endpoints)~15
api/admin/ (admin endpoints)~12
server/ (services, jobs, routers)~10
shared/orchestrator/ (image generation configs)~8
pages/ (SSR getServerSideProps)~8
api/internal/ (internal service endpoints)~6
api/ (auth, generation, image)~5
api/webhooks/ (webhook handlers)~3
components/, utils/ (client-side)~3

The Endpoint Wrappers Do Not Catch ZodError

Civitai uses custom endpoint wrapper utilities — PublicEndpoint, AuthedEndpoint, WebhookEndpoint, ModEndpoint, JobEndpoint — defined in src/server/utils/endpoint-helpers.ts. These wrappers add authentication checks and CORS handling but do not have a global try-catch for ZodError.

This means every route must handle its own validation errors. The wrappers could be a single point of fix — adding a ZodError catch to the endpoint wrapper would protect all ~100 routes at once — but that change does not exist today.


The Fix

Zod provides .safeParse() as the non-throwing alternative to .parse(). Instead of throwing on invalid input, it returns a discriminated union:

// Before — throws ZodError, becomes unhandled 500
const { modelId } = querySchema.parse(req.query);
// After — returns structured result, caller decides
const result = querySchema.safeParse(req.query);
if (!result.success) {
  return res.status(400).json({ error: result.error });
}
const { modelId } = result.data;

This pattern already exists in approximately 15 routes in the civitai codebase (e.g., src/pages/api/v1/models/index.ts, src/pages/api/download/models/). The convention is to pass the full ZodError object — not just .message — so clients get the structured .issues array.


Why This Pattern Exists

Zod's API defaults to the throwing version. When developers write schema.parse(input), they get a clean destructured result on the happy path. The non-throwing alternative (safeParse) requires an extra conditional check before accessing .data. In a codebase with hundreds of API routes, the shorter .parse() call is the path of least resistance — and code review focuses on business logic, not whether every validation call handles the error case.

The 73% violation rate we found across 239 zod-using repositories in our bulk scan of 6,283 repos suggests this is the default state for most TypeScript projects using zod. Civitai is not an outlier — they are representative of how the ecosystem uses the library.


Broader Context

From our bulk scan: 239 repos use zod. 174 have violations (73%). 1,808 total unguarded .parse() calls.

The irony is that zod exists to make input validation safe and ergonomic. It does — but only if you use safeParse(). The library's default API (parse) throws on invalid input, and most projects never switch to the non-throwing version.


Source Data

  • Scan tool: Nark (Profile: zod)
  • Repository: civitai/civitai on GitHub
  • Scan date: April 2026
  • Total violations in repo: 223 (103 from zod, remainder from other packages)
  • Bulk scan aggregate: 6,283 repos scanned, 239 use zod, 73% have at least one unguarded .parse()