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:
next buildnever touches the database. It can run in CI (GitHub Actions) without access to your production database.- Public pages serve a prerendered shell instantly, then stream their data, which is cached at the application layer with
"use 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:
{
"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 shells and warms every "use cache" function it can reach, so any cached database read in a prerenderable position 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 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()? |
|---|---|
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.
{/* 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
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:
export const unstable_instant = falseThis 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.
Cached reads and revalidation
Expensive public reads live in queries.ts files and are cached with "use cache", a tag, and a lifetime. Mutations invalidate by tag.
import { cacheLife, cacheTag } from "next/cache"
export const findTool = async ({ where }: Prisma.ToolFindFirstArgs = {}) => {
"use cache"
cacheTag("tool", `tool-${where?.slug}`) // broad + per-slug tags
cacheLife("infinite") // until a tag invalidation
return db.tool.findFirst({ where, select: toolOnePayload })
}Every mutation, and the publish cron, calls revalidate({ tags: [...] }), so content is fresh the moment it changes with no manual step. On publish that means revalidateTag("tool-${slug}") plus revalidateTag("tools").
A slug change must revalidate both the old and the new slug tag. Use detailTags("tool", oldSlug, newSlug) for that.
CDN and edge caching
By default the app streams request-time content, so the HTML document is served no-store and is not edge-cacheable. The shell is still prerendered and served fast from the origin, and "use cache" keeps the data fast. This is the simplest correct setup: the application cache is the single source of truth, so there's no CDN entry to purge and no stale page after publishing.
To offload the origin you can force edge caching with a headers() block in next.config.ts: an explicit Cache-Control: public, s-maxage=…, stale-while-revalidate=… overrides the streamed no-store, so a CDN configured to respect origin headers will cache the HTML.
A CDN layer is not reached by revalidateTag. If you edge-cache HTML you must purge the CDN yourself on publish or edit, otherwise a freshly published tool keeps showing its pre-publish preview for the whole TTL.
Prefer leaving HTML uncached at the CDN unless origin load demands otherwise. 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 unstable_instant = false (to block) or a <Suspense> boundary (to stream). |
| A page shows stale data after publish | The application cache is fine: revalidateTag ran. A CDN HTML cache is holding it: purge the CDN or stop edge-caching that HTML. |
Last updated on