refactor: ortak tipler @/types/yokatlas + features/rapor/types'a çıkarıldı, server-only korumaları eklendi (Faz 1)
- Program/PUAN_TURLERI/DilimKey/RankResults/UniturGrubu/ProgramNetSatiri/ SihirbazFacetleri artık src/types/yokatlas.ts'te; lib/db.ts yalnızca sorgu fonksiyonları barındırıyor - RaporSonuc/RaporKapsam/RaporParams/MaskeliRapor src/features/rapor/types/ rapor.ts'e taşındı; ölü kalan TercihSchema/RaporSchema silindi - 11 lib dosyasına import "server-only" eklendi; scripts'in düz node ile import ettiği dosyalara (db, appdb, rapor-havuzu, tadimlik-havuzu, ai/client, ai/cagri, credits) bilinçli eklenmedi - Davranış değişikliği yok; pnpm build yeşil Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
101
.claude/skills/nextjs-app-architecture/SKILL.md
Normal file
101
.claude/skills/nextjs-app-architecture/SKILL.md
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
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.3.7"
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
## 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, `<Suspense>` 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.
|
||||
- Skeletons mirror the component tree and live beside the component they represent.
|
||||
|
||||
## 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 `<Suspense>`. No queries, no domain logic, no route-specific components defined inline.
|
||||
2. **Pages stay synchronous.** Use `params.then()` / `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 `<Suspense>`.
|
||||
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 `<domain>-queries.ts`** (`import 'server-only'`); **actions live in `<domain>-actions.ts`** (`'use server'`). The file name matches the folder, even for sub-concepts.
|
||||
8. **One feature folder per real domain noun.** Sub-concepts (favorite, like, vote, bookmark, search) fold into the parent feature, never their own folder.
|
||||
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 `<domain>-cache.ts`, client query definitions in `<domain>-query-options.ts`, hook wrappers in `hooks/use-*.ts`, and tiny client leaves in `components/`; promote support code only after real cross-feature reuse.
|
||||
|
||||
## 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, 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 `<domain>-queries.ts` with `import 'server-only'`; keep shared tag/key identities in a pure `<domain>-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/<domain>/<domain>-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/<domain>/components/<name>.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/<route>/page.tsx`: synchronous, `params.then()`, place `<Suspense fallback={<NameSkeleton />}><Name /></Suspense>`, and wrap fallible sections in an error boundary.
|
||||
→ `references/pages-suspense.md`.
|
||||
✓ The page only composes; the boundary lives here, not in the feature; route props are resolved to plain values before reaching feature components.
|
||||
7. **Add interaction** (if any): optimistic updates, pending state, toasts, confirmation.
|
||||
→ `references/ux-patterns.md`.
|
||||
✓ Feedback isn't doubled; destructive actions confirm; feature-owned client coordination stays with that feature instead of leaking into unrelated domains.
|
||||
8. **Verify** against the checklist below before declaring done.
|
||||
|
||||
## 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 a route-specific component inline.
|
||||
- [ ] Every page with params is synchronous and uses `params.then()` / `searchParams.then()`.
|
||||
- [ ] Feature components receive plain IDs/handles/parsed filters or resolved records; no feature prop is named `params` or `searchParams`.
|
||||
- [ ] Every `<Suspense>` for page data sits in the page; no feature pre-wraps itself.
|
||||
- [ ] Every component has its real `*Skeleton` in the same file, at the end; no tiny skeleton aliases just to pass props.
|
||||
- [ ] 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.
|
||||
- [ ] Mutations touching cached reads call `updateTag()` / `revalidateTag(..., 'max')` for the matching tags; `refresh()` is not a substitute for tag invalidation.
|
||||
- [ ] Action files are named `<folder>-actions.ts`; no sub-concept spawned its own folder.
|
||||
- [ ] Features with both server tags and client query keys define them once in a pure `<domain>-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.
|
||||
- [ ] 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, prefetch.
|
||||
- **`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/example.md`** — the next-beats reference app: invariant → file map, for seeing any rule in real code.
|
||||
20
.claude/skills/nextjs-app-architecture/metadata.json
Normal file
20
.claude/skills/nextjs-app-architecture/metadata.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "1.3.7",
|
||||
"organization": "Aurora Scharff",
|
||||
"date": "July 2026",
|
||||
"abstract": "Build and audit Next.js 16 App Router apps with a next-beats-style RSC architecture: synchronous pages that compose static shells, Suspense, and error boundaries; feature-owned async server components that receive IDs or parsed values; co-located skeletons; server-only queries; server actions; enforced Cache Components practice when enabled; and build/runtime verification of the resulting loading shape. References cover each topic in depth so the agent only loads what's needed for the task.",
|
||||
"references": [
|
||||
"https://preview.nextjs.org/docs/app",
|
||||
"https://preview.nextjs.org/docs/app/guides/ai-agents",
|
||||
"https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents",
|
||||
"https://preview.nextjs.org/docs/app/api-reference/directives/use-cache",
|
||||
"https://preview.nextjs.org/docs/app/api-reference/functions/cacheTag",
|
||||
"https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheLife",
|
||||
"https://preview.nextjs.org/docs/app/api-reference/functions/updateTag",
|
||||
"https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching",
|
||||
"https://preview.nextjs.org/docs/app/guides/migrating-to-cache-components",
|
||||
"https://preview.nextjs.org/docs/app/guides/interactive-apps",
|
||||
"https://aurorascharff.no/posts/component-architecture-for-react-server-components/",
|
||||
"https://github.com/vercel-labs/next-beats"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Cache Components
|
||||
|
||||
Decisions for when [`cacheComponents: true`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents) is set in `next.config.ts`. This file is about *which reads to cache, which directive to use, and how to invalidate* — for the mechanics of each directive, follow the doc links.
|
||||
|
||||
## When this reference applies
|
||||
|
||||
Use this reference when an app already has `cacheComponents: true`, the user wants this architecture while enabling it, or you are reviewing/refactoring an app that targets Cache Components.
|
||||
|
||||
If the project has not adopted Cache Components yet and the user asks to enable, migrate, or work through adoption blockers, use the `next-cache-components-adoption` skill first. It owns the route-by-route migration loop, opt-out strategy, and build/dev overlay workflow. Then return here for steady-state query/action/component architecture.
|
||||
|
||||
If `cacheComponents` is not enabled and the task is ordinary feature work, follow the core references without adding cache directives. Do not recommend skipping Cache Components based on app category alone; adoption is a migration/project decision, not a per-feature shortcut.
|
||||
|
||||
Adopting these in an existing app: follow [Migrating to Cache Components](https://preview.nextjs.org/docs/app/guides/migrating-to-cache-components) and [Adopting Partial Prefetching](https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching) — they cover the incremental path (per-route `prefetch = 'partial'`, fixing dynamic-usage build errors) rather than a big-bang switch.
|
||||
|
||||
## The model
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
const nextConfig: NextConfig = {
|
||||
cacheComponents: true,
|
||||
partialPrefetching: true, // prefetch the static shell of linked routes
|
||||
};
|
||||
```
|
||||
|
||||
- **Static shell** — synchronous content, `'use cache'` output, and Suspense fallbacks prerender at build time.
|
||||
- **Dynamic holes** — async work without `'use cache'` streams in behind `<Suspense>` at request time.
|
||||
- **Build constraint** — any async work without `'use cache'` must sit inside `<Suspense>`, or the build fails (wrap it, or add `'use cache'`).
|
||||
|
||||
`cacheComponents` implies Partial Prerendering — it replaced `experimental.ppr` / `dynamicIO` / `useCache`, so don't set those. See [caching](https://preview.nextjs.org/docs/app/getting-started/caching).
|
||||
|
||||
With `cacheComponents: true`, the skill practice is **cache reusable reads**. Do not leave a database/API read dynamic just because Suspense makes the build pass. If a read has a stable key and a mutation can name what changed, give it a cache directive, tags, and a lifetime.
|
||||
|
||||
Dynamic reads are the exception: use them for values that must be recomputed for every request or cannot be invalidated coherently. When you leave a read dynamic, note the reason in the surrounding code/review and invalidate its mutations with `refresh()` because there is no tag to update.
|
||||
|
||||
## Decide what to cache
|
||||
|
||||
| Data | Directive | Notes |
|
||||
| ---- | --------- | ----- |
|
||||
| Cacheable across users (public listings, computed pages) | [`'use cache'`](https://preview.nextjs.org/docs/app/api-reference/directives/use-cache) | Add [`cacheTag`](https://preview.nextjs.org/docs/app/api-reference/functions/cacheTag) (a global + a scoped tag) and a [`cacheLife`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheLife) profile. |
|
||||
| Per-user / reads cookies, headers, session | [`'use cache: private'`](https://preview.nextjs.org/docs/app/api-reference/directives/use-cache-private) | Cached in the browser only, doesn't persist across reloads; never stored on the server. |
|
||||
| Remote service, safe across users, worth durable storage | [`'use cache: remote'`](https://preview.nextjs.org/docs/app/api-reference/directives/use-cache-remote) | Protects against rate-limited third-party APIs. |
|
||||
| Genuinely dynamic per request | none | Must be justified. Read inside `<Suspense>`; mutations use `refresh()` because no tag exists. |
|
||||
|
||||
Cache the **query** when its result should be reused across requests. Cache the **component** when rendering is expensive and props are stable (a nav, a trending sidebar). Don't `'use cache'` a component that already calls a `'use cache'` query — double-caching, no benefit.
|
||||
|
||||
## Keep a synchronous value out of the shell
|
||||
|
||||
You usually don't need this. A query that reads `cookies()`/`headers()` or awaits a DB/`fetch` inside `<Suspense>` already stays out of the shell on its own. Only a *synchronous* request-time read (`new Date()`, `Math.random()`, a sync sqlite read) needs help: `await` [`io()`](https://preview.nextjs.org/docs/app/api-reference/functions/io) before it, with the caller inside `<Suspense>`.
|
||||
|
||||
Prefer `io()` over [`connection()`](https://preview.nextjs.org/docs/app/api-reference/functions/connection): both exclude what follows from the shell, but `connection()` blocks prefetches while `io()` stays prefetchable. Reach for `connection()` only when rendering must wait for a real user request.
|
||||
|
||||
## Decide how to invalidate
|
||||
|
||||
- [`updateTag(tag)`](https://preview.nextjs.org/docs/app/api-reference/functions/updateTag) — in **server actions**, when the user should see the result immediately (read-your-own-writes). Requires the query to carry a matching `cacheTag`.
|
||||
- [`revalidateTag(tag, 'max')`](https://preview.nextjs.org/docs/app/api-reference/functions/revalidateTag) — in **route handlers** (webhooks, cron) for stale-while-revalidate. The single-arg `revalidateTag(tag)` form is deprecated.
|
||||
- [`refresh()`](https://preview.nextjs.org/docs/app/api-reference/functions/refresh) — re-render the current route for the current user. Use it for deliberately dynamic reads with no tag; don't use it instead of `updateTag()` for cached reads.
|
||||
|
||||
Tag, cache, invalidate: the `cacheTag` in the query and the `updateTag` in the action use the same string and live in the same feature folder.
|
||||
|
||||
## Coordinate hydrated client data
|
||||
|
||||
When cached server data seeds SWR, TanStack Query, or another browser cache, follow `references/single-page-applications.md`. Server and client freshness policies are independent; hydration adds library-specific constraints.
|
||||
|
||||
## Build failure map
|
||||
|
||||
When `next build` fails under Cache Components, map the error back to an architecture rule instead of patching locally:
|
||||
|
||||
- Async work without `'use cache'` and without an ancestor `<Suspense>` → cache the reusable read, or wrap a justified dynamic read in a page-owned `<Suspense>`.
|
||||
- Request data inside `'use cache'` → switch to `'use cache: private'` when it is per-user cacheable, or keep it dynamic with a documented reason.
|
||||
- `await params` / `await searchParams` at the top of a page → keep the page synchronous and move the read into `params.then()` / `searchParams.then()`.
|
||||
- Sync request-time values (`new Date()`, `Math.random()`, sync storage reads) captured in the shell → cache stable values, or use [`io()`](https://preview.nextjs.org/docs/app/api-reference/functions/io) for per-request values.
|
||||
|
||||
For adoption-wide blocker triage, use `next-cache-components-adoption`. For API-specific recipes, follow the [Migrating to Cache Components guide](https://preview.nextjs.org/docs/app/guides/migrating-to-cache-components).
|
||||
|
||||
## Without Cache Components
|
||||
|
||||
- Don't use `'use cache'` / `cacheTag` / `cacheLife` — they require the flag.
|
||||
- Use React `cache()` only for proven same-request dedup needs; plain `server-only` async queries are the default.
|
||||
- Invalidate with `refresh()` from server actions.
|
||||
- Pages still use `params.then()` in this architecture. Without Cache Components there is no build-time static shell to preserve, but keeping pages synchronous still lets chrome paint before route-specific data resolves and keeps the app consistent.
|
||||
214
.claude/skills/nextjs-app-architecture/references/components.md
Normal file
214
.claude/skills/nextjs-app-architecture/references/components.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Components
|
||||
|
||||
How to build server and client components inside a feature folder.
|
||||
|
||||
## Default: async server component
|
||||
|
||||
Server components await their own queries directly — no `useEffect`, no client-side fetching, no manual loading state. See the [Server Components docs](https://preview.nextjs.org/docs/app/getting-started/server-and-client-components) for the model.
|
||||
|
||||
Prefer minimal, stable props: IDs, slugs, handles, parsed filters, or records the parent already fetched. Do not pass raw route `params` or `searchParams` into feature components. Pages resolve those promises and pass plain values.
|
||||
|
||||
```tsx
|
||||
// features/notifications/components/notifications-badge.tsx
|
||||
import { getUnreadNotificationCount } from "@/features/notifications/notifications-queries";
|
||||
|
||||
export async function NotificationsBadge() {
|
||||
const count = await getUnreadNotificationCount();
|
||||
if (count === 0) return null;
|
||||
return <span aria-label={`${count} unread`}>{count}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
The page (not this file) wraps it in `<Suspense fallback={<NotificationsBadgeSkeleton />}>` — see `references/pages-suspense.md`.
|
||||
|
||||
For parameterized routes, the page resolves `params` and the feature receives an ID:
|
||||
|
||||
```tsx
|
||||
// app/post/[id]/page.tsx
|
||||
<Suspense fallback={<PostDetailSkeleton />}>
|
||||
{params.then(({ id }) => (
|
||||
<PostDetail id={id} />
|
||||
))}
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
```tsx
|
||||
// features/post/components/post-detail.tsx
|
||||
export async function PostDetail({ id }: { id: string }) {
|
||||
const post = await getPost(id);
|
||||
return <article>{post.body}</article>;
|
||||
}
|
||||
```
|
||||
|
||||
## Skeletons live in the same file
|
||||
|
||||
Export the main component and its skeleton from the same file. Pages import both. Define the skeleton **at the end of the file**, below the real component(s) — never above. Function declarations are hoisted, so a skeleton referenced by a component earlier in the file still works when defined last.
|
||||
|
||||
```tsx
|
||||
export async function Feed({ userId }: { userId: string }) {
|
||||
const posts = await getFeed(userId);
|
||||
return (
|
||||
<ul>
|
||||
{posts.map((p) => (
|
||||
<Post key={p.id} post={p} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeedSkeleton() {
|
||||
return (
|
||||
<ul>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<li key={i}>
|
||||
<Skeleton className="h-24" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Don't export a second skeleton whose whole job is to rename or preconfigure another skeleton:
|
||||
|
||||
```tsx
|
||||
// Wrong — alias wrapper adds an import surface but no behavior
|
||||
export function CompactGridSkeleton() {
|
||||
return <GridSkeleton dense />;
|
||||
}
|
||||
```
|
||||
|
||||
Import the real skeleton and pass the prop inline at the `<Suspense>` boundary: `fallback={<GridSkeleton dense />}`.
|
||||
|
||||
### Skeleton design checklist
|
||||
|
||||
1. Match the real component's layout: flex direction, gaps, padding, breakpoints.
|
||||
2. Include all structural elements: avatar circles, action button placeholders, image squares.
|
||||
3. Responsive visibility must match (`hidden sm:block` in the real component → same in the skeleton).
|
||||
4. Show 2–5 placeholders for variable-length lists, not the real count.
|
||||
5. Don't include skeletons for inner Suspense content — those have their own boundaries.
|
||||
6. Reserve the right height. CLS comes from skeletons that are shorter than the real content.
|
||||
|
||||
## Group related components in one file
|
||||
|
||||
A card and its grid live in the same file. For example, `genre-card.tsx` exports `GenrePill`, `GenreCard`, `GenreGrid`, `GenreGridSkeleton`. Variants should reuse that skeleton inline instead of exporting alias skeletons. Don't split shared UI primitives prematurely — wait until three call sites need the same shape before extracting.
|
||||
|
||||
Two sidebar widgets that happen to look similar but render different data shapes are **not** the same component. The visuals diverge as soon as one needs an extra slot.
|
||||
|
||||
### Single-use sub-components stay inlined
|
||||
|
||||
For a metadata strip inside one card, a header used only by one detail view, a list item only rendered by its list — inline them as **non-exported** functions in the same file:
|
||||
|
||||
```tsx
|
||||
export async function EventDetails({ slug }: { slug: string }) {
|
||||
const event = await getEventBySlug(slug);
|
||||
return (
|
||||
<article>
|
||||
<MetaStrip event={event} />
|
||||
<Speaker speaker={event.speaker} />
|
||||
<p>{event.description}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaStrip({ event }: { event: Event }) { ... }
|
||||
function Speaker({ speaker }: { speaker: string }) { ... }
|
||||
```
|
||||
|
||||
Exports are for things other files will import. Internal structure is for readability inside one file.
|
||||
|
||||
## The server/client boundary
|
||||
|
||||
`'use client'` only when you need:
|
||||
|
||||
- Hooks (`useState`, `useReducer`, `useOptimistic`, `useTransition`, `useEffect`)
|
||||
- Event handlers (`onClick`, `onChange`, `onSubmit`)
|
||||
- Browser APIs (`window`, `localStorage`, refs to DOM)
|
||||
|
||||
If the component needs interactive pieces, keep the server component as the parent and render client leaves:
|
||||
|
||||
```tsx
|
||||
async function PostDetail({ id }: { id: string }) {
|
||||
const [post, userState] = await Promise.all([
|
||||
getPost(id),
|
||||
getPostUserState(id),
|
||||
]);
|
||||
return (
|
||||
<article>
|
||||
<PostBody body={post.body} />
|
||||
<PostActions userState={userState} /> {/* 'use client' leaf */}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Server content as children of client components
|
||||
|
||||
Composition crosses the boundary. A client component can accept server-rendered JSX as children or props:
|
||||
|
||||
```tsx
|
||||
<ComposerForm
|
||||
avatar={
|
||||
<Suspense fallback={<AvatarSkeleton />}>
|
||||
<CurrentUserAvatar />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
`ComposerForm` is `'use client'`. It doesn't know where the avatar JSX came from. The Suspense boundary streams the avatar in without the form re-rendering.
|
||||
|
||||
### Pass server children resolved values, not promises
|
||||
|
||||
Prefer passing plain values (strings, IDs, resolved data) to a server child. A server component _can_ `await` a promise prop, but resolve route promises in the page instead — pass an unresolved promise down only to a _client_ component that reads it with `use()` (see below). When a parent already has the data from its own query, pass it as a prop instead of having the child refetch.
|
||||
|
||||
```tsx
|
||||
// Right — parent fetches the list, passes each item
|
||||
async function Feed({ userId }: { userId: string }) {
|
||||
const posts = await getFeed(userId);
|
||||
return posts.map((post) => <Post key={post.id} post={post} />);
|
||||
}
|
||||
|
||||
async function Post({ post }: { post: Post }) {
|
||||
return <article>{post.body}</article>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Wrong — child refetches what the parent already had
|
||||
async function Post({ id }: { id: string }) {
|
||||
const post = await getPost(id);
|
||||
return <article>{post.body}</article>;
|
||||
}
|
||||
```
|
||||
|
||||
## Client components that own their loading state
|
||||
|
||||
When a client component needs server data but should manage its own loading (a sidebar badge, a popover that opens on hover), pass an **unresolved promise** from the server and resolve it with [`use()`](https://react.dev/reference/react/use) on the client. Wrap the consumer in `<Suspense>`.
|
||||
|
||||
```tsx
|
||||
// page: pass the unresolved promise, wrap in Suspense
|
||||
<Suspense fallback={<TagListSkeleton />}>
|
||||
<TagPicker itemsPromise={getTags()} />
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
import { use } from "react";
|
||||
|
||||
export function TagPicker({ itemsPromise }: { itemsPromise: Promise<Tag[]> }) {
|
||||
const items = use(itemsPromise);
|
||||
// render interactive UI from items
|
||||
}
|
||||
```
|
||||
|
||||
The opinionated bit: name promise props with a `Promise` suffix (`itemsPromise`, `userPromise`) so the contract is obvious at the call site.
|
||||
|
||||
### Client data libraries (SWR, TanStack Query)
|
||||
|
||||
Follow `references/single-page-applications.md` when a feature uses a browser data cache or needs externally authored updates. It covers when to use a library, where its files live, server seeding, Cache Components coordination, hydration, and mutations.
|
||||
|
||||
## Mutations
|
||||
|
||||
For client-side reactions to a server mutation (instant feedback, pending state, success/error toasts), see `references/ux-patterns.md`. To cache rendered output across requests, see `references/cache-components.md`.
|
||||
34
.claude/skills/nextjs-app-architecture/references/example.md
Normal file
34
.claude/skills/nextjs-app-architecture/references/example.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Reference app: next-beats
|
||||
|
||||
A working app that follows this architecture: **<https://github.com/vercel-labs/next-beats>** (Next.js 16, `cacheComponents` + `partialPrefetching`, a music player). Use it to see any invariant in real code rather than restating the code here. Paths are as of this writing — verify against the current repo.
|
||||
|
||||
For the reasoning behind this architecture, read [Component Architecture for React Server Components](https://aurorascharff.no/posts/component-architecture-for-react-server-components/). The skill's target is the same shape: pages describe layout and loading; feature components own server reads; route params become IDs before they reach components.
|
||||
|
||||
## Invariant → where to see it
|
||||
|
||||
| Invariant | File(s) |
|
||||
| --------- | ------- |
|
||||
| 1. Pages compose, never fetch | `app/(app)/search/page.tsx`, `app/(app)/genre/[genre]/page.tsx` — import feature components, place `<Suspense>`, no queries. |
|
||||
| 2. Pages stay synchronous (`params.then` / `searchParams.then`) | `app/(app)/track/[id]/page.tsx`, `app/(app)/genre/[genre]/page.tsx`, `app/(app)/search/page.tsx`. |
|
||||
| 3. Feature components receive IDs, not route props | Track/genre pages resolve `params` / `searchParams` and pass `id`, `genre`, or parsed values into feature components. |
|
||||
| 4. Async server component default; `'use client'` on leaves | Server: `features/track/components/discover.tsx`, `most-played.tsx`. Client leaves: `features/track/components/track-interactions.tsx`, `play-button.tsx`. |
|
||||
| 5. Page owns Suspense; feature owns skeleton | Pages place the boundary (e.g. `app/(app)/genre/[genre]/page.tsx`); features export the skeleton (below). |
|
||||
| 6. Skeleton in the same file, at the end | `features/track/components/track-row.tsx` (`TrackRow` … `TrackListSkeleton`), `features/genre/components/genre-card.tsx`. |
|
||||
| 7. `<domain>-queries.ts` (`server-only`) / `<domain>-actions.ts` (`'use server'`) | `features/track/track-queries.ts`, `features/playlist/playlist-actions.ts`. |
|
||||
| 8. One folder per domain; sub-concepts folded in | `toggleFavorite` in `features/track/track-actions.ts` (no `favorite` folder); `searchTracks` in `features/track/track-queries.ts` (no `search` folder). |
|
||||
| 9. Client components import actions directly | `features/track/components/track-interactions.tsx` imports `toggleFavorite` directly. |
|
||||
|
||||
## Supporting patterns
|
||||
|
||||
| Pattern | File |
|
||||
| ------- | ---- |
|
||||
| Error boundary on `catchError` (`ErrorInfo` `retry`) | `components/ui/error-boundary.tsx` |
|
||||
| `useOptimistic` for an unlikely-to-fail toggle | `features/track/components/track-interactions.tsx` |
|
||||
| Action-prop / `*Action` convention + confirm dialog | `features/playlist/components/playlist-interactions.tsx`, `components/ui/confirm-dialog.tsx` |
|
||||
| `useFormStatus` submit button | `components/ui/button.tsx` |
|
||||
| `useActionState` inline field errors | `features/user/components/sign-in-form.tsx` |
|
||||
| Client-owned live data via a provider (not `<Poller>`) | `providers/player-provider.tsx` → `components/now-playing-bar.tsx` |
|
||||
| `use()` on an unresolved promise prop | `features/playlist/components/add-to-playlist-menu.tsx` |
|
||||
| Purpose-named `components/scripts/` subfolder | `components/scripts/` |
|
||||
|
||||
> The repo is a live app, not a golden reference — spots may drift from the invariants (a page may fetch inline, a route `error.tsx` may lag an API rename). When the app and the invariants disagree, the invariants win; treat the mismatch as a fix for the app.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Feature folders
|
||||
|
||||
How to organize code under `features/` and `app/`.
|
||||
|
||||
## Folder layout
|
||||
|
||||
```
|
||||
features/<domain>/
|
||||
<domain>-cache.ts # Pure server tags + client query keys, when shared
|
||||
<domain>-queries.ts # Server-only queries
|
||||
<domain>-actions.ts # Server actions
|
||||
<domain>-query-options.ts # Client data-library query definitions, when needed
|
||||
components/ # Server + client components, each with its skeleton
|
||||
types/ # Feature-local public types, when needed by multiple files
|
||||
hooks/ # Actual feature-local React hooks and hook wrappers
|
||||
providers/ # Feature-local providers, only when the provider belongs to this domain
|
||||
```
|
||||
|
||||
The folder name **is** the domain. The query and action filenames match the folder.
|
||||
|
||||
## How many features?
|
||||
|
||||
Keep the feature list short. One folder per **domain noun a user would recognize**, not per database table or technical concern.
|
||||
|
||||
A new folder is justified when **all three** are true:
|
||||
|
||||
1. The concept has its own queries.
|
||||
2. The concept has its own pages or routes.
|
||||
3. The concept is referenced from at least two other features.
|
||||
|
||||
If you find yourself making a feature folder with one query, one action, and one button, fold it into the parent feature instead.
|
||||
|
||||
### Merge aggressively
|
||||
|
||||
Concepts that exist only in service of a parent entity belong inside the parent's feature folder:
|
||||
|
||||
- A `favorite` or `bookmark` concept that only attaches to one parent entity (events, posts) → inside that parent's folder.
|
||||
- A `like`, `repost`, `vote`, or `reaction` concept on a piece of content → with that content's feature.
|
||||
- `auth` / `session` / `current user` → a single `user` folder, not split.
|
||||
- A cross-cutting concern like `search` folds into the primary content feature it queries (`searchTracks` in `features/track/`), not a `features/search/` folder — the page composes it.
|
||||
|
||||
Concrete example: `toggleFavorite` is a mutation about events ("I favorite an event"), not its own domain. It lives in `features/event/event-actions.ts`, not `features/favorite/favorite-actions.ts`.
|
||||
|
||||
## File naming
|
||||
|
||||
Filenames inside the folder always start with the folder name:
|
||||
|
||||
```
|
||||
features/event/
|
||||
event-queries.ts
|
||||
event-actions.ts
|
||||
components/
|
||||
event-grid.tsx
|
||||
event-details.tsx
|
||||
favorite-button.tsx ← OK: a component, not a "favorite" feature
|
||||
```
|
||||
|
||||
- `<folder>-queries.ts` — even if the file has only one query.
|
||||
- `<folder>-actions.ts` — even if a mutation is about a sub-concept.
|
||||
- Other `<folder>-*.ts` files are fine when the folder needs them (`playlist-constants.ts`, `<folder>-schema.ts`), as long as they keep the folder-name prefix. Don't put reusable domain types in a root `*-types.ts` file; use `features/<domain>/types/` once a type is imported by multiple files.
|
||||
- Component files use any descriptive name. The component (not the feature) is the unit here.
|
||||
|
||||
## Local vs shared support folders
|
||||
|
||||
Use a local support folder when the code belongs to one feature:
|
||||
|
||||
```
|
||||
features/message/
|
||||
message-cache.ts
|
||||
message-query-options.ts
|
||||
types/
|
||||
message.ts
|
||||
hooks/
|
||||
use-message-mutations.ts
|
||||
use-message-draft.ts
|
||||
providers/
|
||||
message-draft-provider.tsx
|
||||
```
|
||||
|
||||
Feature-owned client coordination stays with the feature. Place each file by the shape it exports and the domain it belongs to:
|
||||
|
||||
- Client data-library cache contracts and query definitions follow `references/single-page-applications.md`.
|
||||
- Mutation wrappers that export hooks live in `hooks/use-*.ts` (`use-message-mutations.ts` exporting `useSendMessage`).
|
||||
- Browser-only state helpers live in `hooks/` when their public API is a hook (`use-thread.ts`, `use-message-draft.ts`).
|
||||
- Client leaf components that coordinate a server write live in `components/` next to the UI they support (`mark-activity-read.tsx` posts read activity in the background while the current `/activity` tree stays stable).
|
||||
|
||||
Keep the file prefix aligned with the feature folder when a file exports a grouped feature contract (`workspace-cache.ts` and `workspace-query-options.ts`, not `activity-cache.ts` in `features/workspace/`). Support code for a sub-concept still lives with the parent feature: reactions on messages belong in `features/message/`; unread activity chrome belongs in `features/workspace/`.
|
||||
|
||||
Promote only when there are real cross-feature consumers:
|
||||
|
||||
- `types/` at the project root — shared domain/application types imported across features.
|
||||
- `hooks/` at the project root — shared client hooks used across features.
|
||||
- `app/providers.tsx` or `components/*-provider.tsx` — app-shell providers that wrap the whole app.
|
||||
|
||||
Avoid root-level miscellany like `message-types.ts`, `shared-hooks.ts`, or `common-provider.tsx`; the folder name should explain the scope.
|
||||
|
||||
## What goes in `components/`
|
||||
|
||||
Each component file exports the main component **plus its skeleton**:
|
||||
|
||||
```tsx
|
||||
// features/event/components/event-grid.tsx
|
||||
export async function EventGrid(...) { ... }
|
||||
export function EventGridSkeleton() { ... }
|
||||
```
|
||||
|
||||
Group related components in one file when they're always used together or one is a natural building block for another. A card and its grid live together. For example, `genre-card.tsx` exports `GenrePill`, `GenreCard`, `GenreGrid`, `GenreGridSkeleton`.
|
||||
|
||||
Split into separate files only when:
|
||||
|
||||
- A component is consumed by multiple sibling components (one shared use is not enough — wait until three call sites need it).
|
||||
- A component is `'use client'` and a sibling is a server component (the server/client boundary forbids sharing a file).
|
||||
|
||||
See `references/components.md` for inlining rules and the skeleton design checklist.
|
||||
|
||||
## What pages do
|
||||
|
||||
Pages in `app/` compose feature components with Suspense and transition wrappers. They never:
|
||||
|
||||
- Contain domain logic
|
||||
- Define new components except thin transition wrappers (e.g. `<ViewTransition>`)
|
||||
- Fetch data directly
|
||||
- Inline route-specific components — extract them into the feature folder
|
||||
|
||||
See `references/pages-suspense.md` for page composition details.
|
||||
|
||||
## Top-level layout
|
||||
|
||||
```
|
||||
app/ # Pages and layouts
|
||||
features/ # Domain folders
|
||||
components/ # UI primitives, theme, and app-shell singletons
|
||||
hooks/ # Shared client hooks used across features
|
||||
types/ # Shared cross-feature types only
|
||||
lib/ # Utilities and cohesive non-domain subsystems
|
||||
```
|
||||
|
||||
`lib/` holds flat helpers (`db.ts`, `utils.ts`) but may also group a cohesive non-domain subsystem in its own subfolder (e.g. `lib/audio/` for an audio engine). Cross-feature client hooks live in top-level `hooks/`; a hook used by a single feature co-locates in that feature's `hooks/`. Types follow the same rule: shared types at top-level `types/`, feature-only exported types in `features/<domain>/types/`.
|
||||
|
||||
`components/` holds:
|
||||
|
||||
- **`components/ui/`** — primitives. Low-level building blocks and action-prop components.
|
||||
- **`components/theme/`** — theme provider and toggle, paired.
|
||||
- **Top-level files** (`site-header.tsx`, `auth-gate.tsx`, `poller.tsx`) — app-shell singletons used once each. No `common/` folder — "common" is not a category. If a component is used everywhere it's a primitive (→ `ui/`); if it's used once it lives at the top level.
|
||||
- **Purpose-named subfolders** are fine when several files share a clear technical role — e.g. `components/scripts/` for pre-hydration inline `<script>` seed components. This is distinct from the rejected `common/`: a `scripts/` folder names _what the files are_, not "miscellaneous."
|
||||
|
||||
Conventions for filenames and casing live in the project's `AGENTS.md`. This skill doesn't impose one.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Pages and Suspense
|
||||
|
||||
How to compose pages, place Suspense boundaries, and prevent layout shift.
|
||||
|
||||
## Pages are composition only
|
||||
|
||||
Pages in `app/` import feature components and place `<Suspense>` boundaries. They never:
|
||||
|
||||
- Fetch data directly (queries live in feature folders)
|
||||
- Define new components except thin transition wrappers (e.g. `<ViewTransition>`)
|
||||
- Inline route-specific UI (extract it into the feature folder)
|
||||
- Pass raw `params` / `searchParams` to features
|
||||
|
||||
## Page function signatures
|
||||
|
||||
Type page and layout functions with the auto-generated `PageProps<'/route'>` / `LayoutProps<'/route'>` helpers — no import, regenerated on `next dev` / `next build` / `next typegen`. See [route type helpers](https://preview.nextjs.org/docs/app/api-reference/config/typescript#route-type-helpers).
|
||||
|
||||
```tsx
|
||||
export default function PostPage({ params }: PageProps<'/post/[id]'>) { /* ... */ }
|
||||
```
|
||||
|
||||
Don't hand-write `{ params: Promise<{ id: string }> }` — the generated types stay in sync with the route (catch-all, optional segments). Route handlers use `RouteContext<'/api/...'>`. `typedRoutes: true` is a *separate* feature (statically-typed `href`s), not the source of these helpers.
|
||||
|
||||
## Keep pages synchronous
|
||||
|
||||
Use `params.then()` instead of `await params`. Content above the `.then()` pre-renders into the static shell; content inside it suspends.
|
||||
|
||||
```tsx
|
||||
import { Suspense } from 'react';
|
||||
import { PostDetail, PostDetailSkeleton } from '@/features/post/components/post-detail';
|
||||
|
||||
export default function PostPage({ params }: PageProps<'/post/[id]'>) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Post</h1>
|
||||
<Suspense fallback={<PostDetailSkeleton />}>
|
||||
{params.then(({ id }) => (
|
||||
<PostDetail id={id} />
|
||||
))}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `<h1>` sits **above** the `params.then()` so it paints instantly. The `Suspense` fallback covers only the dynamic section.
|
||||
|
||||
Resolve route props to plain values at this boundary. Feature components receive `id`, `slug`, `query`, or parsed filter values — not `params`, `searchParams`, or unresolved server promises.
|
||||
|
||||
### Implicit return inside `.then()`
|
||||
|
||||
Use an implicit-return arrow function when the callback just renders JSX — e.g. `({ id }) => <PostDetail id={id} />`. Only switch to a block body with `return` when you need to do work first (destructure with defaults, parse a `searchParams` value, branch on a condition). This keeps the JSX-in-page shape readable and matches how the resolved tree will look.
|
||||
|
||||
### `searchParams` and combined params
|
||||
|
||||
```tsx
|
||||
// searchParams only
|
||||
export default function SearchPage({ searchParams }: PageProps<'/search'>) {
|
||||
return searchParams.then(sp => {
|
||||
const q = typeof sp.q === 'string' ? sp.q : '';
|
||||
return q ? <SearchResults query={q} /> : <EmptyState />;
|
||||
});
|
||||
}
|
||||
|
||||
// Both params and searchParams
|
||||
export default function ProfilePage({ params, searchParams }: PageProps<'/u/[handle]'>) {
|
||||
return Promise.all([params, searchParams]).then(([{ handle }, sp]) => (
|
||||
<ProfileFeed handle={handle} tab={parseTab(sp.tab)} />
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
### Metadata, static params, and `notFound()`
|
||||
|
||||
- [`generateMetadata`](https://preview.nextjs.org/docs/app/api-reference/functions/generate-metadata) runs before render, so `await params` is fine there — it's a separate async function, not the page body, so it doesn't make the page dynamic.
|
||||
- Export [`generateStaticParams`](https://preview.nextjs.org/docs/app/api-reference/functions/generate-static-params) from a `[slug]` page/layout to pre-build a known set of slugs; with `cacheComponents` + `'use cache'` they land in the static shell. It does **not** change the page signature — `params` is still a Promise, still consumed with `params.then()`.
|
||||
- A query that can't find its resource calls [`notFound()`](https://preview.nextjs.org/docs/app/api-reference/functions/not-found), which bubbles to the nearest [`not-found.tsx`](https://preview.nextjs.org/docs/app/api-reference/file-conventions/not-found). Don't try/catch it — use [`unstable_rethrow`](https://preview.nextjs.org/docs/app/api-reference/functions/unstable_rethrow) if you must catch nearby.
|
||||
|
||||
## The page owns the Suspense boundary
|
||||
|
||||
The feature exports the async component **and** its skeleton. The page imports both and places the boundary. Don't pre-wrap inside the feature — that hides the boundary and prevents grouping siblings.
|
||||
|
||||
```tsx
|
||||
// features/post/components/post-detail.tsx
|
||||
export async function PostDetail({ id }: { id: string }) { ... }
|
||||
export function PostDetailSkeleton() { ... }
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/post/[id]/page.tsx
|
||||
<Suspense fallback={<PostDetailSkeleton />}>
|
||||
{params.then(({ id }) => (
|
||||
<>
|
||||
<PostDetail id={id} />
|
||||
<ErrorBoundary title="Replies didn't load">
|
||||
<Suspense fallback={<RepliesSkeleton />}>
|
||||
<Replies postId={id} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
))}
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
If a page uses a transition wrapper (e.g. `<ViewTransition>`), place it in the page next to the `<Suspense>` boundary. Feature components render content and skeletons, not transition wrappers.
|
||||
|
||||
## Audit smells
|
||||
|
||||
When auditing an existing app, flag and fix these first:
|
||||
|
||||
- `export default async function Page(...)` that only awaits `params`, `searchParams`, or page-level queries.
|
||||
- `import { getSomething } from '@/features/.../*-queries'` inside `app/**/page.tsx` or `layout.tsx`.
|
||||
- Feature components whose props are `params`, `searchParams`, or a route-shaped object.
|
||||
- Page-local components like `HomeContent`, `PostShell`, or `ResultsSection` that only exist to fetch data or group a Suspense fallback.
|
||||
- `<Suspense>` inside feature components that prevents the page from grouping reveal behavior.
|
||||
|
||||
## Don't create page-local wrapper components
|
||||
|
||||
Avoid components whose only job is to group boundary content, like `HomeLists` or `HomeListsSkeleton`. Keep the resolved JSX and fallback JSX **inline in the page** so the loading shape, headings, and grouped reveal behavior are visible at the boundary.
|
||||
|
||||
```tsx
|
||||
// Wrong — hides the structure behind a wrapper
|
||||
<Suspense fallback={<HomeListsSkeleton />}>
|
||||
<HomeLists searchParams={searchParams} />
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Right — structure visible at the page level
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<FeaturedSkeleton />
|
||||
<RecentSkeleton />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{searchParams.then(sp => (
|
||||
<>
|
||||
<Featured filter={sp.filter} />
|
||||
<Recent filter={sp.filter} />
|
||||
</>
|
||||
))}
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
The same applies to feature-level skeleton aliases. If a variant only passes props to a base skeleton, import the base skeleton and pass those props inline in `fallback={...}`.
|
||||
|
||||
## Suspense boundary placement rules
|
||||
|
||||
1. **First section gets its own Suspense** with a known-height skeleton fallback.
|
||||
2. **Section headings stay outside Suspense** when their final position is stable.
|
||||
3. **Variable-height sections: group everything below them** in the same Suspense, including any headings that would otherwise paint in the wrong vertical position.
|
||||
4. **Fixed-height sections: own boundary is safe.**
|
||||
5. **Variable-length lists: show 2–5 skeleton items**, not the real count.
|
||||
6. **Inner Suspense content stays out of the outer skeleton.** Each boundary owns its own.
|
||||
7. **Never `fallback={null}` for visible UI.** If a boundary covers UI, give it a real shaped fallback, or group it with a sibling boundary that already has the correct fallback.
|
||||
8. **If the top section's final height is unknown, group the following sections** in the same boundary so they reveal together and don't jump underneath.
|
||||
|
||||
## Error boundaries
|
||||
|
||||
Wrap fallible sections in a Next.js-aware error boundary so one failure doesn't take down the page. Build it on [`catchError`](https://preview.nextjs.org/docs/app/api-reference/functions/catchError) from `next/error` (its `ErrorInfo` gives you a `retry()` that re-fetches server data) — it understands Next's control-flow throws (`notFound()`, `redirect()`, `unauthorized()`, `forbidden()`) and won't swallow them. Place the boundary around the suspending section, in the page:
|
||||
|
||||
```tsx
|
||||
<ErrorBoundary title="Replies didn't load">
|
||||
<Suspense fallback={<RepliesSkeleton />}>
|
||||
<Replies postId={id} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
```
|
||||
|
||||
Why not plain `react-error-boundary`? It catches Next's framework throws (so `notFound()` never reaches `not-found.tsx`), and its reset doesn't re-fetch server data. Background: [Error Handling in Next.js with catchError](https://aurorascharff.no/posts/error-handling-in-nextjs-with-catch-error/).
|
||||
|
||||
Pair component-level boundaries with route-segment [`error.tsx`](https://preview.nextjs.org/docs/app/api-reference/file-conventions/error) for unrecoverable errors; it also receives a `retry` callback.
|
||||
|
||||
## Layout-level Suspense
|
||||
|
||||
Layouts compose feature components the same way pages do. Use `<Suspense>` for slots that fetch data (auth badge, sidebar):
|
||||
|
||||
```tsx
|
||||
export default function RootLayout({ children }: LayoutProps<'/'>) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<Suspense>
|
||||
<AuthGate userPromise={getCurrentUser()} />
|
||||
</Suspense>
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`AuthGate` is a client component that resolves the promise with `use()` so the dialog can render conditionally without server-side branching.
|
||||
|
||||
## CLS prevention
|
||||
|
||||
Layout shift happens when:
|
||||
|
||||
- A skeleton is shorter than the real content
|
||||
- A heading sits inside a Suspense boundary whose final height is unknown — it paints in the wrong place, then jumps
|
||||
- A variable-length list streams in without a fallback that reserves space
|
||||
|
||||
Fixes:
|
||||
|
||||
- Match skeleton height to the typical real content height.
|
||||
- Move headings **outside** boundaries when their position depends on data above them.
|
||||
- For unknown-height top sections, group everything below in one boundary so siblings stream together.
|
||||
|
||||
To audit CLS, use React DevTools' Suspense panel to pin each boundary in its loading state and check vertical positions.
|
||||
|
||||
## Runtime prefetch for high-value routes
|
||||
|
||||
With `cacheComponents` + [`partialPrefetching`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/partialPrefetching) enabled, a visible `<Link>` prefetches the destination's shared [App Shell](https://preview.nextjs.org/docs/app/glossary#app-shell) — enough to commit navigation instantly, with link-specific content streaming after. The default (`'auto'`) already does this; don't write `prefetch = 'auto'`.
|
||||
|
||||
Use `<Link prefetch={true}>` on high-value links to also resolve the destination's per-link runtime data (`params`, `searchParams`, the full URL) before the click. Each such link can wake the server for a runtime prerender, so reserve it for routes users predictably visit next. See [runtime prefetching](https://preview.nextjs.org/docs/app/guides/runtime-prefetching).
|
||||
|
||||
Can't enable `partialPrefetching` app-wide yet? Opt in per route with `export const prefetch = 'partial'` on the destination, then drop the per-route exports once the global flag is on — see [Adopting Partial Prefetching](https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching) for the incremental path and [prefetch config](https://preview.nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/prefetch) for the options. To validate navigation feels instant, see the [`instant` config](https://preview.nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/instant) and [Instant Navigation guide](https://preview.nextjs.org/docs/app/guides/instant-navigation).
|
||||
|
||||
## Never wrap the entire page in a Suspense fallback
|
||||
|
||||
Page chrome (header, nav, surrounding layout) should paint instantly. Only data-dependent sections suspend. If you find yourself wrapping `<div>` and everything in it with `<Suspense fallback={<FullPageSkeleton />}>`, restructure: pull static elements out, narrow the boundary to just the dynamic part.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Queries and actions
|
||||
|
||||
The data layer. Every feature has both: queries to read, actions to write.
|
||||
|
||||
This page covers the universal data layer that applies to every Next.js App Router app. When `cacheComponents: true` is enabled, follow `references/cache-components.md`: reusable reads are cached/tagged/lifetimed, and mutations update matching tags.
|
||||
|
||||
## Cache identities
|
||||
|
||||
When a server read also seeds a browser data cache, follow `references/single-page-applications.md` for the feature-local cache contract and client-library placement.
|
||||
|
||||
## Queries
|
||||
|
||||
Create `features/<domain>/<domain>-queries.ts`. Mark it `import 'server-only'` — that's the invariant. Default to plain async exports.
|
||||
|
||||
```ts
|
||||
import 'server-only';
|
||||
|
||||
export async function getFeed(userId: string) {
|
||||
return db.post.findMany({ where: { userId } });
|
||||
}
|
||||
```
|
||||
|
||||
Use [`cache()`](https://react.dev/reference/react/cache) from React only for **request-level deduplication** when the same dynamic query is called multiple times with the same arguments in one render. Highest-value cases: a session/user lookup used by many queries, or a shared expensive read used by metadata + page sections. Don't wrap every query "just in case" — it adds indirection and can hide when data is intentionally dynamic.
|
||||
|
||||
`cache()` dedups within a request; `'use cache'` + `cacheTag` (Cache Components) shares results *across* requests. Don't add React `cache()` to a function only because it already uses `'use cache'`; that is double-caching unless you have a separate, proven same-request duplication problem. See `references/cache-components.md`.
|
||||
|
||||
## Actions
|
||||
|
||||
Create `features/<domain>/<domain>-actions.ts`. Mark with `'use server'` at the top. Always:
|
||||
|
||||
1. Verify auth.
|
||||
2. Validate input with your schema validator.
|
||||
3. Run the mutation.
|
||||
4. Invalidate cached data so the next render sees the new state.
|
||||
5. Return a result (`{ ok }` or `{ error }`).
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import { refresh } from 'next/cache';
|
||||
|
||||
export async function createPost(formData: FormData) {
|
||||
const user = await verifyUser();
|
||||
const parsed = schema.safeParse({ body: formData.get('body') });
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues[0].message };
|
||||
}
|
||||
|
||||
await db.post.create({ data: { body: parsed.data.body, userId: user.id } });
|
||||
refresh();
|
||||
return { ok: true as const };
|
||||
}
|
||||
```
|
||||
|
||||
[`refresh()`](https://preview.nextjs.org/docs/app/api-reference/functions/refresh) re-renders the current route for the current user. Use it when the affected read is deliberately dynamic and has no tag. With Cache Components enabled, reusable reads should have matching `cacheTag()` calls, so server actions normally call `updateTag()` for read-your-own-writes. See `references/cache-components.md`.
|
||||
|
||||
### Action file naming
|
||||
|
||||
Actions for a feature always go in `<folder>-actions.ts`, matching the folder name — even when the mutation operates on a sub-concept. `toggleFavorite` in `features/event/` lives in `event-actions.ts`, not `favorite-actions.ts`. The folder is the source of truth for the name.
|
||||
|
||||
## Calling actions from client components
|
||||
|
||||
Client components import server actions directly. **Don't** pass an action as a prop just to call it:
|
||||
|
||||
```tsx
|
||||
// Right
|
||||
'use client';
|
||||
import { likePost } from '@/features/post/post-actions';
|
||||
|
||||
export function LikeButton({ postId }: { postId: string }) {
|
||||
return <button onClick={() => likePost(postId)}>Like</button>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Wrong — adds indirection with no benefit
|
||||
async function Post({ id }: { id: string }) {
|
||||
return <LikeButton postId={id} onLike={likePost} />;
|
||||
}
|
||||
```
|
||||
|
||||
Design components (`<BottomNav>`, `<ToggleGroup>`, `<SubmitButton>`) take this further with the **action-prop pattern** — `action` is a callback wrapped in `useTransition` / `useOptimistic` internally. See `references/ux-patterns.md`.
|
||||
|
||||
## Form actions vs onClick handlers
|
||||
|
||||
Prefer [`<form action={serverAction}>`](https://react.dev/reference/react-dom/components/form#action) for form mutations — React wraps the call in a transition and surfaces pending state automatically.
|
||||
|
||||
For one-off buttons, `onClick={() => action(args)}` is fine. Wrap in [`startTransition`](https://react.dev/reference/react/startTransition) if you need pending state.
|
||||
|
||||
## Return shape
|
||||
|
||||
Return a discriminated union from actions that can fail:
|
||||
|
||||
```tsx
|
||||
export type ActionResult<T = void> = { ok: true; data?: T } | { ok: false; error: string };
|
||||
```
|
||||
|
||||
Toast on `ok: false` from the client. Skip success toasts when an optimistic UI already shows the result.
|
||||
|
||||
A shared `ActionResult<T>` is optional — a per-action inline union is just as good, and often clearer when the payload has a natural name: `return { ok: true as const, playlist }` reads better than a generic `data`. What matters is that fallible actions return a discriminated union the client can narrow on, not that every action shares one type.
|
||||
|
||||
## Mappers and domain types
|
||||
|
||||
If your DB rows have shapes you don't want to leak to components (extra columns, ORM-specific types), write a mapper inside the query:
|
||||
|
||||
```ts
|
||||
export async function getPost(id: string) {
|
||||
const row = await db.post.findUnique({ where: { id }, include: { author: true } });
|
||||
if (!row) notFound();
|
||||
return toPost(row);
|
||||
}
|
||||
|
||||
function toPost(row: PostRow & { author: UserRow }): Post {
|
||||
return { id: row.id, body: row.body, author: row.author.handle };
|
||||
}
|
||||
```
|
||||
|
||||
Components see `Post`, not the ORM row. If that type is imported by multiple files in the feature, put it under `features/<domain>/types/` (for example `features/post/types/post.ts`). Promote it to top-level `types/` only when multiple features import it.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Single-page application patterns
|
||||
|
||||
Use this reference when a feature adds SWR, TanStack Query, or another browser data cache. For complete library APIs and runnable examples, follow the [Single-page applications guide](https://preview.nextjs.org/docs/app/guides/single-page-applications).
|
||||
|
||||
## Decide whether a client cache is needed
|
||||
|
||||
Use a client data library when the browser needs revalidation, optimistic mutations, request deduplication, or shared live data. If a Client Component only reads server data once, pass a Promise from its Server Component and unwrap it with `use()` instead.
|
||||
|
||||
## Keep ownership with the feature
|
||||
|
||||
```text
|
||||
features/<domain>/
|
||||
<domain>-cache.ts # Pure server tags + client keys
|
||||
<domain>-queries.ts # Server reads and cacheLife
|
||||
<domain>-query-options.ts # Client fetcher/query options
|
||||
hooks/use-*.ts # Client mutations and coordination
|
||||
components/ # Async server owner + client leaves
|
||||
```
|
||||
|
||||
The cache contract imports neither Next.js nor the client library. Queries, actions, route handlers, hydration code, query options, and hooks import identities from it. This prevents a key or tag spelling from drifting between a read and its invalidation.
|
||||
|
||||
Keep behavior in the layer that owns it:
|
||||
|
||||
- Server `cacheLife`, `cacheTag`, and database reads belong in `<domain>-queries.ts`.
|
||||
- Browser freshness and refetch behavior belong in `<domain>-query-options.ts` or the SWR hook.
|
||||
- Optimistic mutation behavior belongs in `hooks/use-*.ts`.
|
||||
- Tiny effect-only or interactive leaves belong in `components/`.
|
||||
|
||||
## Seed from the server
|
||||
|
||||
The async feature component owns the initial read and the library's hydration provider. The page remains a synchronous composition surface and owns the feature's Suspense boundary.
|
||||
|
||||
- With SWR, seed the exact key read by `useSWR`. Use `preload` with `cacheData` when later `mutate(key)` calls must update the seeded entry itself.
|
||||
- With TanStack Query, seed the same query key read by the client query and render the client subtree inside `HydrationBoundary`.
|
||||
|
||||
Do not move the initial read to the browser just because the feature also has a client cache.
|
||||
|
||||
## Coordinate Cache Components
|
||||
|
||||
The server cache and browser cache have independent freshness policies. Do not mirror `cacheLife` into `staleTime`, polling intervals, or SWR revalidation settings. Coordinate identities and invalidation, not durations.
|
||||
|
||||
For tag-driven data, a mutation updates the client cache for immediate feedback and invalidates the same server tag used by the seeded read. For a time-driven server read, choose its `cacheLife` from the server data's freshness requirement.
|
||||
|
||||
TanStack Query hydration adds one coupling: the hydration timestamp must advance whenever the seeded data advances. For tag-driven reads, cache the timestamp with the same tags as the data. For time-driven reads, derive the data and timestamp from the same cached snapshot. Do not cache a `QueryClient` or dehydrated payload.
|
||||
|
||||
## Mutate without drift
|
||||
|
||||
Let the client library own the optimistic browser update, rollback, and authoritative response. Let the write invalidate the server tag only after stored data changes. Do not add polling as a cache-coordination mechanism; add focus revalidation, intervals, SSE, or WebSockets only when the product actually needs external updates to appear automatically.
|
||||
@@ -0,0 +1,68 @@
|
||||
# UX patterns
|
||||
|
||||
Interaction decisions on top of the architecture: which feedback mechanism to reach for, and the boundary edge-cases that trip agents up. Hook mechanics live in the React / Next docs — linked, not restated. The deeper end-to-end picture is the [Building interactive apps guide](https://preview.nextjs.org/docs/app/guides/interactive-apps).
|
||||
|
||||
## Choose the feedback mechanism
|
||||
|
||||
| Situation | Reach for | Key rule |
|
||||
| --------- | --------- | -------- |
|
||||
| Mutation unlikely to fail (favorite, vote, follow) | [`useOptimistic`](https://react.dev/reference/react/useOptimistic) | Update immediately, roll back on throw. Set it inside a transition; inside `<form action>` React opens the transition for you. Use a reducer for counters. |
|
||||
| No optimistic fit (filters, sort, navigation) | [`useTransition`](https://react.dev/reference/react/useTransition) + `data-pending` | Put `data-pending` on the pending node; let ancestors react with CSS (`has-data-pending:` for a direct parent, `group-has-data-pending:` further up) so it bubbles without prop drilling. |
|
||||
| Form field validation ("fix this field") | [`useActionState`](https://react.dev/reference/react/useActionState) | Action returns `{ error }`; render inline with `aria-invalid` + `role="alert"`. |
|
||||
| Submit disable + spinner | [`useFormStatus`](https://react.dev/reference/react-dom/hooks/useFormStatus) | Call it from a child of `<form>`, not the form component itself. |
|
||||
| One-shot result with no visible change | toast | See below. |
|
||||
|
||||
`useOptimistic(false)` also works as a transition-scoped **pending flag** that resets automatically when the transition settles — handy when you don't need the `data-pending` bubbling.
|
||||
|
||||
## Toasts
|
||||
|
||||
- **Toast only on error** when an optimistic UI already shows the result — a success toast next to an optimistic checkmark/removal is double feedback, which is noise.
|
||||
- **Toast on success** only for non-visible side effects (email sent, link copied, file uploaded).
|
||||
- **Don't toast for routine navigation** — the page change is the feedback.
|
||||
- **Don't toast inside a server action.** Toasts are client-side; return a result and toast at the call site.
|
||||
|
||||
## View transitions: portaled / floating UI
|
||||
|
||||
Portaled elements (toasts, dialogs, popovers, dropdowns, tooltips) flicker during route transitions unless excluded. Apply `viewTransitionName: 'none'` to the portal root. When the portal also needs stacking control (z-index) or has translucent layers (backdrop-blur), give it a *named* transition and neutralize it in CSS instead — `::view-transition-group(name) { animation: none; z-index: … }` can do things `'none'` can't. See the [React View Transitions skill](https://github.com/vercel-labs/agent-skills/tree/main/skills/react-view-transitions).
|
||||
|
||||
## Destructive actions (delete / leave / unsubscribe)
|
||||
|
||||
Gate behind a confirmation dialog, and mind two edge cases:
|
||||
|
||||
- **Don't `redirect()` inside the action.** It throws, which stops the client from toasting or closing the dialog. Return `{ ok: true }` and navigate with `router.push()`.
|
||||
- **Don't wrap the whole action call in `useTransition`** inside the dialog — with view transitions on, that animates the background UI behind the dialog. Track pending with `useState` / `useOptimistic(false)` and reserve `startTransition` for the post-success navigation only.
|
||||
|
||||
## The action-prop pattern
|
||||
|
||||
A reusable design component (`<ToggleGroup>`, `<BottomNav>`, `<SubmitButton>`) can take an action-style prop and own the async coordination (optimistic update, pending, dimming) so consumers pass a plain callback. Convention: an `action` / `*Action` prop signals "triggers a mutation this component coordinates," versus a plain `onChange` / `onClick` — renaming between them is a contract change. Not every such prop is transition-wrapped: a destructive `confirmAction` is awaited *without* a transition (see above). Transition-wrapping is the default for optimistic/navigation actions, not a rule tied to the name.
|
||||
|
||||
## URL-based pagination
|
||||
|
||||
Drive the page number through `searchParams`, render each page as its own `<Suspense>` boundary so pages stream independently, and add "load more" with `<Link scroll={false}>`. See [linking and navigating](https://preview.nextjs.org/docs/app/getting-started/linking-and-navigating).
|
||||
|
||||
```tsx
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export function Feed({ page = 1 }: { page?: number }) {
|
||||
return (
|
||||
<ul>
|
||||
{Array.from({ length: page }).map((_, i) => {
|
||||
const p = i + 1;
|
||||
return p === 1 ? (
|
||||
<FeedPage key={p} page={p} />
|
||||
) : (
|
||||
<Suspense key={p} fallback={<FeedPageSkeleton />}>
|
||||
<FeedPage page={p} />
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The first page can render in the parent boundary; later pages get their own fallbacks so "load more" streams only the newly requested page. If the URL update should preserve scroll, use [`<Link scroll={false}>`](https://preview.nextjs.org/docs/app/api-reference/components/link).
|
||||
|
||||
## Global client state
|
||||
|
||||
For truly global client state (audio player, cart, system-reactive theme), wrap a [context provider](https://react.dev/reference/react/createContext) at the root; the provider is `'use client'` but `children` stays server-rendered, and only leaf components read the context. **Don't push server data into it** — server data stays in queries; client state is for ephemeral UI (open menus, playback position, optimistic drafts). See the live-data decision in `references/components.md`.
|
||||
Reference in New Issue
Block a user