app-structure — server default, client leaves
Stage: Phase 5 — Scaffold - Reads: design/SITEMAP.md, scaffolded tree - Writes: app/ structure + design/SITEMAP.md part 3 (the RSC/client boundary plan every component skill obeys)
Standard
The client bundle carries interactivity and nothing else. Every component is a Server Component until it proves it needs state, effects, browser APIs, or event handlers — then "use client" goes on the smallest leaf that needs it, never on the section, page, or layout containing it. Target for a typical marketing site: layouts and pages 100% server, "use client" file count in the low teens, each occurrence individually justifiable in one sentence.
Process
- Enumerate interactivity: read design/SITEMAP.md (parts 1–2) and the scaffolded tree; list every interactive need per page — nav/menu, tabs, filters, forms, motion, theme.
- Assign each a home using the state table below: server, URL, client leaf, or action. Anything without a one-sentence justification for
"use client"stays server. - Write the boundary plan as design/SITEMAP.md part 3 — per page: the client leaves (file path + one-sentence justification each), which state lives in the URL, which mutations become Server Actions. Every component skill obeys this plan; gate-performance audits against it.
- Stub the shared client leaves the plan names — components/motion/ wrappers (Reveal, Stagger…) and components/layout/providers.tsx — so sections compose them instead of inventing their own boundaries.
- Verify empirically: grep app/ for
"use client"— zero hits in any layout.tsx or page.tsx; count total occurrences against the low-teens target.
Boundary rules
"use client"marks a module-graph boundary: every module a client file imports becomes client code. Placing it high poisons the whole subtree — place it at the leaf.- Layouts NEVER carry
"use client". A layout needing a client feature (theme, scroll state) wraps a client child instead. - Server components cannot be imported by client components — but they pass through untouched as
children/props. This is the composition escape hatch; use it everywhere:
// components/sections/features.tsx — server: data, text, images
import { Reveal } from "@/components/motion/reveal"
export function Features({ items }: { items: Feature[] }) {
return <Reveal>{items.map(/* server-rendered content */)}</Reveal>
}
// components/motion/reveal.tsx — the client leaf
"use client"
import { motion } from "motion/react"
export function Reveal({ children }: { children: React.ReactNode }) { /* … */ }
- Anything importing from
"motion/react"is client — motion lives in thin wrappers under components/motion/; sections stay server and compose them. - Props crossing server→client must be serializable: no functions, no class instances. Never pass an event handler down across the boundary — pass a Server Action (ultraweb:server-actions).
paramsandsearchParamsare Promises in Next 16 —awaitthem in pages, layouts, and generateMetadata:
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
}
Where state lives
| State | Home | Mechanism |
|---|---|---|
| Data from any source | Server | fetch/db call in the RSC — fetch is NOT cached by default; caching policy is ultraweb:data-fetching |
| Shareable UI state (tab, filter, page) | URL | searchParams (await it) + <Link> — survives refresh and sharing |
| Ephemeral UI (menu open, hover index) | Client leaf | useState in the leaf that renders it |
| Form/mutation state | Action | useActionState + 'use server' action (ultraweb:server-actions) |
| Theme | One provider | next-themes client wrapper — the ONLY context in the root layout by default |
| Focus after client navigation | One client leaf in root layout | usePathname() effect moves focus to the page's #main-heading (tabIndex={-1}) — mounted once, never per-page |
No global state library (zustand, redux, jotai) unless the brief demands cross-page client state. A marketing site never does.
Root layout pattern
layout.tsx stays server; providers are a client wrapper around children:
// app/layout.tsx — server component, no directive
import { ThemeProvider } from "@/components/layout/providers"
import { FocusOnNavigate } from "@/components/layout/focus-on-navigate"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<FocusOnNavigate />
{children}
</ThemeProvider>
</body>
</html>
)
}
components/layout/providers.tsx is the "use client" file re-exporting next-themes' provider. Class-strategy dark mode also needs @custom-variant dark (&:where(.dark, .dark *)); in globals.css — scaffold laid it.
Focus on client navigation: the browser resets focus only on full page loads. A client route change swaps the DOM while focus lingers on the now-removed link or falls to <body> — keyboard and screen-reader users are stranded at the old position with no announcement. One client leaf in the root layout fixes it for every route, never per page; each page's top heading carries id="main-heading" tabIndex={-1} as the landing target:
// components/layout/focus-on-navigate.tsx — the client leaf; renders nothing, just moves focus
"use client"
import { usePathname } from "next/navigation"
import { useEffect, useRef } from "react"
export function FocusOnNavigate() {
const pathname = usePathname()
const first = useRef(true)
useEffect(() => {
if (first.current) { first.current = false; return } // skip first mount so a deep-linked #hash target keeps focus
document.getElementById("main-heading")?.focus({ preventScroll: true })
}, [pathname])
return null
}
This is the focus half of accessible client navigation; ultraweb:page-transitions owns the aria-live announcer half, and ultraweb:gate-accessibility audits that focus actually lands on the heading after a route change.
layout vs template: layout persists across navigation — DOM and state preserved, no re-mount. template.tsx re-mounts per navigation; reach for it only when ultraweb:page-transitions needs per-route re-runs.
File organization contract
Every component skill writes into this shape; deviating breaks downstream skills:
app/ route files ONLY: page/layout/loading/error/not-found + globals.css
app/<route>/_components/ pieces used by exactly one route (private folder, not routable)
components/ui/ restyled shadcn primitives — client only when genuinely interactive
components/sections/ page sections — server by default, one section per file, kebab-case filename, PascalCase named export
components/layout/ header.tsx, footer.tsx, providers.tsx
components/motion/ thin "use client" motion wrappers (Reveal, Stagger…) that sections compose
components/scene/ the DIRECTION-commissioned persistent canvas: one "use client" leaf holding next/dynamic({ssr:false}), its import fired from an idle callback after the LCP entry — ssr:false alone skips server render, it defers nothing (ultraweb:set-design)
lib/ utils.ts (cn), fonts.ts (next/font instances)
lib/scene/ the journey map and the station registry — the route↔camera contract, and the single source the DOM nav and the scene hotspots both read
Sections take data as props — pages fetch, sections render. Secrets (process.env.* without NEXT_PUBLIC_) are read in server files only.
Anti-patterns
"use client"in anylayout.tsx— the single worst boundary placement; grep for it, treat a hit as a defect"use client"at the top ofpage.tsx"to be safe"useEffect+fetchfor initial data — fetch in the RSC instead- Importing a server component into a client file — pass it as
children useStatefor tab/filter/pagination state a URL should carryparams.slugorsearchParams.qwithoutawait— they are Promises in Next 16- Provider pyramid in the root layout — one theme provider; each additional context needs written justification. A DIRECTION-commissioned persistent canvas is that written-justification case and nothing more: the root layout stays a server component and renders a
"use client"leaf beside{children}, the canvas is fixed-position andaria-hiddenbehind the route tree, and scene state is ONE small typed store, never a new provider level — the justification sentence names the route scope it serves, and the leaf enforces it: matchusePathname()against that scope, and outside it the canvas neither mounts nor requests its chunk - Client navigation that never moves focus — keyboard/SR users stay stranded on the old page; the root-layout focus leaf must move focus to
#main-headingafter every route change - Owning focus-on-navigate per page (an effect in each page.tsx) — it belongs to one leaf in the root layout, or pages fight each other
- Functions or event handlers passed across the server→client boundary
npm i zustand/npm i reduxon a marketing site
Worked example — Tidepool, port-logistics SaaS boundary plan
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.