SSR, Rendering & Caching
OZEAON is a Next.js App Router app that runs on Cloudflare Workers through the OpenNext adapter. This page explains how a route ends up prerendered or dynamic, why the Next.js Cache Components model is switched off, what the OpenNext adapter is (and is not) configured to cache, and which HTTP headers the app sets.
Overview
Section titled “Overview”Rendering is decided by which Supabase client a route uses, not by route-segment directives. A server component that calls createClient() from src/lib/supabase/server.ts reads cookies(), so Next.js marks the route dynamic. A route that only uses createPublicClient() from src/lib/supabase/public.ts has no cookies and can prerender. See Supabase Client Patterns for the factories themselves.
Caching is mostly left at defaults:
cacheComponentsisfalseinnext.config.ts, on purpose. It stays off until the OpenNext Cloudflare adapter supports it; the postponed-state buffers would exceed the 128 MBworkerdmemory cap.- The OpenNext R2 incremental cache, D1 tag cache and Durable Object queue are not enabled. All three are commented out in
open-next.config.ts, anddefineCloudflareConfigis called with an empty object, so the adapter uses its own defaults. - The only explicit caching is two
Cache-Controlheader rules for image paths, set innext.config.ts.
The working rules live in the codebase in docs/ssr/rendering-rules-today.md. The future model ("use cache", cacheLife, cacheTag, Suspense boundaries) is written up in docs/ssr/cache-components-model.md. That model is not in use yet.
Rendering Rules Today
Section titled “Rendering Rules Today”- User-specific data: use
createClient()(orgetAuthUser()/getAuthUserOrRedirect(), which call it). The route becomes dynamic automatically. No directive is needed. - Public data only: use
createPublicClient()and agenerateStaticParamsbuilt with the helpers insrc/utils/generators/static-params.ts. They return a__placeholder__row when the database is empty or unreachable during a build, andnotFoundIfPlaceholderturns that row into a 404 at request time. - Mixed pages: render the public shell with
createPublicClient()and put the per-user part in its own server component that usescreateClient(), wrapped in<Suspense>. - No route-segment config. There are no
export const dynamic | revalidate | fetchCache | runtime | dynamicParamsexports anywhere insrc/. ESLint enforces part of this:eslint.rules.cache.mjserrors ondynamic = "force-dynamic"and"force-static". The other exports are kept out by convention only. - No Cache Components directives. The same rule file errors on
"use cache",cacheTag(),cacheLife()andupdateTag()with “Project doesn’t support cacheComponents yet.” - No public client in private routes.
eslint.config.mjsbans importing@/lib/supabase/publicfrom anysrc/app/**/(private)/**/*.tsxfile. - Invalidation uses
revalidatePath. Server Actions and route handlers callrevalidatePath(...)after writes. For examples, seesrc/lib/supabase/actions.tsandsrc/app/api/posts/route.ts. The code has norevalidateTagcalls, even thoughCLAUDE.mdanddocs/ssr/rendering-rules-today.mdmention them.
Some constructs in the tree only matter once Cache Components is on: DynamicMarker (a Suspense-wrapped await connection()) and the few connection() call sites. They do nothing today. Leave them in place, and don’t copy them into new code.
Architecture
Section titled “Architecture”flowchart LR
Browser --> Edge["Cloudflare edge"]
Edge --> Worker["OpenNext Worker"]
Worker --> Page{"Which Supabase client?"}
Page -->|"createClient() reads cookies"| Dynamic["Rendered per request"]
Page -->|"createPublicClient() only"| Static["Prerendered at build, served by adapter default cache"]
Dynamic -. "writes call revalidatePath" .-> Static
next.config.ts decides what is rendered and which headers go out. open-next.config.ts would decide where cached output is stored, but its overrides are commented out, so it currently changes nothing.
HTTP Headers
Section titled “HTTP Headers”headers() returns nothing in development. In production builds it sets two caching rules and a set of security headers:
{ source: "/_next/image/:path*", headers: [{ key: "Cache-Control", value: `public, max-age=0, s-maxage=${FOUR_DAYS}, stale-while-revalidate=86400` }] },{ source: "/images/:path*", headers: [{ key: "Cache-Control", value: `public, max-age=${FOUR_DAYS}, immutable` }] },FOUR_DAYS is four days in seconds. It is also used for images.minimumCacheTTL, so the header and the optimiser TTL change together. HTML responses (/:path*) get security headers but no Cache-Control.
The Content Security Policy is built from environment variables. Both storage hosts (storage-r2.ozeaon.com and storage-r2.ozeaon.dev) are always allowed in img-src and connect-src, because article content and attachments store absolute URLs from whichever environment uploaded them. Local Supabase origins are added when NEXT_PUBLIC_SUPABASE_URL points at localhost or 127.0.0.1. The check uses the URL rather than NODE_ENV because pnpm preview and pnpm ci:build emit production headers while still talking to a local Supabase.
Images
Section titled “Images”images.loader is "custom", pointing at src/lib/image-loader.ts. The loader rewrites production R2 URLs to Cloudflare Image Transformations (/cdn-cgi/image/...) on the storage domain. Cloudflare resizes, re-encodes and edge-caches the image there. Local /api/storage URLs get width and quality hints. Everything else passes through unchanged. See Media & Images and Storage (R2).
Failure Modes & Edge Cases
Section titled “Failure Modes & Edge Cases”- A public page imports
createClient()by accident. The page silently becomes dynamic. Nothing fails, andtscwon’t catch it; the only sign is in thepnpm buildroute output. The ESLint rule only works the other way round, stopping the public client from being used in(private)routes. - The database is unreachable during a build.
generateStaticParamswould return no params. ThestaticParamshelpers return a placeholder row instead, so the build still succeeds and the placeholder route 404s. NEXT_PUBLIC_STORAGE_URLis unset or malformed. The configured storage host is dropped from the CSP andimages.remotePatterns. The two hard-coded ozeaon storage hosts are still allowed.- The image loader only knows the production R2 origin. URLs on
storage-r2.ozeaon.devpass through untransformed. Because the loader is custom,next/imagedoes not generate/_next/imageURLs, so thatCache-Controlrule andminimumCacheTTLonly apply if something requests the path directly. There is also nopublic/images/directory, so the/images/:path*rule matches nothing today.
Operational Notes
Section titled “Operational Notes”NEXT_PUBLIC_*values are inlined at build time. Changing the storage or Supabase URL means rebuilding, because the CSP and image patterns are baked into the build.initOpenNextCloudflareForDev()only runs whenNODE_ENV === "development". That stopstypegen,buildand other CLI commands from stalling on binding setup.pdfjs-distis listed inserverExternalPackages, so it is not bundled into the server output.- Prerender errors only appear in
pnpm build, not intscorpnpm lint.
Extension Points
Section titled “Extension Points”- Turning on Cache Components is a roadmap item, blocked on adapter support. When it happens: follow
docs/ssr/cache-components-model.md, uncommentexperimental.maxPostponedStateSizeinnext.config.ts, then re-enable the R2 incremental cache, D1 tag cache and DO queue inopen-next.config.ts. Each of those also needs its Cloudflare binding provisioned. - Adding an image or CSP host: add it to
images.remotePatternsand to the CSP template in the same change. - Changing the image cache window: edit
FOUR_DAYS.