--- name: nextjs-app-architecture description: Build or audit Next.js 16 App Router apps using a next-beats-style React Server Components architecture. Use when scaffolding a new app, adding a feature, reviewing an existing app, refactoring route-loader-shaped pages into feature-owned async server components, deciding where queries/actions/components live, keeping pages synchronous with `params.then()`, placing Suspense boundaries, choosing the client/server boundary, designing skeletons, preventing CLS, or enabling Cache Components. Also use when the user asks about RSC composition, components receiving IDs instead of route params, `'use cache'`, `cacheTag`, `updateTag`, static-shell prerendering, or making an app easier for AI agents to modify. license: MIT metadata: author: aurorascharff version: "1.4.1" --- # Next.js App Architecture A workflow for building and auditing Next.js 16+ App Router apps so they follow one consistent, feature-sliced RSC architecture like `next-beats`. **Follow the workflow below step by step** — it produces the invariants by construction. Load the reference a step names for the decision it depends on. Get framework _mechanics_ (API signatures, config options, hook contracts) from the linked docs — don't restate or improvise them. Links for canary-only Next.js APIs use `preview.nextjs.org`. ## Prerequisite Before changing a Next.js app, make sure the project is set up for AI agents to read version-matched docs. Follow the [AI Coding Agents guide](https://preview.nextjs.org/docs/app/guides/ai-agents): prefer the project's `AGENTS.md` / bundled docs, and create or refresh them when missing. Then use this skill for architecture decisions. ## Architecture target Build pages that describe the loading experience, not pages that act like route loaders: - `app/**/page.tsx` and `layout.tsx` are synchronous composition surfaces: static chrome, section headings, `` boundaries, error boundaries, and transition wrappers. - Feature components own their reads on the server. They receive minimal stable inputs (`id`, `slug`, `handle`, parsed filter values) or already-fetched records, never raw `params` / `searchParams`. - Queries and actions live in the feature folder. Components import queries; client leaves import actions directly. - When server tags and client query keys describe the same feature data, a pure feature-local cache contract owns those identities. - Stable chrome, wrappers, and skeletons preserve layout: cards/panels stay outside Suspense, and fallbacks swap only the data-dependent body. ## Invariants (what every change must satisfy) The non-negotiables. The workflow produces them; the final check verifies them. 1. **Pages compose, they never fetch.** A page/layout imports feature components and places ``. No queries or domain logic inline. Tiny route-local control-flow helpers are allowed only when they exist to place a boundary around `connection()`, `redirect()`, or a resolved route prop. 2. **Pages stay synchronous.** Use `params.then()` / `searchParams.then()` or `Promise.all([params, searchParams]).then(...)`, never `await params` at the top — so chrome paints into the static shell and only data-dependent sections suspend. 3. **Feature components receive IDs, not route props.** Resolve `params` / `searchParams` at the page boundary and pass plain values (`id`, `slug`, `query`) into features. 4. **Async server component is the default.** `'use client'` only for hooks, event handlers, or browser APIs — and only on leaves, never on parents of server content. 5. **The page owns the Suspense boundary; the feature owns the skeleton.** Features never pre-wrap themselves in ``; stable wrappers/cards/chrome wrap the boundary instead of being duplicated in fallback and final content. 6. **Skeletons live in the same file as the component**, exported alongside it, defined at the end. `Feed` and `FeedSkeleton` are siblings. 7. **Queries live in `-queries.ts`** (`import 'server-only'`); **actions live in `-actions.ts`** (`'use server'`). The file name matches the folder, even for sub-concepts. 8. **Feature folders follow product ownership.** Entity-owned sub-concepts (favorite, like, vote, bookmark) fold into their parent. A route-level experience that composes multiple domains may own its UI and state in a separate feature while each domain keeps its queries and actions. 9. **Client components import actions directly** — never receive a server action as a prop just to call it. 10. **Feature-local cache coordination stays with its domain.** Put pure tags/keys in `-cache.ts`, client query definitions in `-query-options.ts`, hook wrappers in `hooks/use-*.ts`, and tiny client leaves in `components/`; promote support code only after real cross-feature reuse. 11. **Interactive async UI keeps server data on the server and client state local to the interaction.** Use `useOptimistic`, `useTransition`, reducers, URL/search params, and form actions instead of mirrored prop state, derived-state effects, or hand-rolled pending arrays. ## Workflow Run these in order for build-from-scratch, feature work, or audits. Each step names the reference to consult and the check it must pass. 1. **Choose mode.** - **Build from scratch:** sketch routes, real domain nouns, static shell, and expected loading groups before writing code. - **Audit/refactor:** scan current `app/` pages first; list every async page, page-level query import, route prop leak, missing Suspense boundary, and feature folder mismatch. → `references/example.md` for the target shape; `references/feature-folders.md` for placement. ✓ You know whether you are creating the architecture or converting loader-shaped code into it. 2. **Place the work.** Decide the feature folder before writing anything. → `references/feature-folders.md` (decision tree + merge rules). ✓ A real domain, a cross-domain product experience, or folded into the right parent. 3. **Write the query and, when a client cache shares its data, the cache contract.** Put server reads in `-queries.ts` with `import 'server-only'`; keep shared tag/key identities in a pure `-cache.ts`. → `references/queries-actions.md`; for SWR/TanStack Query → `references/single-page-applications.md`; with `cacheComponents: true`, also → `references/cache-components.md`. ✓ Cache identities are defined once; server reads are server-only, cached/tagged/lifetimed under Cache Components, and return domain types rather than ORM rows. 4. **Write the action** (if there's a mutation). `features//-actions.ts`, `'use server'` at the top. → `references/queries-actions.md`. ✓ Re-checks auth, validates input, invalidates matching cache tags under Cache Components (`refresh()` only for justified dynamic reads), returns a discriminated union. 5. **Build the component + skeleton.** `features//components/.tsx`: an async server component that awaits its own query from minimal props; `'use client'` only on interactive leaves. → `references/components.md`; for a client data library or strict-SPA/CSR feature → `references/single-page-applications.md`. ✓ Component receives IDs/handles/parsed filters or already-resolved records, not `params`; skeleton is a sibling export at the end; no alias skeleton wrappers. 6. **Compose the page.** `app//page.tsx`: synchronous, `params.then()` / `Promise.all(...).then(...)`, place Suspense around data bodies, and wrap the section that can fail independently in an error boundary. → `references/pages-suspense.md`; with `cacheComponents: true`, also → `references/instant-navigation.md` for which links prefetch and which reads wait for the navigation. ✓ Route props become plain values; stable cards/sections wrap Suspense when they set layout; boundaries stay visible at the page. 7. **Add interaction** (if any): optimistic updates, pending state, toasts, confirmation. → `references/ux-patterns.md`. ✓ Feedback isn't doubled; optimistic reducers/actions live with the feature; URL/search params own shareable state; client effects synchronize external systems, not derived React state. 8. **Verify** against the checklist below before declaring done. When the change touches a loading sequence, lock it in with an end-to-end test. → `references/testing.md`. ✓ The test asserts which content is available before uncached work resolves, using visibility-aware locators. ## Verify before done Inspect the diff against every invariant — each is checkable by reading the changed files: - [ ] No page/layout imports a `*-queries` file or defines reusable domain UI inline; any inline helper is route-local control flow only. - [ ] Every page with params is synchronous and uses `params.then()` / `searchParams.then()` / `Promise.all([params, searchParams]).then(...)`. - [ ] Feature components receive plain IDs/handles/parsed filters or resolved records; no feature prop is named `params` or `searchParams`. - [ ] Every `` for page data sits in the page; no feature pre-wraps itself. - [ ] Stable wrappers/cards/chrome sit outside Suspense; fallback and final content do not duplicate the same outer card. - [ ] Every component has its real `*Skeleton` in the same file, at the end; no tiny skeleton aliases just to pass props. - [ ] Skeleton and content are the same height (measured), section headings sit outside the boundary, and sections that can be empty reserve their space. - [ ] Sections below a variable-height section are nested sibling boundaries inside it, not awaited by it and not passed through it as children. - [ ] `unstable_prefetch()` / `unstable_navigation()` are awaited in the component before its query call; no wrapper query exists just to hold the gate. - [ ] Every `*-queries.ts` starts with `import 'server-only'`; every `*-actions.ts` with `'use server'`. - [ ] With `cacheComponents: true`, reusable reads use `'use cache'` / `cacheTag` / `cacheLife`, or `'use cache: private'` / `'use cache: remote'` when appropriate; any dynamic read is intentional and justified. - [ ] A personalized shared-cache scope receives trusted, normalized identity values from an uncached feature wrapper; it never reads request APIs or trusts a client-provided identity inside the cache scope. - [ ] Reads whose every write calls `updateTag()` use `cacheLife('max')`; a shorter profile is reserved for data that changes outside the app's actions. - [ ] Mutations touching cached reads call `updateTag()` / `revalidateTag(..., 'max')` for the matching tags; `refresh()` is not a substitute for tag invalidation. - [ ] `prefetch={true}` is reserved for links users predictably follow, or for rows whose per-link prefetch was made cheap by gating the heavy part behind `unstable_navigation()`. - [ ] Action files are named `-actions.ts`; no entity-owned sub-concept spawned its own folder, and cross-domain product features do not take ownership of entity queries/actions. - [ ] Features with both server tags and client query keys define them once in a pure `-cache.ts`; queries, actions, hydration, query options, and hooks import from it. - [ ] Feature-local client-support files sit in the smallest fitting place: query options at the feature root, `use-*` hook wrappers in `hooks/`, leaf components in `components/`, and shared support only after real cross-feature reuse. - [ ] `'use client'` components are leaves — they import actions/hooks/providers, not async server components. A client component that needs server data has a server half in the feature (`ComposePanel` → `ComposePanelClient`); no page or layout creates query promises for it. - [ ] Client leaves use `useOptimistic`, transitions, reducers, URL state, or form actions for interaction; they do not call `setState` in effects for derived React state. - [ ] Forms whose action can reject input keep what the user typed: the action state returns the submitted values and the fields read them as `defaultValue`. - [ ] Mutations validate their input and invalidate the affected data. ## Reference index - **`references/feature-folders.md`** — where code goes: folder layout, cache contracts, naming, and merging sub-concepts. - **`references/queries-actions.md`** — query/action rules: server-only, dedup, validation, invalidation, return shape. - **`references/components.md`** — server/client boundary, skeletons, `use()`, single-use helpers, live data. - **`references/pages-suspense.md`** — page composition, `params.then()`, Suspense placement, CLS, error boundaries. - **`references/instant-navigation.md`** — prefetching decisions: `prefetch={true}`, the three render stages and their gates, validating what a navigation shows, hidden routes. - **`references/cache-components.md`** — the `cacheComponents` decisions: which reads to cache, which directive to use, how to invalidate. - **`references/single-page-applications.md`** — client cache decisions: placement, server seeding, Cache Components coordination, hydration, and mutations. - **`references/ux-patterns.md`** — interaction decisions: optimistic vs pending vs inline error, toasts, action-prop, confirmations. - **`references/testing.md`** — end-to-end tests: asserting the loading sequence with `instant()`, locating content across hidden routes, testing mutations. - **`references/example.md`** — the next-beats reference app: invariant → file map, for seeing any rule in real code.