DEV Community

Pavel Gajvoronski
Pavel Gajvoronski

Posted on

Next.js builds succeed locally, crash in Docker — the RSC prerender trap

Our Docker build worked for three milestones without a problem. We added a public marketing page that fetches aggregate stats from the database, pushed to CI, and got this:

Invalid `prisma.span.findMany()` invocation:
  error: Environment variable not found: DATABASE_URL.

Export encountered errors on following paths:
  /(marketing)/mcp-trust/page: /mcp-trust
Enter fullscreen mode Exit fullscreen mode

DATABASE_URL was set in the Railway environment. It was set in .env. The app ran fine locally. The build kept failing.

The problem

We're building Tracehawk — an AI observability platform. We added a public /mcp-trust page that shows aggregate quality scores for MCP servers. The page is an RSC (React Server Component) that calls our getMcpTrustScores() function, which queries Prisma:

// src/app/(marketing)/mcp-trust/page.tsx
export default async function McpTrustPage() {
  const scores = await getMcpTrustScores(); // calls prisma.span.findMany(...)
  return <McpTrustTable scores={scores} />;
}
Enter fullscreen mode Exit fullscreen mode

This works perfectly at runtime — the server has DATABASE_URL, Prisma connects, query runs. But during next build, Next.js tries to statically pre-render every RSC page it can. The build process runs inside the Docker builder stage. The builder stage has no access to production secrets. No DATABASE_URL. Prisma throws. Build fails.

# Dockerfile (simplified)
FROM node:20-alpine AS builder
RUN npm ci
RUN npm run build   # ← next build runs here, inside the builder layer
                    # ← no DATABASE_URL in this environment
Enter fullscreen mode Exit fullscreen mode

What we tried first

We assumed the env var wasn't being passed to Docker. We added ARG DATABASE_URL and --build-arg DATABASE_URL=$DATABASE_URL to the Docker build command. This is both wrong and dangerous — build args get baked into the image layer, which means your database credentials end up in the image history. Don't do this.

We also tried adding DATABASE_URL to the Railway build environment. Railway doesn't expose runtime secrets to the builder stage — by design, for good reason.

Why it only affects some pages

Next.js App Router automatically detects dynamic pages by looking for calls to cookies(), headers(), or auth() in the component tree. Any page that calls these functions is marked as dynamic and skipped during static pre-render.

Our dashboard pages all call auth() (NextAuth v5), so they're automatically dynamic. The marketing page is public — no auth check, no cookie read, no header access. Next.js sees it as static-safe and pre-renders it at build time.

The Prisma call is invisible to the static analysis. Next.js doesn't know your function talks to a database.

The fix

One line at the top of the RSC page file:

// src/app/(marketing)/mcp-trust/page.tsx
export const dynamic = "force-dynamic";  // ← this line

export default async function McpTrustPage() {
  const scores = await getMcpTrustScores();
  return <McpTrustTable scores={scores} />;
}
Enter fullscreen mode Exit fullscreen mode

force-dynamic tells Next.js: skip static pre-render for this page entirely. Render per-request only. The Prisma call now only runs at request time when DATABASE_URL is available.

The Redis cache in getMcpTrustScores() (1h TTL) means the per-request rendering is cheap — first request hits the DB, subsequent requests hit the cache.

// src/lib/mcp-trust-score.ts
export async function getMcpTrustScores(): Promise<McpTrustScore[]> {
  // check Redis cache first — 1h TTL
  const cached = await redis.get("mcp-trust-scores");
  if (cached) return JSON.parse(cached);

  // DB query only on cache miss
  const scores = await computeFromDb();
  await redis.set("mcp-trust-scores", JSON.stringify(scores), "EX", 3600);
  return scores;
}
Enter fullscreen mode Exit fullscreen mode

What we learned

  1. force-dynamic is required for any public RSC page that touches the DB. If your component calls cookies(), headers(), or auth(), Next.js auto-detects it as dynamic. If it doesn't (public page, no auth), you must declare it explicitly.

  2. Never pass database credentials as Docker build args. They end up in the image layer history. Pass secrets only at runtime via environment variables. The builder stage should have no secrets.

  3. The error message is confusing because it names the variable, not the cause. "Environment variable not found: DATABASE_URL" reads like a config problem, not a static analysis problem. The real cause is buried in the Export encountered errors line below it.

  4. Cache the DB call if you're going force-dynamic on a high-traffic page. force-dynamic means every request triggers the component. For a public page with expensive aggregation queries, add a Redis or in-memory cache layer — otherwise you just converted a build-time query into a per-request one.

  5. Dashboard pages are safe by default because of auth. Any page that calls auth() or reads cookies is automatically dynamic. This is why we didn't hit this earlier — all our dashboard pages have auth guards.

The class of pages this affects

Any RSC page that is:

  • Public (no auth guard, no cookie/header reads)
  • Fetches from an external source (DB, API, Redis)

Common examples:

  • Public stats/leaderboard pages
  • Sitemap generation that queries the DB
  • Landing pages with "X customers" counters
  • Blog post list pages fetching from a CMS

What's next

The right long-term fix is to make Next.js throw a build-time warning when a page has no dynamic markers but contains what looks like a DB call (Prisma, Drizzle, SQL client). It can't catch everything, but static analysis on import chains would catch the common case.

A simpler improvement we haven't done: a CI check that verifies every page under (marketing)/ that has a DB import also has export const dynamic = "force-dynamic". A grep-based pre-commit hook would take ten minutes to write.

Over to you

  • Has anyone built a lint rule or static check that catches this class of missing force-dynamic declarations?
  • How do you manage the tension between wanting static pages (fast, cheap CDN) and needing fresh data from the DB — what's your caching strategy for public RSC pages?
  • Have you seen other cases where Next.js's automatic dynamic detection doesn't fire when you'd expect it to?

Top comments (0)