Documentation

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: every route prerenders a static shell at build time and streams request-time content into <Suspense> boundaries.

Two properties follow from that, and every rule on this page exists to protect them:

  1. next build never touches the database. It can run in CI (GitHub Actions) without access to your production database.
  2. Public pages serve a prerendered shell instantly, then stream their data read fresh from Postgres — a small catalogue that answers any query in 1–5 ms. Repeat traffic is absorbed by the CDN, not a per-query cache.

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:

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], …) render a param-less shell and fetch their row at request time, cached per-slug, 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 build

Deferring 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.

components/web/ads/ad-banner.tsx
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:

  1. the code is in a prerenderable position (not already behind a dynamic value), and
  2. it reads the database (or other uncached I/O).
PositionNeeds connection()?
Component in the layout/shell that reads the database (AdBanner, CountBadge)Yes
Index listing with no searchParams that reads the database (CategoryQuery, PostQuery)Yes
Static GET route handler that reads the database (RSS feeds, sitemap/[id])Yes
Component behind searchParams (ToolQuery) or params (detail page bodies)No, already request-time
Route handler that reads the request (req, headers, cookies, searchParams)No, already dynamic
Anything with no database read (sitemap.xml index)No

Don't sprinkle it everywhere. On an already-dynamic route 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.

components/providers.tsx
{/* 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.

components/web/ui/active-nav-link.tsx
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

Some routes must block on the request instead of streaming a shell: the authenticated dashboard (which redirects anonymous users before any HTML is sent) and one-shot transactional pages such as Stripe checkout confirmations or magic-link landings. Those segments export:

app/[locale]/(app)/layout.tsx
export const unstable_instant = false

This opts the segment out of static-shell prerendering. It replaces the old dynamic = "force-dynamic", which Cache Components forbids.

unstable_instant is the current key in Next.js 16.2; it becomes the stable instant in 16.3.

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:

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:

next.config.ts
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$)[^/]+)", 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/advertise/success and /submit/[slug] confirm a Stripe session or read a live tool tier. They're multi-segment, so the rules above never match them.
  • Session-gated/app, /auth, and /submit are gated in proxy.ts on the session cookie, so their response (redirect vs page) is session-dependent. The single-segment /:slug rule would otherwise match the roots, so it excludes them: /:slug((?!app$|auth$|submit$)[^/]+). As a backstop, proxy.ts also stamps Cache-Control: private, no-store on 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

SymptomFix
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 cacheComponentsReplace export const dynamic with unstable_instant = false (to block) or a <Suspense> boundary (to stream).
A page shows stale data after publishExpected 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

On this page

Join hundreds of directory builders

Build your directory, launch, earn

Don't waste time on Stripe subscriptions or designing a pricing section. Get started today with our battle-tested stack and built-in monetization features.

Get Lifetime Access