Skip to content
Back to skills

Ssr Zero Layout Shift

ASecurity

SSR-first Next.js App Router doctrine with zero layout shift. Use when creating a page or data-fetching UI, fixing CLS, layout shift, or hydration mismatches, placing 'use client', Suspense, or skeleton boundaries, awaiting params/searchParams/cookies, adding server-only guards or cache() dedup, or choosing Route Handlers vs Server Actions.

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 3, 2026
developmentjavascriptgojavashellreactnextjsnodeapi

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 4 files and shows the line behind each finding

Scanned October 3, 2026

npx -y skills add armanisadeghi/ai-matrx --skill ssr-zero-layout-shift --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Ssr Zero Layout Shift?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Ssr Zero Layout Shift
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/armanisadeghi-ssr-zero-layout-shift/badge)](https://www.skillsdirectory.com/skills/armanisadeghi-ssr-zero-layout-shift)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: ssr-zero-layout-shift
description: "SSR-first Next.js App Router doctrine with zero layout shift. Use when creating a page or data-fetching UI, fixing CLS, layout shift, or hydration mismatches, placing 'use client', Suspense, or skeleton boundaries, awaiting params/searchParams/cookies, adding server-only guards or cache() dedup, or choosing Route Handlers vs 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.

## The Hierarchy

**Server Component defines fixed-dimension container** -> **Suspense boundary wraps async content** -> **Skeleton fallback matches exact dimensions** -> **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.

```tsx
// Server Component renders structure, Client Component handles the click
import { LikeButton } from './like-button'

export default async function Post({ id }: { id: string }) {
  const post = await getPost(id)
  return (
    <article className="p-6 border rounded-lg">
      <h2>{post.title}</h2>
      <p>{post.body}</p>
      <LikeButton postId={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.

```tsx
'use client'
export function CollapsiblePanel({ title, children }: {
  title: string; children: React.ReactNode
}) {
  const [open, setOpen] = useState(true)
  return (
    <section>
      <button onClick={() => setOpen(!open)}>{title}</button>
      {open && children}
    </section>
  )
}

// Server Component composes the pattern
export default async function Sidebar() {
  const stats = await getStats()
  return (
    <CollapsiblePanel title="Stats">
      <StatsDisplay data={stats} />  {/* Server Component passed through */}
    </CollapsiblePanel>
  )
}
```

### Anti-patterns

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

```tsx
export default function Dashboard() {
  return (
    <>
      <PageHeader>
        <h1 className="text-sm font-medium truncate">Dashboard</h1>
      </PageHeader>
      <div className="h-full flex flex-col overflow-hidden">
        <div className="flex-1 grid grid-cols-3 gap-4 p-4">
          <Suspense fallback={<StatCardSkeleton />}><RevenueCard /></Suspense>
          <Suspense fallback={<StatCardSkeleton />}><UsersCard /></Suspense>
          <Suspense fallback={<StatCardSkeleton />}><OrdersCard /></Suspense>
        </div>
      </div>
    </>
  )
}
```

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.

---

## Pattern 4: Lazy Loading Client Components (`next/dynamic`)

Defer loading Client Component code until the user triggers it. The trigger renders in the static shell; the heavy component loads on demand.

### Rules

- Use `next/dynamic` with `ssr: false` for components that only matter after user interaction (modals, panels, rich editors).
- The trigger (button, dropdown trigger) is always server-rendered or in a thin Client Component.
- Provide a dimension-matched `loading` component that also says what is loading — a bare pulsing box is a `real-loading-states` finding.

```tsx
import dynamic from 'next/dynamic'
import SuspenseLoader from '@/components/loaders/SuspenseLoader'

const RichEditor = dynamic(() => import('./RichEditor'), {
  ssr: false,
  loading: () => (
    <div className="h-64 w-full rounded-md border border-border">
      <SuspenseLoader message="Loading editor…" />
    </div>
  ),
})
```

---

## Pattern 5: CLS Prevention — Dimension-Matched Skeletons

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

1. Same explicit height on the outer container
2. Same padding and gap classes
3. Same border/rounding so the visual footprint is identical
4. 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.

```tsx
// Server Component — renders the form field structure instantly
export default function AssignAgentField({ currentAgentName }: { currentAgentName: string }) {
  return (
    <div className="flex flex-col gap-1.5">
      <label className="text-sm font-medium">Agent</label>
      <div className="h-10 w-full">
        <AgentDropdown defaultLabel={currentAgentName} />
      </div>
    </div>
  )
}
```

```tsx
// Client Component — fetches ONLY on user interaction
'use client'
import { useState, useTransition } from 'react'

export function AgentDropdown({ defaultLabel }: { defaultLabel: string }) {
  const [open, setOpen] = useState(false)
  const [agents, setAgents] = useState<Agent[] | null>(null)
  const [isPending, startTransition] = useTransition()

  const handleOpen = () => {
    setOpen(true)
    if (!agents) {
      startTransition(async () => {
        const data = await fetchAgents()
        setAgents(data)
      })
    }
  }

  return (
    <button onClick={handleOpen} className="h-10 w-full rounded-md border px-3 text-left">
      {defaultLabel}
      {open && (
        <div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover">
          {isPending
            ? <DropdownSkeleton />
            : agents?.map(a => <div key={a.id} className="px-3 py-2">{a.name}</div>)
          }
        </div>
      )}
    </button>
  )
}
```

### When to use each approach

| Data needed at... | Pattern |
|---|---|
| Initial page paint | Fetch in Server Component, pass as props (Pattern 1) |
| Initial paint, but slow | Suspense boundary with skeleton (Pattern 2) |
| User click/hover/scroll | Interaction-triggered fetch (Pattern 6) |
| Never on this page load | `next/dynamic` with `ssr: false` (Pattern 4) |

---

## Pattern 7: Redux Hydration — One-Shot Background Operation

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.

```tsx
'use client'
import { useRef } from 'react'
import { useAppDispatch } from '@/lib/redux/hooks'
import { hydrateAgent } from '@/lib/redux/slices/agentsSlice'

export function AgentHydrator({ agent }: { agent: Agent }) {
  const dispatch = useAppDispatch()
  const hydrated = useRef(false)

  if (!hydrated.current) {
    dispatch(hydrateAgent(agent))
    hydrated.current = true
  }

  return null
}
```

### Rules

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

```tsx
// lib/data.ts
import { cache } from 'react'
import 'server-only'

export const getAgent = cache(async (id: string) => {
  const supabase = await createClient()
  const { data, error } = await supabase.from('agents').select('*').eq('id', id).single()
  if (error || !data) notFound()
  return data
})
```

**`cache()` is per-request dedup only** (one render pass). Cross-request caching is a separate, rare opt-in — see "Cross-request caching" below; **`'use cache'` is NOT available here** (`cacheComponents` is off; the directive is a build error).

### `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:

```tsx
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  preloadAgent(id)
  const canView = await checkPermission(id)
  if (!canView) redirect('/agents')
  return <AgentView id={id} />
}

const preloadAgent = (id: string) => { void getAgent(id) }
```

### Parallel Fetching

```tsx
// Parallel — both start immediately
const [agent, logs] = await Promise.all([getAgent(id), getAgentLogs(id)])

// Waterfall — logs waits for agent. NEVER do this.
const agent = await getAgent(id)
const logs = await getAgentLogs(id)
```

---

## Next.js 15/16 App Router Rules (merged from nextjs-app-router-expert and nextjs-ssr-architecture)

### 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:

```tsx
export default async function Page({ params, searchParams }: {
  params: Promise<{ id: string }>
  searchParams: Promise<Record<string, string>>
}) {
  const { id } = await params
  const { page } = await searchParams
}

export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const cookieStore = await cookies()
}
```

Types must include the `Promise<>` wrappers.

### Route Handlers vs Server Actions

| Use case | Use |
|----------|-----|
| Webhooks, third-party integrations | Route Handlers (`/api/...`) |
| Forms / internal mutations | Server Actions |

Both go through shared services.

### Cross-request caching — dynamic by default

**Dynamic rendering is the default. `'use cache'` / `cacheLife` / `cacheTag` are NOT available** (`cacheComponents` is off; the directive is a build error). Opting in uses the pre-Cache-Components APIs — live exemplar `features/education/publishing/queries.ts` + `actions.ts`:

```tsx
import 'server-only'
import { unstable_cache } from 'next/cache'
import { getScriptSupabaseClient } from '@/utils/supabase/getScriptClient'

// Anon, cookie-free client ONLY — a per-user read inside unstable_cache serves one user's data to everyone.
export const listPublishedDocs = unstable_cache(
  async () => fetchPublishedRows(getScriptSupabaseClient()),
  ['docs:list'],                       // keyParts must carry every argument, or all args collapse onto one entry
  { tags: ['docs'], revalidate: 3600 },
)
// Invalidate from a Server Action: updateTag('docs') (Route Handler: revalidateTag).
```

- **Route-level ISR:** `export const revalidate = <seconds>` on a cookie-free page (`app/(core)/education/learn/page.tsx`).
- **User-specific data is never cached across requests** — it renders dynamically inside `<Suspense>`.
- Reference: `node_modules/next/dist/docs/01-app/02-guides/caching-without-cache-components.md`.

### `"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 (never cached across requests)
│   └── No → Dynamic by default; hot + cookie-free → unstable_cache with tags/revalidate
│
└── 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 (cached + dynamic sections), see [examples.md](examples.md)
- For route-level architecture (layouts, nested routes, metadata, error boundaries), see [route-architecture.md](route-architecture.md)
- For detailed Next.js 16 templates (services, API routes, signatures), see [nextjs16-patterns-reference.md](nextjs16-patterns-reference.md)
- For splitting heavy client code out of the bundle, see the `code-splitting` skill; for the full new-route workflow, see `new-route-scaffold`

Files in this skill

  • SKILL.md18.6 KB
  • examples.md17.9 KB
  • nextjs16-patterns-reference.md13.9 KB
  • route-architecture.md14.3 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…