Engineering
Your Next.js build should not need your database
Prerendering CMS pages made our Docker build open a Postgres connection. It cost five deploys and a published database. Here is what we did instead.

If a page reads from your database, render it on demand, not during the build. Prerendering it means your build container needs a route to Postgres, and on self-hosted infrastructure that route is the most host-specific thing you own. We learned this by paying for it.
What we were doing
This site runs Next.js with Payload CMS in-process, on Postgres, in Docker, on a single VPS. Six service pages moved into the CMS. They were dynamic segments with a generateStaticParams that queried Payload for every slug, so all six were prerendered into HTML at build time. That is the configuration every tutorial shows you, and on Vercel it works, because the build runs inside the same network as the managed database.
Ours does not. The build runs in a Docker build container, and a build container does not join the overlay network the services talk over. So the hostname the application resolves at runtime does not resolve at build time, and the build fails with a DNS error rather than anything that mentions networking.
What did not work
The obvious fix is to make the database reachable from the build: publish Postgres on the host and point the build at the Docker bridge gateway. We did that. It took five deploys to find the address.
Every answer online says the gateway is 172.17.0.1. On this box, inside the builder, it is not. We stopped guessing and made the build print its own default route:
RUN ip route | awk '/default/ {print "gateway is", $3}'It answered 172.16.0.1 — specific to this host’s builder, not a Docker default, and not something any amount of reading would have told us.
That got the build passing, and left two bills. The database had to be published to the host to make it reachable at all, reversing a decision whose code comment read "publishing 5432 on a public IP is how Postgres instances end up in ransom notes". And the build now depended on one machine’s network layout, so rebuilding on EC2 would mean rediscovering that address, with a failed build as the only clue.
The documented workaround was worse
Payload documents a build mode for exactly this situation, next build --experimental-build-mode compile, which skips the data-fetching phase. It also stops inlining NEXT_PUBLIC_* environment variables.
On this site one of those values decides whether the response carries X-Robots-Tag: noindex. With it absent, a production build serves a correct-looking site that quietly tells every crawler to go away. The failure is invisible until organic traffic disappears weeks later.
A workaround that trades a loud build failure for a silent production one is not a workaround.
What we do instead
The dynamic segment stays, and its generateStaticParams returns an empty list. Deliberately — not as a fallback.
// app/(frontend)/services/[slug]/page.tsx
// Empty on purpose. Returning real slugs would query Payload, which would open a
// Postgres connection during `next build` and tie the build to one host's network.
// Unknown slugs still 404, because `dynamicParams` stays at its default.
export async function generateStaticParams() {
return [];
}Nothing is prerendered. The first request for a real slug renders the page in about 200ms and Next caches it; every request after that is a cache hit. When an editor publishes a change, the collection’s afterChange hook calls revalidatePath and the page updates without a deploy.
Postgres goes back to publishing nothing. The build secrets and the gateway detection are deleted. Nothing host-specific remains: moving to another server means restoring the database and deploying the containers.
Two things that look like fixes and are not
export const revalidate does not keep the build away from the database
Setting a revalidation period on a page does not stop Next prerendering it. It still renders the initial payload at build time and then refreshes on a timer afterwards. If that render reads your database, your build reads your database. The route has to be dynamic, not merely revalidating.
A page with no route parameters has no generateStaticParams to empty
We hit this the moment we added a blog index. There is no [slug] on /blog, so there is nothing to return an empty list from, and Next prerendered it:
Error occurred prerendering page "/blog".
cannot connect to Postgres: getaddrinfo ENOTFOUND baseThe fix for a parameter-less page is export const dynamic = 'force-dynamic'. Same for anything Next evaluates at build: our sitemap and RSS feed are route handlers marked dynamic with their own cache headers, rather than the typed metadata files, precisely because they list posts from the CMS.
What it costs, stated plainly
This is a trade, not a free win, and the limits are worth naming because they decide whether it suits you:
- The first request after a deploy is slower. About 200ms here, then cached. Fine for content pages; measure it before doing this to something latency-sensitive.
- A database outage with a cold cache breaks those pages. Prerendered HTML would have survived it. We accept that: the pages that matter most are fully static and unaffected, and a health endpoint reports the database so the outage is visible rather than inferred.
- Pages that are no longer files in the build output drop out of any CI check that inspects those files. Ours asserted one social-card image per page by reading the built HTML. That check had to be rewritten.
- If you are on a managed database with a hostname reachable from anywhere, you can have both. We looked at that and chose not to add a monthly bill and an external dependency to a site this size.
We also decided against a fallback: if Postgres is unreachable the page fails rather than serving the copy committed in the repository. Visibly broken beats silently stale, because stale wording is the kind of thing nobody notices for a month.
The rule we kept
If it renders from a database, it renders on demand. Written down, with the reasoning, as an architecture decision record, because the next person to see an empty generateStaticParams will read it as an oversight and "fix" it. That one line is worth more than the fix itself: the build stopped being the thing that knows where our database lives, and that is what makes the stack portable.
If you hit the same wall, the short version is: do not teach your build how to reach your database. Teach your pages to render later. And if you are working through this on your own stack, tell us what broke — the failure modes above cost us real deploys, and the list is almost certainly not complete.