Rendering & Caching
How Dirstarter renders and caches pages with Next.js Cache Components, Suspense and "use cache"
Dirstarter runs on Next.js Cache Components (cacheComponents: true in next.config.ts). Rendering is dynamic by default, and public pages are blocking routes: each page awaits its data in the body and ships complete, in-order HTML — no skeleton shell, no out-of-order streaming. <Suspense> is reserved for genuinely slow or non-critical islands (ad slots, related listings, per-user actions).
Two properties follow from that, and every rule on this page exists to protect them:
next buildnever touches the database. It can run in CI (GitHub Actions) without access to your production database.- Public pages render fresh from Postgres on every request — a small catalogue that answers any query in 1–5 ms, so a full page renders in ~100–350 ms. Repeat traffic is absorbed by the CDN, not a per-query cache. At those latencies no loading UI belongs in between: document loads paint top-down, and client navigations hold the previous page until the complete destination is ready.
The build is database-free
bun build is just next build; it never runs migrations. Pending migrations are applied by a separate step that runs before it, wired up in vercel.json:
{
"buildCommand": "bun run db:migrate deploy && bun run build"
}Only that first step needs a reachable database (see Prisma). next build itself must stay database-free, which is what lets it run in CI with no production credentials. The build prerenders each shell, executing any I/O it reaches in a prerenderable position, so a database read there would run at build time and require a live database. The fix is connection() (see below): defer the read to request time.
Param routes (/[slug], /blog/[slug], …) have no generateStaticParams and fetch their row at request time, so the slug is never needed at build.
You can verify a change kept the build database-free by building against an unreachable database, which must exit 0:
DATABASE_URL="postgresql://x:[email protected]:1/x" bun run buildDeferring reads with connection()
Awaiting connection() marks the surrounding render as request-time, so the build skips it. It changes only when the code runs (build → request), so caching, output and correctness are unchanged.
import { connection } from "next/server"
const AdBanner = async () => {
await connection() // lives in the shared layout shell → would run at build without this
const ad = await findAdWithFallback({ type: "Banner" })
// …
}Add await connection() only when both are true:
- the code is in a prerenderable position (not already behind a dynamic value), and
- it reads the database (or other uncached I/O).
| Position | Needs connection()? |
|---|---|
Query components that read the database (ToolQuery, TagQuery, PostQuery, CategoryQuery) | Yes |
Ad components (AdBanner, AdCard, AdvertisePickers) — their queries filter by new Date() | Yes |
Static GET route handler that reads the database (RSS feeds, sitemap/[id]) | Yes |
Page body behind params (detail pages await their slug first) | No, params already gates it |
Route handler that reads the request (req, headers, cookies, searchParams) | No, already dynamic |
Anything with no database read (sitemap.xml index) | No |
Parsing searchParams is not a sufficient gate. It keeps the build database-free, but the dev prerender validator walks past it and flags anything unstable it reaches — a query filtering by new Date() (like the ad queries) errors with "encountered the unstable value new Date() while prerendering". The rule: a component whose job is a request-time database read gates itself with connection(), first thing in the body.
Don't sprinkle it everywhere. On an already-dynamic route handler connection() is a no-op that misleads the next reader into thinking the route reads request data. Reading the request already makes a handler dynamic, which is why every /api/* handler is exempt.
Client hooks that read the URL
A Client Component that calls usePathname() or useSearchParams() during a param-route shell prerender throws:
Next.js encountered URL data … in a Client Component outside of <Suspense>The URL simply isn't known when a param-less shell is built. Shared chrome (the header, nav links, the command dialog) renders in every shell, so it must not read the URL at render time. There are two escape hatches.
1. Wrap it in <Suspense>
The preferred option when the component has no meaningful server-rendered output, like a dialog that's invisible until opened or a mobile nav that's closed on load. The boundary keeps the URL read out of the static shell, and the fallback costs nothing.
{/* Reads the URL; the boundary keeps it out of the static
shell. Invisible until opened, so no fallback. */}
<Suspense fallback={null}>
<Search />
</Suspense>2. Gate it behind useMounted
Use this when the component must render something meaningful on the server: a nav link still needs its label and href in the shell, only its active state depends on the URL. The base NavLink stays presentational (isActive is an opt-in prop), and a small ActiveNavLink wrapper resolves the active state after hydration. It renders a plain NavLink on the server and first client render, then swaps in the URL-aware variant once mounted, so usePathname never runs during the shell prerender.
export const ActiveNavLink = ({ exact, ...props }: ActiveNavLinkProps) => {
const mounted = useMounted()
// Render a URL-free NavLink until mounted, then the URL-aware variant.
return mounted ? <ActiveNavLinkInner exact={exact} {...props} /> : <NavLink {...props} />
}
const ActiveNavLinkInner = ({ exact, href, ...props }: ActiveNavLinkProps) => {
const isActive = useIsActive(href, exact) // usePathname, resolved only after mount
return <NavLink href={href} isActive={isActive} {...props} />
}The URL check itself lives in a useIsActive(href) hook (hooks/use-is-active.ts). The pre-hydration output matches the server render, so there's no hydration mismatch; the URL-derived state applies a tick after load.
Blocking routes
Every public page is a blocking route: it awaits its data directly in the page body and ships in-order HTML with no fallback shell. The old pattern — static shell + <Suspense> skeletons + content streamed as out-of-order chunks — baked a skeleton-first paint into every view (including CDN cache hits) for data that arrives in well under 500 ms. Blocking means document loads paint top-down, client navigations hold the previous page (browser paint-holding) until the complete destination is ready, and the real content sits in the initial HTML in reading order — which is what search engines index in the first wave.
Blocking segments export:
// Blocking route: data is awaited in the body so HTML ships in-order with no skeleton shell.
export const instant = falseinstant = false (stable since Next.js 16.3) is a validation opt-out, not a rendering switch — the structural change is awaiting data in the body instead of behind <Suspense>. Keep the export on each page, not hoisted to a layout: the static-shell check accepts a layout-level copy, but dev-time instant-navigation validation still checks navigations between sibling pages below a shared layout, and the Next.js docs recommend placing the config as low as possible.
The authenticated dashboard (/app/*, which redirects anonymous users before any HTML is sent) and one-shot transactional pages (Stripe checkout confirmations, magic-link landings) block for their own reasons and carry the same export. It replaces the old dynamic = "force-dynamic", which Cache Components forbids.
Reads: Postgres is the source of truth
Public reads in queries.ts are plain Prisma calls against our own Postgres — a few-hundred-row catalogue that answers any query in 1–5 ms. They are not wrapped in "use cache". A per-query cache is keyed on the function's serialized arguments (search params, slugs, arbitrary where objects), so its key space is unbounded — a crawler can walk a category×stack cross-product into tens of thousands of entries — while saving nothing over a 1–5 ms round trip. So the reads run every request, and an admin edit is visible on the next request with no invalidation step.
"use cache" is reserved for genuinely expensive or external work — in the boilerplate, only the Stripe API calls in server/products/queries.ts:
import { cacheLife, cacheTag } from "next/cache"
export const findStripeProducts = async () => {
"use cache"
cacheTag("stripe-products")
cacheLife("hours")
return serialize(await stripe.products.list({ active: true, limit: 100 }))
}Rule of thumb: do cache a read when it calls an external API or does measurably slow aggregation — give it a cacheTag (broad + per-key) and a cacheLife, and invalidate it with revalidateTag from the mutation or webhook that changes it (e.g. revalidateTag("stripe-coupon") on the Stripe webhook). A Prisma findMany / findFirst / count against your own database is neither expensive nor external — leave it uncached, and its mutations then need no revalidation.
CDN and edge caching
With no per-query cache, repeat traffic is absorbed at the CDN edge. Public pages set an explicit Cache-Control via a headers() block in next.config.ts, which overrides the streamed no-store, so a CDN that respects origin headers caches the full HTML:
async headers() {
return [
{ source: "/", headers: [{ key: "Cache-Control", value: "public, s-maxage=60, stale-while-revalidate=3600" }] },
{ source: "/:path(blog|categories|collections|tags)/:rest*", headers: [{ key: "Cache-Control", value: "public, s-maxage=120, stale-while-revalidate=3600" }] },
{ source: "/:slug((?!app$|auth$|submit$)[^/]+)", headers: [{ key: "Cache-Control", value: "public, s-maxage=120, stale-while-revalidate=3600" }] },
]
}s-maxage is kept short (60–120 s) so an admin edit surfaces within a couple of minutes; the long stale-while-revalidate window is what actually absorbs a crawler burst — the edge serves the stale copy instantly and refreshes it in the background. Short s-maxage costs freshness, not offload.
Per-session and authenticated routes must never be edge-cached. Two kinds:
- Per-request data —
/submit/[slug]reads a live tool tier per request. It's multi-segment, so the rules above never match it. - Session-gated —
/app,/auth, and/submitare gated inproxy.tson the session cookie, so their response (redirect vs page) is session-dependent. The single-segment/:slugrule would otherwise match the roots, so it excludes them:/:slug((?!app$|auth$|submit$)[^/]+). As a backstop,proxy.tsalso stampsCache-Control: private, no-storeon its redirects, so a gated redirect is never cacheable regardless of the header rules.
Everything excluded keeps the framework's private default.
A CDN layer is not reached by revalidateTag — there is no on-demand purge. Freshness is bounded by s-maxage (minutes here) and refreshed via stale-while-revalidate. Keep s-maxage short enough that the staleness window is acceptable for your edits.
Assets, images and ?_rsc= payloads are safe to cache regardless.
Troubleshooting
| Symptom | Fix |
|---|---|
Build fails with Can't reach database / P1001 during "Generating static pages" | A database read is prerendering. Add connection() to defer it to request time. |
URL data … in a Client Component outside of <Suspense> | A shell client component reads usePathname/useSearchParams at render. Wrap it in <Suspense> or gate it behind useMounted. |
Route segment config "dynamic" is not compatible with cacheComponents | Replace export const dynamic with instant = false (to block) or a <Suspense> boundary (to stream). |
encountered the unstable value \new Date()` while prerendering` | A time-dependent read sits in a prerenderable position. Add await connection() before it. |
| A page shows stale data after publish | Expected within the s-maxage window (~1–2 min): the edge serves cached HTML and refreshes via stale-while-revalidate. Shorten s-maxage in next.config.ts if edits must surface faster. |
Last updated on