skillbase/frontend-react-nextjs
React 18+ and Next.js 14+ App Router: server/client components, suspense, streaming, data fetching, routing
SKILL.md
42
You are a senior React and Next.js engineer specializing in the App Router architecture, server components, and modern React patterns.
43
44
This skill covers React 18+ and Next.js 14+ with App Router: server components by default, client components only for interactivity, Suspense streaming, server actions for mutations, and state management with zustand/jotai (client) and TanStack Query (server). The goal is to produce applications with minimal client-side JavaScript, parallel data streaming, and clear server/client boundaries. Common pitfalls this skill prevents: marking entire pages as `"use client"` instead of pushing interactivity to leaves, fetching data with `useEffect` instead of server components, missing Suspense boundaries causing waterfall loading, and unvalidated server action inputs.
49
## Server vs Client components
50
51
Server components are the default. Add `"use client"` only when the component needs:
52
- Event handlers (`onClick`, `onChange`, `onSubmit`)
53
- Browser APIs (`window`, `localStorage`, `IntersectionObserver`)
54
- React hooks that use state or effects (`useState`, `useEffect`, `useRef` with DOM)
55
- Third-party libraries that require browser context
56
57
Push `"use client"` to leaf components. Keep data-fetching and layout logic on the server:
58
59
```
60
app/dashboard/page.tsx → Server component (fetches data)
61
└─ DashboardStats.tsx → Server component (renders data)
62
└─ DashboardFilters.tsx → "use client" (interactive filters)
63
```
64
65
## Data fetching
66
67
Fetch data in server components using `async` component functions:
68
69
```typescript
70
async function UsersPage() {71
const users = await getUsers()
72
return (
73
<Suspense fallback={<UsersSkeleton />}>74
<UserList users={users} />75
</Suspense>
76
)
77
}
78
```
79
80
- Use `cache()` from React to deduplicate requests within a single render pass.
81
- Prefer server-side data access over client-side fetching.
82
- When client-side fetching is necessary, use TanStack Query — not `useEffect` + `useState`.
83
84
## Routing and layouts
85
86
App Router file conventions:
87
88
- `page.tsx` — route UI
89
- `layout.tsx` — shared layout (does not re-render on navigation)
90
- `loading.tsx` — Suspense fallback for the route segment
91
- `error.tsx` — error boundary (`"use client"` required)
92
- `not-found.tsx` — 404 UI for `notFound()` calls
93
- `route.ts` — API route handlers (GET, POST, etc.)
94
95
Use parallel routes (`@slot`) and intercepting routes (`(.)`) for modals and complex UI patterns.
96
97
## Server Actions
98
99
Use server actions for mutations tied to UI forms:
100
101
```typescript
102
"use server"
103
104
import { revalidatePath } from "next/cache"105
import { z } from "zod"106
107
const CreateUserSchema = z.object({108
name: z.string().min(1),
109
email: z.string().email(),
110
})
111
112
export async function createUser(formData: FormData) {113
const parsed = CreateUserSchema.safeParse({114
name: formData.get("name"),115
email: formData.get("email"),116
})
117
118
if (!parsed.success) {119
return { error: parsed.error.flatten().fieldErrors }120
}
121
122
await db.user.create({ data: parsed.data })123
revalidatePath("/users")124
}
125
```
126
127
- Validate all input with zod — form data is untrusted.
128
- Use `revalidatePath` / `revalidateTag` to invalidate cached data after mutations.
129
- Return structured error objects for form validation feedback.
130
131
## Component patterns
132
133
### Functional components only
134
135
```typescript
136
interface UserCardProps {137
readonly user: User
138
readonly onSelect?: (id: string) => void
139
}
140
141
function UserCard({ user, onSelect }: UserCardProps) {142
return (
143
<div onClick={() => onSelect?.(user.id)}>144
<h3>{user.name}</h3>145
<p>{user.email}</p>146
</div>
147
)
148
}
149
```
150
151
- Define props as `interface` with `readonly` fields.
152
- Prefer direct prop passing over context for 1-2 levels.
153
- Use `children` and composition over deeply nested conditional rendering.
154
155
### Minimize useEffect
156
157
| Instead of useEffect for... | Use |
158
|---|---|
159
| Fetching data | Server component async function, or TanStack Query |
160
| Computed values | `useMemo`, or derive during render |
161
| Responding to events | Event handler directly |
162
| Syncing with external store | `useSyncExternalStore` |
163
| One-time setup | Module-level code or `useRef` init pattern |
164
165
### Suspense and streaming
166
167
Wrap async server components in `<Suspense>` with skeleton fallbacks:
168
169
```typescript
170
export default function DashboardPage() {171
return (
172
<div className="grid grid-cols-2 gap-4">
173
<Suspense fallback={<StatsSkeleton />}>174
<DashboardStats />
175
</Suspense>
176
<Suspense fallback={<ChartSkeleton />}>177
<RevenueChart />
178
</Suspense>
179
</div>
180
)
181
}
182
```
183
184
Each `<Suspense>` boundary streams independently — place boundaries around components that fetch their own data.
185
186
## State management
187
188
- **Client state** (UI, form, preferences): zustand or jotai. Keep stores small and colocated.
189
- **Server state** (APIs/DB): TanStack Query for polling, optimistic updates, cache management.
190
- **URL state** (filters, pagination, search): `useSearchParams` + `useRouter`.
191
- **Form state**: `useActionState` for server action forms, react-hook-form + zod for complex client forms.
192
193
## Error handling
194
195
- Every route segment should have an `error.tsx` boundary.
196
- Use `notFound()` when a resource doesn't exist.
197
- Server actions return error objects, not thrown exceptions.
198
- Client components surface errors in UI — never silently swallow.
206
User asks: "Create a product listing page with search and pagination"
209
```typescript
210
// app/products/page.tsx — Server Component
211
import { Suspense } from "react"212
import { ProductList } from "./product-list"213
import { SearchBar } from "./search-bar"214
import { ProductsSkeleton } from "./products-skeleton"215
216
interface SearchParams { q?: string; page?: string }217
218
export default async function ProductsPage({219
searchParams,
220
}: {221
searchParams: Promise<SearchParams>
222
}) {223
const { q, page } = await searchParams224
return (
225
<div>
226
<SearchBar defaultValue={q} />227
<Suspense key={`${q}-${page}`} fallback={<ProductsSkeleton />}>228
<ProductList query={q} page={Number(page) || 1} />229
</Suspense>
230
</div>
231
)
232
}
233
```
234
235
```typescript
236
// app/products/search-bar.tsx
237
"use client"
238
239
import { useRouter, useSearchParams } from "next/navigation"240
import { useTransition } from "react"241
242
export function SearchBar({ defaultValue }: { readonly defaultValue?: string }) {243
const router = useRouter()
244
const searchParams = useSearchParams()
245
const [isPending, startTransition] = useTransition()
246
247
function handleSearch(value: string) {248
const params = new URLSearchParams(searchParams.toString())
249
if (value) params.set("q", value)250
else params.delete("q")251
params.delete("page")252
startTransition(() => {253
router.push(`/products?${params.toString()}`)254
})
255
}
256
257
return (
258
<input
259
type="search"
260
defaultValue={defaultValue}261
onChange={(e) => handleSearch(e.target.value)}262
placeholder="Search products..."
263
aria-label="Search products"
264
data-pending={isPending || undefined}265
/>
266
)
267
}
268
```
269
270
```typescript
271
// app/products/product-list.tsx — Server Component
272
import { getProducts } from "@/lib/api"273
import { Pagination } from "./pagination"274
275
interface ProductListProps {276
readonly query?: string
277
readonly page: number
278
}
279
280
export async function ProductList({ query, page }: ProductListProps) {281
const { products, totalPages } = await getProducts({ query, page })282
283
if (products.length === 0) {284
return <p>No products found.</p>
285
}
286
287
return (
288
<>
289
<ul>
290
{products.map((product) => (291
<li key={product.id}>{product.name} — ${product.price}</li>292
))}
293
</ul>
294
<Pagination currentPage={page} totalPages={totalPages} />295
</>
296
)
297
}
298
```
304
User asks: "How do I handle loading and error states in Next.js App Router?"
306
[File-based conventions wired into React Suspense and error boundaries: `loading.tsx` (renders instantly during navigation as Suspense fallback), `error.tsx` (`"use client"` required, receives `error` and `reset` props, includes retry button), `not-found.tsx` (renders when `notFound()` is called). Each route segment can have its own set of these files for granular control.]
310
- Default to server components — add `"use client"` only at the interactive leaf — keeps JavaScript bundle small and data fetching on the server
311
- Fetch data in server components with direct DB/API calls — avoids client-server waterfalls and eliminates loading spinners for initial render
312
- Use Suspense boundaries around independent data-fetching components — enables parallel streaming instead of sequential loading
313
- Pass a unique `key` to Suspense when its content depends on dynamic input — forces React to remount and show the fallback for new data
314
- Use server actions for form mutations; revalidate with `revalidatePath`/`revalidateTag` — keeps mutation logic server-side and automatically updates cached data
315
- Validate all inputs in server actions with zod — server actions are public HTTP endpoints, unvalidated input is a security risk
316
- Use URL search params as source of truth for filters, pagination, search — enables shareable URLs, browser back/forward, and SSR of the correct state
317
- Co-locate components, actions, and types with their route segment — makes dependencies obvious and simplifies code navigation