Skillbase / spm
Packages

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 searchParams
224
  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