跳到正文
cccskills
免费在 GitHub 公开

ssr-zero-layout-shift

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.

中文简介正在准备中。当前显示原文说明。

集成工具Supabase
查看安装方法

包含的文件(4)

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

SKILL.md(原文)

安装前可以查看交给 AI 代理的指令内容。

SSR-First Zero Layout Shift

<!-- rules:stamp -->

Rules from common-docs/systems/architecture/frontend/STATE.md — edit there, never here (python3 common-docs/meta/scripts/one_home.py --write).

  • Server Component shell with fixed-dimension frames; dynamic content streams into sized Suspense holes, and skeletons match the final dimensions, so layout shift is zero. "use client" goes on the smallest island.
  • A Server Component cannot use dynamic({ ssr: false }); the heavy widget below a thin server shell owns its splitting inside a "use client" child. Never stack ssr:false boundaries down one render path (a waterfall), never mass-convert React.lazy to next/dynamic, and a dynamic import that is always rendered is cost with no deferral: gate it. A user-triggered chunk always has a loading and an error boundary.
  • Nothing fetches until needed: no provider, context or hook calls Supabase, an API or IndexedDB on mount; providers mount as empty shells and the consumer that needs data triggers its load. Every removal from boot ships its on-click load.
  • Nothing runs twice: never wrap a second <XProvider> for a context the global tree already provides. Redux for all new state; context providers are legacy and are not expanded.
  • No client-side Supabase auth calls; identity is in Redux. A read that needs a session never leaves the client without one, and a refusal is never silent: use the existing session barrier (utils/supabase/sessionBarrier.ts), never hand-roll a session check, subscription or retry.
  • Choose the sharing tier by rule: database leaf (renders one shape's data, no deploy) then @ai-matrx/* package (every app needs it, identical everywhere) then host (this app's wiring, never shared "just in case"). Package source never imports host aliases (@/), and a package change is the first option, never a copy or workaround.
  • A new subdomain app is a project, not an architecture change; cross-host auth rides the .aimatrx.com cookie and hard navigation between hosts is fine.
  • A route group's shell posture is deliberate: (kiosk), (meet), (link) and (lab) are ungated or shell-less on purpose; never add auth or AppShell to them to match (core).
<!-- rules:stamp-end -->

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

'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.
export default function Dashboard() {
  return (
    <>
      <PageHeader>
        <h1 className="type-title 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.
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.
// 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="type-label">Agent</label>
      <div className="h-10 w-full">
        <AgentDropdown defaultLabel={currentAgentName} />
      </div>
    </div>
  )
}
// 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 paintFetch in Server Component, pass as props (Pattern 1)
Initial paint, but slowSuspense boundary with skeleton (Pattern 2)
User click/hover/scrollInteraction-triggered fetch (Pattern 6)
Never on this page loadnext/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.

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

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

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

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

ScopeLocation
Feature-specificfeatures/[feature-name]/service.ts
Shared/reusablelib/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:

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 caseUse
Webhooks, third-party integrationsRoute Handlers (/api/...)
Forms / internal mutationsServer 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:

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
  • For route-level architecture (layouts, nested routes, metadata, error boundaries), see route-architecture.md
  • For detailed Next.js 16 templates (services, API routes, signatures), see 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

评价

还没有评价。欢迎分享你的使用感受。

评论

无需登录即可发表。欢迎分享使用感受、提问,或说明在哪些环境下无法运行。

还没有评论。来写第一条吧。

提交后会立即公开。请勿填写邮箱、电话等个人信息。URL 最多 2 个。

0 / 1000 字

此表单受 reCAPTCHA 保护,适用 Google 的隐私权政策和服务条款。

同一仓库的技能

Admin debug system wiring for the floating AdminIndicator's live debug data and Copy Full Context. Use when adding debug visibility to a route or feature, wiring useDebugContext or the debug panel, capturing console errors, or enabling the copy-context workflow for a page.

中文简介正在准备中。当前显示原文说明。

armanisadeghi/ai-matrx32026年10月20日 更新

Compact two-icon Copy / Copy-for-AI controls (components/agent-copy). Use when adding copy buttons to a row, card, list, or record; merging duplicate Copy/JSON/AI controls; continuing the copy rollout; or writing a Copy-for-AI payload. NOT for markdown content actions (use rich-document-actions).

中文简介正在准备中。当前显示原文说明。

armanisadeghi/ai-matrx32026年10月20日 更新

Disclosing a surface's existing fixed AI jobs in the shell's top Agents menu. Use when a page, panel, overlay, or window already runs a mandate behind a button, assist, automatic action, or mode; when the agent-disclosure guard names a file; or during a surface check.

中文简介正在准备中。当前显示原文说明。

armanisadeghi/ai-matrx32026年10月20日 更新

The watch-fix-rerun method for making an existing platform agent or automated process efficient and correct: baseline its ledger, run one unit yourself, fix the class behind every wasted call, rerun, record. Use when asked to improve, optimize, watch, or 'make efficient' an agent, a sandbox session, a sync job, or any recurring automated process.

中文简介正在准备中。当前显示原文说明。

armanisadeghi/ai-matrx32026年10月20日 更新

Redux state for agent execution and firing agent shortcuts. Use when editing features/agents/redux/, building agent UI, creating a conversation, touching assembleRequest or NDJSON stream state, wiring agent-state selectors, adding a per-conversation capability, or triggering a shortcut from a button, menu, or code (useShortcutTrigger, launchAgentExecution).

中文简介正在准备中。当前显示原文说明。

armanisadeghi/ai-matrx32026年10月20日 更新

The provision for a call site: the exhaustive menu of values that place in the code can realistically produce. Use before creating or fixing a mandate or agent that reasons about more than its own input, when deciding whether an agent can answer at all with what it is sent, or when its output 'looks right' but nobody checked it could be. NOT for building the agent (use create-agent).

中文简介正在准备中。当前显示原文说明。

armanisadeghi/ai-matrx32026年10月20日 更新

查看 armanisadeghi 的全部技能

报告此技能的问题