Enforces SSR-first rendering with zero Cumulative Layout Shift using seven composable patterns: Server Component shells with Client Component islands, Suspense boundaries as static-shell-with-dynamic-holes, selective hydration, lazy-loaded client components via next/dynamic, dimension-matched skeleton fallbacks, interaction-triggered data fetching, and one-shot Redux hydration. Also covers React cache() deduplication, server-only guards, the slot pattern, preloading, and the loading state hierarchy. Also the canonical Next.js 15/16 App Router doctrine (absorbed nextjs-ssr-architecture + nextjs-app-router-expert): server/client component boundaries, shared service patterns, async request APIs (awaited params/searchParams/cookies/headers), Route Handlers vs Server Actions, and 'use cache'. Use when creating pages, components, forms, dropdowns, data tables, dashboards, or any UI that fetches data. Also use when fixing layout shift, CLS issues, hydration mismatches, or reviewing component architecture for renderi
Instrucciones de origen · Vista previa de solo lectura
name
ssr-zero-layout-shift
description
Enforces SSR-first rendering with zero Cumulative Layout Shift using seven composable patterns: Server Component shells with Client Component islands, Suspense boundaries as static-shell-with-dynamic-holes, selective hydration, lazy-loaded client components via next/dynamic, dimension-matched skeleton fallbacks, interaction-triggered data fetching, and one-shot Redux hydration. Also covers React cache() deduplication, server-only guards, the slot pattern, preloading, and the loading state hierarchy. Also the canonical Next.js 15/16 App Router doctrine (absorbed nextjs-ssr-architecture + nextjs-app-router-expert): server/client component boundaries, shared service patterns, async request APIs (awaited params/searchParams/cookies/headers), Route Handlers vs Server Actions, and 'use cache'. Use when creating pages, components, forms, dropdowns, data tables, dashboards, or any UI that fetches data. Also use when fixing layout shift, CLS issues, hydration mismatches, or reviewing component architecture for rendering performance. Triggers on: Suspense, skeleton, layout shift, CLS, SSR, streaming, hydration, next/dynamic, lazy loading, prerender, static shell, cache components, Redux hydration, server-only, "use client" audit, API route patterns, Server Actions.
SSR-First Zero Layout Shift
Seven composable patterns that eliminate CLS while maximizing server-rendered content. Every component gets a fixed-dimension Server Component frame; dynamic content streams into sized holes inside those frames.
Core Mental Model
Every piece of UI has two aspects: structure (dimensions, position, borders, labels) and behavior (interactivity, state, fetched data). Structure is always a Server Component. Behavior is always a thin Client Component island nested inside that structure. The browser receives a complete, dimensionally-stable HTML shell instantly. Nothing shifts. Nothing flashes.
Client Component island handles only interactive bits
Lazy loading defers non-critical interactivity until user action
Redux hydrator (if needed) bridges server data to client store
Pattern 1: Server Component Shell + Client Component Islands
The outer structure is always a Server Component. Only the interactive behavior is a Client Component. The HTML structure is fixed-size, so zero layout shift.
Rules
Default to Server Components. Add 'use client' only for hooks, event handlers, or browser APIs.
Push 'use client' as far down the tree as possible — wrap the smallest interactive unit, not the container.
The Server Component renders the full HTML skeleton (label, container, dimensions). The Client Component only adds behavior.
Server Components ship zero JavaScript. Every line inside 'use client' adds to the client bundle.
// Server Component renders structure, Client Component handles the clickimport { LikeButton } from'./like-button'exportdefaultasyncfunctionPost({ id }: { id: string }) {
const post = awaitgetPost(id)
return (
<articleclassName="p-6 border rounded-lg"><h2>{post.title}</h2><p>{post.body}</p><LikeButtonpostId={id}initialCount={post.likes} /></article>
)
}
Client Shell -> Server Children (Slot Pattern)
A Client Component can receive Server Components as children. The children render on the server; the Client Component just provides the interactive wrapper.
Wrapping an entire form or page in 'use client' when only one input is interactive.
Fetching data inside a Client Component when a Server Component parent can pass it as props.
Putting the label, container sizing, or layout inside the Client Component.
Pattern 2: Suspense Boundaries as Static Shell with Dynamic Holes
The static shell (layout, labels, known UI) prerenders immediately. Async data fills sized holes via <Suspense>. Everything outside the boundary is in the static shell.
Rules
Place <Suspense> as close as possible to the async component — maximize the static shell.
Every <Suspense> fallback must be a dimension-matched skeleton (see Pattern 5).
Async Server Components inside Suspense stream independently — no waterfalls.
Boundary placement heuristic: If two data-fetching components can resolve at different times, they need separate Suspense boundaries. If they always resolve together (same query), they can share one.
Each boundary fetches independently and in parallel. This replaces Promise.all when components render different UI.
Pattern 3: Selective Hydration
React 18+ breaks hydration into Suspense-scoped chunks. If a user interacts with a component before hydration completes, React prioritizes hydrating that component first.
Rules
Wrap each interactive section in its own <Suspense> boundary to enable independent hydration.
Critical interactive elements (search bars, primary CTAs) get their own boundary.
One boundary = one streaming unit = one hydration unit. No extra API needed.
Every Suspense fallback and loading state must preserve the exact layout dimensions of the real component.
Rules
Skeletons must match the real component's height, width, padding, and gap.
Use explicit dimension classes (h-32, h-10, w-full) — never rely on content-derived sizing.
Skeletons live inside the Suspense boundary, not outside. The outer container is stable regardless.
Use the project's <Skeleton> component (Server Component — no 'use client' needed).
Mirror the content structure: if the real component has a title and two lines, the skeleton has matching blocks.
No spinners for page content. Spinners are acceptable for small inline actions (button loading state).
Validating zero CLS
Same explicit height on the outer container
Same padding and gap classes
Same border/rounding so the visual footprint is identical
No content-dependent sizing — the container never grows based on text length
Pattern 6: Interaction-Triggered Data Fetching
Not all data needs to be fetched on page load. If data is only needed after user action (click, scroll, hover), defer it. The component renders a fixed-dimension shell with a placeholder/hardcoded value. Data fetches only when the user interacts.
Rules
The dropdown trigger, form field label, and container dimensions are Server Component output — instant, zero JS.
The Client Component fetches data only on first interaction, caches it locally, and replaces the placeholder.
Use useTransition to keep the UI responsive during the fetch.
// Server Component — renders the form field structure instantlyexportdefaultfunctionAssignAgentField({ currentAgentName }: { currentAgentName: string }) {
return (
<divclassName="flex flex-col gap-1.5"><labelclassName="text-sm font-medium">Agent</label><divclassName="h-10 w-full"><AgentDropdowndefaultLabel={currentAgentName} /></div></div>
)
}
When SSR-fetched data must live in the Redux store for client-side interactions, hydrate it exactly once using a thin Client Component that renders nothing.
Why not useEffect: It fires after paint. Children reading from the store would see empty state for one frame, causing a flash. The useRef guard dispatches during the first render pass — before any child reads.
Synchronous action only: The Redux action must be a plain synchronous action (not a thunk). The server did the fetching; Redux just receives.
Hydrate once: When the layout persists across tab/page navigation, the hydrator doesn't re-mount. Re-hydrating on every navigation risks overwriting in-progress edits.
Place in layouts: The hydrator typically lives in a layout that wraps multiple pages sharing the same data.
Data Fetching Architecture
React cache() for Request Deduplication
Wrap data-access functions in React's cache() to deduplicate across the render pass. Layout, page, generateMetadata, and any Server Component can all call the same function — React collapses them into one execution per request.
cache() vs 'use cache':cache() deduplicates within a single request (same render pass). 'use cache' persists data across requests (cross-user caching). They serve different purposes and are often used together.
server-only Import Guard
Add import 'server-only' to any data-access file. If a Client Component accidentally imports it, the build fails immediately — preventing secret leaks and server-only code from entering the client bundle.
Preloading Pattern
When data is expensive and conditionally needed, start the fetch early:
// Parallel — both start immediatelyconst [agent, logs] = awaitPromise.all([getAgent(id), getAgentLogs(id)])
// Waterfall — logs waits for agent. NEVER do this.const agent = awaitgetAgent(id)
const logs = awaitgetAgentLogs(id)
Next.js 15/16 App Router Rules (merged from nextjs-app-router-expert)
Business logic lives in shared services
Never scatter Supabase queries across components and API routes. Extract to a service and consume it from Server Components, API routes, and Server Actions alike:
Scope
Location
Feature-specific
features/[feature-name]/service.ts
Shared/reusable
lib/services/[name].ts
Client Components avoid direct Supabase queries where a Server Component parent (via a service) can pass data down. Server clients: @/utils/supabase/server; browser: @/utils/supabase/client.
Async request APIs — always await
params, searchParams, cookies(), and headers() all return Promises in Next.js 15/16:
Opt-in and explicit. cache() deduplicates within one request; 'use cache' persists across requests — often used together:
asyncfunctiongetStaticData() {
'use cache'cacheTag('static-data')
cacheLife('hours') // seconds, minutes, hours, days, maxreturnawaitfetchData()
}
// Invalidate: revalidateTag('static-data') from next/cache after a mutation.
"use client" audit
"use client" is justified ONLY by hooks, event handlers, browser APIs, or client-only third-party libs. A pure display component carrying the directive is a violation — remove it. When reviewing, check each in order: hooks? events? browser APIs? None → strip the directive.
Loading State Hierarchy
layout.tsx → Never shows loading (persists across navigation)
loading.tsx → Wraps page.tsx in Suspense (route transition skeleton)
<Suspense> → Wraps individual async components (granular streaming)
dynamic(ssr:false)→ Placeholder until client JS loads (interaction-triggered)
loading.tsx is automatic Suspense at the page level. Prefer granular <Suspense> boundaries inside pages for fine-grained streaming. Use loading.tsx as a coarse fallback for full-page transitions.
Decision Tree
Building a new component?
│
├── Does it fetch data?
│ ├── Yes, needed at initial paint → async Server Component in <Suspense> (Pattern 2)
│ ├── Yes, but only after interaction → Interaction-triggered fetch (Pattern 6)
│ └── No → Render directly in the static shell
│
├── Does it need interactivity?
│ ├── Yes → Can the interactive part be isolated?
│ │ ├── Yes → Server parent + thin Client child (Pattern 1)
│ │ └── No → Minimal 'use client' component
│ ├── Is it critical (above fold)? → Own Suspense boundary (Pattern 3)
│ └── Is it non-critical? → next/dynamic ssr:false (Pattern 4)
│
├── Does it display user-specific data (cookies, session)?
│ ├── Yes → Dynamic Server Component inside Suspense, outside 'use cache'
│ └── No → Candidate for 'use cache' with cacheLife/cacheTag
│
└── Does the fetched data need to be in Redux?
├── Yes → Add Hydrator component in layout (Pattern 7)
└── No → Pass as props
Checklist
Before shipping any page or component:
Outer container has explicit dimensions (not content-derived)
'use client' only on the smallest interactive unit
Every <Suspense> fallback matches the real component's bounding box
Async data fetches happen in Server Components, not Client Components
Non-critical interactive components use next/dynamic with ssr: false
No data waterfalls — parallel Suspense boundaries or Promise.all
Skeleton components are Server Components (no 'use client' directive)
Data-access functions wrapped in cache() with import 'server-only'
Redux hydration (if needed) uses useRef guard, not useEffect
Non-critical data defers to interaction-triggered fetching
Additional Resources
For complete page examples with cache components, see examples.md
For route-level architecture (layouts, nested routes, metadata, error boundaries), see route-architecture.md