autoworkflow 3.10.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,2730 @@
|
|
|
1
|
+
# Next.js Master Skill
|
|
2
|
+
|
|
3
|
+
> Comprehensive Next.js 14/15 expertise for production-grade applications.
|
|
4
|
+
> This skill covers App Router, Server Components, Server Actions, and advanced patterns.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Table of Contents
|
|
9
|
+
|
|
10
|
+
1. [Architecture Fundamentals](#architecture-fundamentals)
|
|
11
|
+
2. [Server vs Client Components](#server-vs-client-components)
|
|
12
|
+
3. [Data Fetching Patterns](#data-fetching-patterns)
|
|
13
|
+
4. [Caching Strategies](#caching-strategies)
|
|
14
|
+
5. [Server Actions](#server-actions)
|
|
15
|
+
6. [Route Handlers (API)](#route-handlers-api)
|
|
16
|
+
7. [Middleware](#middleware)
|
|
17
|
+
8. [Streaming & Suspense](#streaming--suspense)
|
|
18
|
+
9. [Error Handling](#error-handling)
|
|
19
|
+
10. [Metadata & SEO](#metadata--seo)
|
|
20
|
+
11. [Authentication Patterns](#authentication-patterns)
|
|
21
|
+
12. [Database Integration](#database-integration)
|
|
22
|
+
13. [Performance Optimization](#performance-optimization)
|
|
23
|
+
14. [Security Best Practices](#security-best-practices)
|
|
24
|
+
15. [Testing Strategies](#testing-strategies)
|
|
25
|
+
16. [Deployment](#deployment)
|
|
26
|
+
17. [Anti-Patterns to Avoid](#anti-patterns-to-avoid)
|
|
27
|
+
18. [File Structure Conventions](#file-structure-conventions)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Architecture Fundamentals
|
|
32
|
+
|
|
33
|
+
### App Router Structure (Next.js 13+)
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
app/
|
|
37
|
+
├── layout.tsx # Root layout (required)
|
|
38
|
+
├── page.tsx # Home page (/)
|
|
39
|
+
├── loading.tsx # Loading UI
|
|
40
|
+
├── error.tsx # Error boundary
|
|
41
|
+
├── not-found.tsx # 404 page
|
|
42
|
+
├── global-error.tsx # Root error boundary
|
|
43
|
+
├── template.tsx # Re-renders on navigation
|
|
44
|
+
├── default.tsx # Parallel route fallback
|
|
45
|
+
│
|
|
46
|
+
├── (marketing)/ # Route group (no URL impact)
|
|
47
|
+
│ ├── layout.tsx
|
|
48
|
+
│ ├── about/page.tsx
|
|
49
|
+
│ └── contact/page.tsx
|
|
50
|
+
│
|
|
51
|
+
├── dashboard/
|
|
52
|
+
│ ├── layout.tsx
|
|
53
|
+
│ ├── page.tsx
|
|
54
|
+
│ ├── @analytics/ # Parallel route (named slot)
|
|
55
|
+
│ │ └── page.tsx
|
|
56
|
+
│ ├── @team/ # Another parallel route
|
|
57
|
+
│ │ └── page.tsx
|
|
58
|
+
│ └── settings/
|
|
59
|
+
│ └── page.tsx
|
|
60
|
+
│
|
|
61
|
+
├── blog/
|
|
62
|
+
│ ├── page.tsx # /blog
|
|
63
|
+
│ ├── [slug]/ # Dynamic segment
|
|
64
|
+
│ │ └── page.tsx # /blog/my-post
|
|
65
|
+
│ └── [...catchAll]/ # Catch-all segment
|
|
66
|
+
│ └── page.tsx # /blog/2024/01/post
|
|
67
|
+
│
|
|
68
|
+
├── api/ # Route Handlers
|
|
69
|
+
│ └── webhook/
|
|
70
|
+
│ └── route.ts
|
|
71
|
+
│
|
|
72
|
+
└── _components/ # Private folder (not routed)
|
|
73
|
+
└── shared.tsx
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Route Segment Config
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
// Any page.tsx, layout.tsx, or route.ts
|
|
80
|
+
export const dynamic = 'auto' | 'force-dynamic' | 'error' | 'force-static'
|
|
81
|
+
export const dynamicParams = true | false
|
|
82
|
+
export const revalidate = false | 0 | number
|
|
83
|
+
export const fetchCache = 'auto' | 'default-cache' | 'only-cache' | 'force-cache' | 'force-no-store' | 'default-no-store' | 'only-no-store'
|
|
84
|
+
export const runtime = 'nodejs' | 'edge'
|
|
85
|
+
export const preferredRegion = 'auto' | 'global' | 'home' | string | string[]
|
|
86
|
+
export const maxDuration = number
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Server vs Client Components
|
|
92
|
+
|
|
93
|
+
### Decision Matrix
|
|
94
|
+
|
|
95
|
+
| Feature | Server Component | Client Component |
|
|
96
|
+
|---------|------------------|------------------|
|
|
97
|
+
| Fetch data | ✅ Direct DB/API access | ❌ Needs API route |
|
|
98
|
+
| Access backend resources | ✅ Filesystem, DB | ❌ No |
|
|
99
|
+
| Keep secrets secure | ✅ Never sent to client | ❌ Exposed |
|
|
100
|
+
| Reduce bundle size | ✅ Zero JS | ❌ Adds to bundle |
|
|
101
|
+
| useState/useEffect | ❌ No | ✅ Yes |
|
|
102
|
+
| Event handlers (onClick) | ❌ No | ✅ Yes |
|
|
103
|
+
| Browser APIs | ❌ No | ✅ Yes |
|
|
104
|
+
| Custom hooks with state | ❌ No | ✅ Yes |
|
|
105
|
+
| React Context | ❌ No | ✅ Yes |
|
|
106
|
+
|
|
107
|
+
### Server Component (Default)
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// app/dashboard/page.tsx
|
|
111
|
+
// NO "use client" directive = Server Component
|
|
112
|
+
|
|
113
|
+
import { db } from '@/lib/db'
|
|
114
|
+
import { headers, cookies } from 'next/headers'
|
|
115
|
+
|
|
116
|
+
export default async function DashboardPage() {
|
|
117
|
+
// Direct database access - no API needed
|
|
118
|
+
const user = await db.user.findUnique({
|
|
119
|
+
where: { id: cookies().get('userId')?.value }
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Access request headers
|
|
123
|
+
const headersList = headers()
|
|
124
|
+
const userAgent = headersList.get('user-agent')
|
|
125
|
+
|
|
126
|
+
// Environment variables (including secrets)
|
|
127
|
+
const apiKey = process.env.SECRET_API_KEY // Safe - never sent to client
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div>
|
|
131
|
+
<h1>Welcome, {user?.name}</h1>
|
|
132
|
+
<ClientInteractiveSection initialData={user} />
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Client Component
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
'use client'
|
|
142
|
+
|
|
143
|
+
// MUST be first line (before imports)
|
|
144
|
+
// This directive marks the boundary - all imports become client components
|
|
145
|
+
|
|
146
|
+
import { useState, useEffect, useTransition } from 'react'
|
|
147
|
+
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
|
148
|
+
|
|
149
|
+
interface Props {
|
|
150
|
+
initialData: User
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function ClientInteractiveSection({ initialData }: Props) {
|
|
154
|
+
const [data, setData] = useState(initialData)
|
|
155
|
+
const [isPending, startTransition] = useTransition()
|
|
156
|
+
const router = useRouter()
|
|
157
|
+
const pathname = usePathname()
|
|
158
|
+
const searchParams = useSearchParams()
|
|
159
|
+
|
|
160
|
+
// Browser APIs available
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const handleResize = () => console.log(window.innerWidth)
|
|
163
|
+
window.addEventListener('resize', handleResize)
|
|
164
|
+
return () => window.removeEventListener('resize', handleResize)
|
|
165
|
+
}, [])
|
|
166
|
+
|
|
167
|
+
const handleClick = () => {
|
|
168
|
+
startTransition(() => {
|
|
169
|
+
router.refresh() // Revalidate server components
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<button onClick={handleClick} disabled={isPending}>
|
|
175
|
+
{isPending ? 'Refreshing...' : 'Refresh Data'}
|
|
176
|
+
</button>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Composition Pattern (Server → Client)
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
// app/dashboard/page.tsx (Server Component)
|
|
185
|
+
import { ClientTabs } from './client-tabs'
|
|
186
|
+
import { db } from '@/lib/db'
|
|
187
|
+
|
|
188
|
+
export default async function Page() {
|
|
189
|
+
const data = await db.analytics.findMany()
|
|
190
|
+
|
|
191
|
+
// Pass Server Component as child to Client Component
|
|
192
|
+
return (
|
|
193
|
+
<ClientTabs>
|
|
194
|
+
<ServerRenderedChart data={data} />
|
|
195
|
+
</ClientTabs>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// client-tabs.tsx
|
|
200
|
+
'use client'
|
|
201
|
+
import { useState, ReactNode } from 'react'
|
|
202
|
+
|
|
203
|
+
export function ClientTabs({ children }: { children: ReactNode }) {
|
|
204
|
+
const [activeTab, setActiveTab] = useState(0)
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div>
|
|
208
|
+
<button onClick={() => setActiveTab(0)}>Tab 1</button>
|
|
209
|
+
<button onClick={() => setActiveTab(1)}>Tab 2</button>
|
|
210
|
+
{activeTab === 0 && children} {/* Server component rendered here */}
|
|
211
|
+
</div>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Provider Pattern
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
// app/providers.tsx
|
|
220
|
+
'use client'
|
|
221
|
+
|
|
222
|
+
import { ThemeProvider } from 'next-themes'
|
|
223
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
224
|
+
import { SessionProvider } from 'next-auth/react'
|
|
225
|
+
import { useState } from 'react'
|
|
226
|
+
|
|
227
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
228
|
+
// Create QueryClient inside component to avoid sharing between requests
|
|
229
|
+
const [queryClient] = useState(() => new QueryClient({
|
|
230
|
+
defaultOptions: {
|
|
231
|
+
queries: {
|
|
232
|
+
staleTime: 60 * 1000,
|
|
233
|
+
refetchOnWindowFocus: false,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
}))
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<SessionProvider>
|
|
240
|
+
<QueryClientProvider client={queryClient}>
|
|
241
|
+
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
|
242
|
+
{children}
|
|
243
|
+
</ThemeProvider>
|
|
244
|
+
</QueryClientProvider>
|
|
245
|
+
</SessionProvider>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// app/layout.tsx (Server Component)
|
|
250
|
+
import { Providers } from './providers'
|
|
251
|
+
|
|
252
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
253
|
+
return (
|
|
254
|
+
<html lang="en" suppressHydrationWarning>
|
|
255
|
+
<body>
|
|
256
|
+
<Providers>{children}</Providers>
|
|
257
|
+
</body>
|
|
258
|
+
</html>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Data Fetching Patterns
|
|
266
|
+
|
|
267
|
+
### Server Component Data Fetching (Recommended)
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
// app/posts/page.tsx
|
|
271
|
+
import { Suspense } from 'react'
|
|
272
|
+
|
|
273
|
+
// Parallel data fetching
|
|
274
|
+
async function getPosts() {
|
|
275
|
+
const res = await fetch('https://api.example.com/posts', {
|
|
276
|
+
next: { revalidate: 3600 } // Cache for 1 hour
|
|
277
|
+
})
|
|
278
|
+
if (!res.ok) throw new Error('Failed to fetch posts')
|
|
279
|
+
return res.json()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function getUser() {
|
|
283
|
+
const res = await fetch('https://api.example.com/user', {
|
|
284
|
+
cache: 'no-store' // Always fresh
|
|
285
|
+
})
|
|
286
|
+
return res.json()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export default async function PostsPage() {
|
|
290
|
+
// Parallel execution - both start immediately
|
|
291
|
+
const [posts, user] = await Promise.all([
|
|
292
|
+
getPosts(),
|
|
293
|
+
getUser()
|
|
294
|
+
])
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div>
|
|
298
|
+
<UserHeader user={user} />
|
|
299
|
+
<PostList posts={posts} />
|
|
300
|
+
|
|
301
|
+
{/* Defer non-critical data */}
|
|
302
|
+
<Suspense fallback={<CommentsSkeleton />}>
|
|
303
|
+
<Comments />
|
|
304
|
+
</Suspense>
|
|
305
|
+
</div>
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Separate component for streaming
|
|
310
|
+
async function Comments() {
|
|
311
|
+
// This can load after initial page render
|
|
312
|
+
const comments = await fetch('https://api.example.com/comments', {
|
|
313
|
+
next: { revalidate: 60 }
|
|
314
|
+
}).then(r => r.json())
|
|
315
|
+
|
|
316
|
+
return <CommentList comments={comments} />
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Fetch Deduplication
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
// Next.js automatically deduplicates fetch requests with same URL and options
|
|
324
|
+
// across the component tree during a single render
|
|
325
|
+
|
|
326
|
+
// component-a.tsx
|
|
327
|
+
async function ComponentA() {
|
|
328
|
+
// This request...
|
|
329
|
+
const data = await fetch('https://api.example.com/data')
|
|
330
|
+
return <div>{data.title}</div>
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// component-b.tsx
|
|
334
|
+
async function ComponentB() {
|
|
335
|
+
// ...is deduped with this one (same URL, only 1 request made)
|
|
336
|
+
const data = await fetch('https://api.example.com/data')
|
|
337
|
+
return <div>{data.description}</div>
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// For non-fetch requests, use React cache()
|
|
341
|
+
import { cache } from 'react'
|
|
342
|
+
import { db } from '@/lib/db'
|
|
343
|
+
|
|
344
|
+
export const getUser = cache(async (id: string) => {
|
|
345
|
+
return db.user.findUnique({ where: { id } })
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
// Now getUser(id) is deduped across components in same render
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Data Fetching with Preloading
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
// lib/data.ts
|
|
355
|
+
import { cache } from 'react'
|
|
356
|
+
|
|
357
|
+
// Wrap in cache for deduplication
|
|
358
|
+
export const getItem = cache(async (id: string) => {
|
|
359
|
+
const res = await fetch(`https://api.example.com/items/${id}`)
|
|
360
|
+
return res.json()
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
// Preload function (call early, don't await)
|
|
364
|
+
export const preloadItem = (id: string) => {
|
|
365
|
+
void getItem(id)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// page.tsx
|
|
369
|
+
import { getItem, preloadItem } from '@/lib/data'
|
|
370
|
+
|
|
371
|
+
export default async function Page({ params }: { params: { id: string } }) {
|
|
372
|
+
// Start fetching immediately
|
|
373
|
+
preloadItem(params.id)
|
|
374
|
+
|
|
375
|
+
// Do other work...
|
|
376
|
+
const otherData = await getOtherData()
|
|
377
|
+
|
|
378
|
+
// Now await - likely already cached
|
|
379
|
+
const item = await getItem(params.id)
|
|
380
|
+
|
|
381
|
+
return <ItemDisplay item={item} />
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Static Generation with Dynamic Params
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
// app/blog/[slug]/page.tsx
|
|
389
|
+
|
|
390
|
+
// Generate static paths at build time
|
|
391
|
+
export async function generateStaticParams() {
|
|
392
|
+
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
|
|
393
|
+
|
|
394
|
+
return posts.map((post: Post) => ({
|
|
395
|
+
slug: post.slug,
|
|
396
|
+
}))
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Optional: Control behavior for paths not in generateStaticParams
|
|
400
|
+
export const dynamicParams = true // true = generate on-demand, false = 404
|
|
401
|
+
|
|
402
|
+
export default async function BlogPost({
|
|
403
|
+
params
|
|
404
|
+
}: {
|
|
405
|
+
params: { slug: string }
|
|
406
|
+
}) {
|
|
407
|
+
const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
|
|
408
|
+
next: { revalidate: 3600 }
|
|
409
|
+
}).then(r => r.json())
|
|
410
|
+
|
|
411
|
+
return <article>{post.content}</article>
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Generate metadata for each page
|
|
415
|
+
export async function generateMetadata({
|
|
416
|
+
params
|
|
417
|
+
}: {
|
|
418
|
+
params: { slug: string }
|
|
419
|
+
}): Promise<Metadata> {
|
|
420
|
+
const post = await getPost(params.slug) // Deduped with page fetch
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
title: post.title,
|
|
424
|
+
description: post.excerpt,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
## Caching Strategies
|
|
432
|
+
|
|
433
|
+
### Cache Hierarchy
|
|
434
|
+
|
|
435
|
+
```
|
|
436
|
+
Request Memoization (React)
|
|
437
|
+
↓
|
|
438
|
+
Data Cache (Next.js)
|
|
439
|
+
↓
|
|
440
|
+
Full Route Cache (Next.js)
|
|
441
|
+
↓
|
|
442
|
+
Router Cache (Client-side)
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### Fetch Cache Options
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
// 1. Force cache (default for GET in production)
|
|
449
|
+
fetch(url, { cache: 'force-cache' })
|
|
450
|
+
fetch(url) // Same as above
|
|
451
|
+
|
|
452
|
+
// 2. No cache (always fresh)
|
|
453
|
+
fetch(url, { cache: 'no-store' })
|
|
454
|
+
|
|
455
|
+
// 3. Time-based revalidation
|
|
456
|
+
fetch(url, { next: { revalidate: 3600 } }) // Revalidate every hour
|
|
457
|
+
|
|
458
|
+
// 4. Tag-based revalidation
|
|
459
|
+
fetch(url, { next: { tags: ['posts', 'user-123'] } })
|
|
460
|
+
|
|
461
|
+
// Then in Server Action or Route Handler:
|
|
462
|
+
import { revalidateTag, revalidatePath } from 'next/cache'
|
|
463
|
+
|
|
464
|
+
export async function updatePost() {
|
|
465
|
+
await db.post.update(...)
|
|
466
|
+
|
|
467
|
+
revalidateTag('posts') // Revalidate all fetches with this tag
|
|
468
|
+
revalidatePath('/blog') // Revalidate specific path
|
|
469
|
+
revalidatePath('/blog', 'layout') // Revalidate layout and all pages below
|
|
470
|
+
revalidatePath('/blog/[slug]', 'page') // Revalidate dynamic pages
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### unstable_cache for Non-Fetch Functions
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
import { unstable_cache } from 'next/cache'
|
|
478
|
+
import { db } from '@/lib/db'
|
|
479
|
+
|
|
480
|
+
// Cache database queries
|
|
481
|
+
const getCachedUser = unstable_cache(
|
|
482
|
+
async (id: string) => {
|
|
483
|
+
return db.user.findUnique({ where: { id } })
|
|
484
|
+
},
|
|
485
|
+
['user-cache'], // Cache key prefix
|
|
486
|
+
{
|
|
487
|
+
tags: ['users'],
|
|
488
|
+
revalidate: 3600,
|
|
489
|
+
}
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
// Usage
|
|
493
|
+
const user = await getCachedUser('user-123')
|
|
494
|
+
|
|
495
|
+
// Revalidate
|
|
496
|
+
revalidateTag('users')
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### Route Segment Caching
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
// app/dashboard/page.tsx
|
|
503
|
+
|
|
504
|
+
// Force dynamic rendering (no cache)
|
|
505
|
+
export const dynamic = 'force-dynamic'
|
|
506
|
+
|
|
507
|
+
// OR force static (error if dynamic features used)
|
|
508
|
+
export const dynamic = 'force-static'
|
|
509
|
+
|
|
510
|
+
// Set revalidation period for entire route
|
|
511
|
+
export const revalidate = 3600 // seconds
|
|
512
|
+
|
|
513
|
+
// Disable dynamicParams (404 for unknown params)
|
|
514
|
+
export const dynamicParams = false
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
---
|
|
518
|
+
|
|
519
|
+
## Server Actions
|
|
520
|
+
|
|
521
|
+
### Basic Server Action
|
|
522
|
+
|
|
523
|
+
```typescript
|
|
524
|
+
// app/actions.ts
|
|
525
|
+
'use server'
|
|
526
|
+
|
|
527
|
+
import { revalidatePath } from 'next/cache'
|
|
528
|
+
import { redirect } from 'next/navigation'
|
|
529
|
+
import { z } from 'zod'
|
|
530
|
+
import { db } from '@/lib/db'
|
|
531
|
+
import { auth } from '@/lib/auth'
|
|
532
|
+
|
|
533
|
+
const CreatePostSchema = z.object({
|
|
534
|
+
title: z.string().min(1).max(100),
|
|
535
|
+
content: z.string().min(1),
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
export async function createPost(formData: FormData) {
|
|
539
|
+
// 1. Authentication
|
|
540
|
+
const session = await auth()
|
|
541
|
+
if (!session?.user) {
|
|
542
|
+
throw new Error('Unauthorized')
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 2. Validation
|
|
546
|
+
const validatedFields = CreatePostSchema.safeParse({
|
|
547
|
+
title: formData.get('title'),
|
|
548
|
+
content: formData.get('content'),
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
if (!validatedFields.success) {
|
|
552
|
+
return {
|
|
553
|
+
errors: validatedFields.error.flatten().fieldErrors,
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// 3. Database operation
|
|
558
|
+
try {
|
|
559
|
+
await db.post.create({
|
|
560
|
+
data: {
|
|
561
|
+
...validatedFields.data,
|
|
562
|
+
authorId: session.user.id,
|
|
563
|
+
},
|
|
564
|
+
})
|
|
565
|
+
} catch (error) {
|
|
566
|
+
return {
|
|
567
|
+
error: 'Failed to create post',
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 4. Revalidate and redirect
|
|
572
|
+
revalidatePath('/posts')
|
|
573
|
+
redirect('/posts')
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Server Action with useActionState (React 19)
|
|
578
|
+
|
|
579
|
+
```typescript
|
|
580
|
+
// app/posts/new/page.tsx
|
|
581
|
+
'use client'
|
|
582
|
+
|
|
583
|
+
import { useActionState } from 'react'
|
|
584
|
+
import { createPost } from '../actions'
|
|
585
|
+
|
|
586
|
+
const initialState = {
|
|
587
|
+
errors: {},
|
|
588
|
+
error: null,
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export default function NewPostForm() {
|
|
592
|
+
const [state, formAction, isPending] = useActionState(createPost, initialState)
|
|
593
|
+
|
|
594
|
+
return (
|
|
595
|
+
<form action={formAction}>
|
|
596
|
+
<div>
|
|
597
|
+
<label htmlFor="title">Title</label>
|
|
598
|
+
<input
|
|
599
|
+
id="title"
|
|
600
|
+
name="title"
|
|
601
|
+
type="text"
|
|
602
|
+
aria-describedby="title-error"
|
|
603
|
+
/>
|
|
604
|
+
{state.errors?.title && (
|
|
605
|
+
<p id="title-error" className="text-red-500">
|
|
606
|
+
{state.errors.title}
|
|
607
|
+
</p>
|
|
608
|
+
)}
|
|
609
|
+
</div>
|
|
610
|
+
|
|
611
|
+
<div>
|
|
612
|
+
<label htmlFor="content">Content</label>
|
|
613
|
+
<textarea
|
|
614
|
+
id="content"
|
|
615
|
+
name="content"
|
|
616
|
+
aria-describedby="content-error"
|
|
617
|
+
/>
|
|
618
|
+
{state.errors?.content && (
|
|
619
|
+
<p id="content-error" className="text-red-500">
|
|
620
|
+
{state.errors.content}
|
|
621
|
+
</p>
|
|
622
|
+
)}
|
|
623
|
+
</div>
|
|
624
|
+
|
|
625
|
+
{state.error && (
|
|
626
|
+
<p className="text-red-500">{state.error}</p>
|
|
627
|
+
)}
|
|
628
|
+
|
|
629
|
+
<button type="submit" disabled={isPending}>
|
|
630
|
+
{isPending ? 'Creating...' : 'Create Post'}
|
|
631
|
+
</button>
|
|
632
|
+
</form>
|
|
633
|
+
)
|
|
634
|
+
}
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
### Optimistic Updates
|
|
638
|
+
|
|
639
|
+
```typescript
|
|
640
|
+
'use client'
|
|
641
|
+
|
|
642
|
+
import { useOptimistic, useTransition } from 'react'
|
|
643
|
+
import { likePost } from './actions'
|
|
644
|
+
|
|
645
|
+
interface Post {
|
|
646
|
+
id: string
|
|
647
|
+
likes: number
|
|
648
|
+
isLiked: boolean
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export function LikeButton({ post }: { post: Post }) {
|
|
652
|
+
const [isPending, startTransition] = useTransition()
|
|
653
|
+
|
|
654
|
+
const [optimisticPost, addOptimisticLike] = useOptimistic(
|
|
655
|
+
post,
|
|
656
|
+
(currentPost, newLikes: number) => ({
|
|
657
|
+
...currentPost,
|
|
658
|
+
likes: newLikes,
|
|
659
|
+
isLiked: !currentPost.isLiked,
|
|
660
|
+
})
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
const handleLike = () => {
|
|
664
|
+
startTransition(async () => {
|
|
665
|
+
// Optimistically update UI
|
|
666
|
+
addOptimisticLike(optimisticPost.likes + (optimisticPost.isLiked ? -1 : 1))
|
|
667
|
+
|
|
668
|
+
// Actual server action
|
|
669
|
+
await likePost(post.id)
|
|
670
|
+
})
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return (
|
|
674
|
+
<button onClick={handleLike} disabled={isPending}>
|
|
675
|
+
{optimisticPost.isLiked ? '❤️' : '🤍'} {optimisticPost.likes}
|
|
676
|
+
</button>
|
|
677
|
+
)
|
|
678
|
+
}
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
### Server Action Security Patterns
|
|
682
|
+
|
|
683
|
+
```typescript
|
|
684
|
+
'use server'
|
|
685
|
+
|
|
686
|
+
import { auth } from '@/lib/auth'
|
|
687
|
+
import { ratelimit } from '@/lib/ratelimit'
|
|
688
|
+
import { z } from 'zod'
|
|
689
|
+
|
|
690
|
+
// Rate limiting
|
|
691
|
+
async function checkRateLimit(userId: string) {
|
|
692
|
+
const { success, remaining } = await ratelimit.limit(userId)
|
|
693
|
+
if (!success) {
|
|
694
|
+
throw new Error('Rate limit exceeded')
|
|
695
|
+
}
|
|
696
|
+
return remaining
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Authorization check
|
|
700
|
+
async function requireRole(allowedRoles: string[]) {
|
|
701
|
+
const session = await auth()
|
|
702
|
+
if (!session?.user) {
|
|
703
|
+
throw new Error('Unauthorized')
|
|
704
|
+
}
|
|
705
|
+
if (!allowedRoles.includes(session.user.role)) {
|
|
706
|
+
throw new Error('Forbidden')
|
|
707
|
+
}
|
|
708
|
+
return session.user
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Secure action pattern
|
|
712
|
+
export async function deletePost(postId: string) {
|
|
713
|
+
// 1. Auth
|
|
714
|
+
const user = await requireRole(['admin', 'moderator'])
|
|
715
|
+
|
|
716
|
+
// 2. Rate limit
|
|
717
|
+
await checkRateLimit(user.id)
|
|
718
|
+
|
|
719
|
+
// 3. Validate input
|
|
720
|
+
const id = z.string().uuid().parse(postId)
|
|
721
|
+
|
|
722
|
+
// 4. Authorization (ownership check)
|
|
723
|
+
const post = await db.post.findUnique({ where: { id } })
|
|
724
|
+
if (!post) throw new Error('Not found')
|
|
725
|
+
if (post.authorId !== user.id && user.role !== 'admin') {
|
|
726
|
+
throw new Error('Forbidden')
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// 5. Perform action
|
|
730
|
+
await db.post.delete({ where: { id } })
|
|
731
|
+
|
|
732
|
+
// 6. Revalidate
|
|
733
|
+
revalidatePath('/posts')
|
|
734
|
+
}
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
---
|
|
738
|
+
|
|
739
|
+
## Route Handlers (API)
|
|
740
|
+
|
|
741
|
+
### Basic Route Handler
|
|
742
|
+
|
|
743
|
+
```typescript
|
|
744
|
+
// app/api/posts/route.ts
|
|
745
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
746
|
+
import { auth } from '@/lib/auth'
|
|
747
|
+
import { db } from '@/lib/db'
|
|
748
|
+
|
|
749
|
+
// GET /api/posts
|
|
750
|
+
export async function GET(request: NextRequest) {
|
|
751
|
+
const searchParams = request.nextUrl.searchParams
|
|
752
|
+
const page = parseInt(searchParams.get('page') ?? '1')
|
|
753
|
+
const limit = parseInt(searchParams.get('limit') ?? '10')
|
|
754
|
+
|
|
755
|
+
const posts = await db.post.findMany({
|
|
756
|
+
skip: (page - 1) * limit,
|
|
757
|
+
take: limit,
|
|
758
|
+
orderBy: { createdAt: 'desc' },
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
return NextResponse.json(posts)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// POST /api/posts
|
|
765
|
+
export async function POST(request: NextRequest) {
|
|
766
|
+
const session = await auth()
|
|
767
|
+
if (!session) {
|
|
768
|
+
return NextResponse.json(
|
|
769
|
+
{ error: 'Unauthorized' },
|
|
770
|
+
{ status: 401 }
|
|
771
|
+
)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const body = await request.json()
|
|
775
|
+
|
|
776
|
+
const post = await db.post.create({
|
|
777
|
+
data: {
|
|
778
|
+
...body,
|
|
779
|
+
authorId: session.user.id,
|
|
780
|
+
},
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
return NextResponse.json(post, { status: 201 })
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Set caching behavior
|
|
787
|
+
export const dynamic = 'force-dynamic' // Disable cache
|
|
788
|
+
// OR
|
|
789
|
+
export const revalidate = 60 // Cache for 60 seconds
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
### Dynamic Route Handler
|
|
793
|
+
|
|
794
|
+
```typescript
|
|
795
|
+
// app/api/posts/[id]/route.ts
|
|
796
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
797
|
+
|
|
798
|
+
interface RouteParams {
|
|
799
|
+
params: { id: string }
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export async function GET(
|
|
803
|
+
request: NextRequest,
|
|
804
|
+
{ params }: RouteParams
|
|
805
|
+
) {
|
|
806
|
+
const post = await db.post.findUnique({
|
|
807
|
+
where: { id: params.id },
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
if (!post) {
|
|
811
|
+
return NextResponse.json(
|
|
812
|
+
{ error: 'Not found' },
|
|
813
|
+
{ status: 404 }
|
|
814
|
+
)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return NextResponse.json(post)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export async function PATCH(
|
|
821
|
+
request: NextRequest,
|
|
822
|
+
{ params }: RouteParams
|
|
823
|
+
) {
|
|
824
|
+
const body = await request.json()
|
|
825
|
+
|
|
826
|
+
const post = await db.post.update({
|
|
827
|
+
where: { id: params.id },
|
|
828
|
+
data: body,
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
return NextResponse.json(post)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export async function DELETE(
|
|
835
|
+
request: NextRequest,
|
|
836
|
+
{ params }: RouteParams
|
|
837
|
+
) {
|
|
838
|
+
await db.post.delete({
|
|
839
|
+
where: { id: params.id },
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
return new NextResponse(null, { status: 204 })
|
|
843
|
+
}
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
### Streaming Response
|
|
847
|
+
|
|
848
|
+
```typescript
|
|
849
|
+
// app/api/stream/route.ts
|
|
850
|
+
import { NextRequest } from 'next/server'
|
|
851
|
+
|
|
852
|
+
export async function GET(request: NextRequest) {
|
|
853
|
+
const encoder = new TextEncoder()
|
|
854
|
+
|
|
855
|
+
const stream = new ReadableStream({
|
|
856
|
+
async start(controller) {
|
|
857
|
+
for (let i = 0; i < 10; i++) {
|
|
858
|
+
const chunk = encoder.encode(`data: ${JSON.stringify({ count: i })}\n\n`)
|
|
859
|
+
controller.enqueue(chunk)
|
|
860
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
861
|
+
}
|
|
862
|
+
controller.close()
|
|
863
|
+
},
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
return new Response(stream, {
|
|
867
|
+
headers: {
|
|
868
|
+
'Content-Type': 'text/event-stream',
|
|
869
|
+
'Cache-Control': 'no-cache',
|
|
870
|
+
'Connection': 'keep-alive',
|
|
871
|
+
},
|
|
872
|
+
})
|
|
873
|
+
}
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
### Webhook Handler
|
|
877
|
+
|
|
878
|
+
```typescript
|
|
879
|
+
// app/api/webhook/stripe/route.ts
|
|
880
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
881
|
+
import { headers } from 'next/headers'
|
|
882
|
+
import Stripe from 'stripe'
|
|
883
|
+
|
|
884
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
|
|
885
|
+
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
|
|
886
|
+
|
|
887
|
+
export async function POST(request: NextRequest) {
|
|
888
|
+
const body = await request.text()
|
|
889
|
+
const signature = headers().get('stripe-signature')!
|
|
890
|
+
|
|
891
|
+
let event: Stripe.Event
|
|
892
|
+
|
|
893
|
+
try {
|
|
894
|
+
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
|
|
895
|
+
} catch (err) {
|
|
896
|
+
console.error('Webhook signature verification failed')
|
|
897
|
+
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
switch (event.type) {
|
|
901
|
+
case 'checkout.session.completed':
|
|
902
|
+
const session = event.data.object as Stripe.Checkout.Session
|
|
903
|
+
await handleCheckoutComplete(session)
|
|
904
|
+
break
|
|
905
|
+
case 'customer.subscription.deleted':
|
|
906
|
+
const subscription = event.data.object as Stripe.Subscription
|
|
907
|
+
await handleSubscriptionCanceled(subscription)
|
|
908
|
+
break
|
|
909
|
+
default:
|
|
910
|
+
console.log(`Unhandled event type: ${event.type}`)
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return NextResponse.json({ received: true })
|
|
914
|
+
}
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
---
|
|
918
|
+
|
|
919
|
+
## Middleware
|
|
920
|
+
|
|
921
|
+
### Basic Middleware
|
|
922
|
+
|
|
923
|
+
```typescript
|
|
924
|
+
// middleware.ts (root of project)
|
|
925
|
+
import { NextResponse } from 'next/server'
|
|
926
|
+
import type { NextRequest } from 'next/server'
|
|
927
|
+
|
|
928
|
+
export function middleware(request: NextRequest) {
|
|
929
|
+
// Get pathname
|
|
930
|
+
const pathname = request.nextUrl.pathname
|
|
931
|
+
|
|
932
|
+
// Example: Redirect old URLs
|
|
933
|
+
if (pathname.startsWith('/old-blog')) {
|
|
934
|
+
return NextResponse.redirect(
|
|
935
|
+
new URL(pathname.replace('/old-blog', '/blog'), request.url)
|
|
936
|
+
)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Example: Add custom headers
|
|
940
|
+
const response = NextResponse.next()
|
|
941
|
+
response.headers.set('x-custom-header', 'my-value')
|
|
942
|
+
|
|
943
|
+
// Example: Rewrite (internal redirect)
|
|
944
|
+
if (pathname === '/dashboard' && !request.cookies.get('auth')) {
|
|
945
|
+
return NextResponse.rewrite(new URL('/login', request.url))
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
return response
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Configure which paths middleware runs on
|
|
952
|
+
export const config = {
|
|
953
|
+
matcher: [
|
|
954
|
+
// Match all paths except static files and api
|
|
955
|
+
'/((?!_next/static|_next/image|favicon.ico|api).*)',
|
|
956
|
+
],
|
|
957
|
+
}
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
### Authentication Middleware
|
|
961
|
+
|
|
962
|
+
```typescript
|
|
963
|
+
// middleware.ts
|
|
964
|
+
import { NextResponse } from 'next/server'
|
|
965
|
+
import type { NextRequest } from 'next/server'
|
|
966
|
+
import { getToken } from 'next-auth/jwt'
|
|
967
|
+
|
|
968
|
+
const protectedRoutes = ['/dashboard', '/settings', '/profile']
|
|
969
|
+
const authRoutes = ['/login', '/register']
|
|
970
|
+
|
|
971
|
+
export async function middleware(request: NextRequest) {
|
|
972
|
+
const token = await getToken({
|
|
973
|
+
req: request,
|
|
974
|
+
secret: process.env.NEXTAUTH_SECRET,
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
const pathname = request.nextUrl.pathname
|
|
978
|
+
|
|
979
|
+
// Redirect authenticated users away from auth pages
|
|
980
|
+
if (authRoutes.some(route => pathname.startsWith(route))) {
|
|
981
|
+
if (token) {
|
|
982
|
+
return NextResponse.redirect(new URL('/dashboard', request.url))
|
|
983
|
+
}
|
|
984
|
+
return NextResponse.next()
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Protect routes
|
|
988
|
+
if (protectedRoutes.some(route => pathname.startsWith(route))) {
|
|
989
|
+
if (!token) {
|
|
990
|
+
const loginUrl = new URL('/login', request.url)
|
|
991
|
+
loginUrl.searchParams.set('callbackUrl', pathname)
|
|
992
|
+
return NextResponse.redirect(loginUrl)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Role-based access
|
|
997
|
+
if (pathname.startsWith('/admin')) {
|
|
998
|
+
if (token?.role !== 'admin') {
|
|
999
|
+
return NextResponse.redirect(new URL('/unauthorized', request.url))
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return NextResponse.next()
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
export const config = {
|
|
1007
|
+
matcher: [
|
|
1008
|
+
'/dashboard/:path*',
|
|
1009
|
+
'/settings/:path*',
|
|
1010
|
+
'/profile/:path*',
|
|
1011
|
+
'/admin/:path*',
|
|
1012
|
+
'/login',
|
|
1013
|
+
'/register',
|
|
1014
|
+
],
|
|
1015
|
+
}
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
### Geolocation & A/B Testing
|
|
1019
|
+
|
|
1020
|
+
```typescript
|
|
1021
|
+
// middleware.ts
|
|
1022
|
+
import { NextResponse } from 'next/server'
|
|
1023
|
+
import type { NextRequest } from 'next/server'
|
|
1024
|
+
|
|
1025
|
+
export function middleware(request: NextRequest) {
|
|
1026
|
+
// Geolocation (available on Vercel Edge)
|
|
1027
|
+
const country = request.geo?.country ?? 'US'
|
|
1028
|
+
const city = request.geo?.city ?? 'Unknown'
|
|
1029
|
+
|
|
1030
|
+
// A/B Testing
|
|
1031
|
+
let bucket = request.cookies.get('ab-bucket')?.value
|
|
1032
|
+
|
|
1033
|
+
if (!bucket) {
|
|
1034
|
+
bucket = Math.random() < 0.5 ? 'control' : 'variant'
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const response = NextResponse.next()
|
|
1038
|
+
|
|
1039
|
+
// Set headers for downstream use
|
|
1040
|
+
response.headers.set('x-country', country)
|
|
1041
|
+
response.headers.set('x-city', city)
|
|
1042
|
+
response.headers.set('x-ab-bucket', bucket)
|
|
1043
|
+
|
|
1044
|
+
// Set cookie for consistent bucketing
|
|
1045
|
+
response.cookies.set('ab-bucket', bucket, {
|
|
1046
|
+
maxAge: 60 * 60 * 24 * 30, // 30 days
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
return response
|
|
1050
|
+
}
|
|
1051
|
+
```
|
|
1052
|
+
|
|
1053
|
+
---
|
|
1054
|
+
|
|
1055
|
+
## Streaming & Suspense
|
|
1056
|
+
|
|
1057
|
+
### Loading UI with Suspense
|
|
1058
|
+
|
|
1059
|
+
```typescript
|
|
1060
|
+
// app/dashboard/page.tsx
|
|
1061
|
+
import { Suspense } from 'react'
|
|
1062
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
1063
|
+
|
|
1064
|
+
export default function DashboardPage() {
|
|
1065
|
+
return (
|
|
1066
|
+
<div className="grid gap-4">
|
|
1067
|
+
{/* Critical content - renders immediately */}
|
|
1068
|
+
<DashboardHeader />
|
|
1069
|
+
|
|
1070
|
+
{/* Non-critical - streams in */}
|
|
1071
|
+
<Suspense fallback={<StatsSkeleton />}>
|
|
1072
|
+
<Stats />
|
|
1073
|
+
</Suspense>
|
|
1074
|
+
|
|
1075
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1076
|
+
<Suspense fallback={<ChartSkeleton />}>
|
|
1077
|
+
<RevenueChart />
|
|
1078
|
+
</Suspense>
|
|
1079
|
+
|
|
1080
|
+
<Suspense fallback={<ChartSkeleton />}>
|
|
1081
|
+
<UserChart />
|
|
1082
|
+
</Suspense>
|
|
1083
|
+
</div>
|
|
1084
|
+
|
|
1085
|
+
<Suspense fallback={<TableSkeleton />}>
|
|
1086
|
+
<RecentOrders />
|
|
1087
|
+
</Suspense>
|
|
1088
|
+
</div>
|
|
1089
|
+
)
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Each async component streams independently
|
|
1093
|
+
async function Stats() {
|
|
1094
|
+
const stats = await getStats() // Slow API call
|
|
1095
|
+
return <StatsDisplay stats={stats} />
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
async function RevenueChart() {
|
|
1099
|
+
const data = await getRevenueData()
|
|
1100
|
+
return <Chart data={data} />
|
|
1101
|
+
}
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
### Loading.tsx (Route-level Loading)
|
|
1105
|
+
|
|
1106
|
+
```typescript
|
|
1107
|
+
// app/dashboard/loading.tsx
|
|
1108
|
+
// Automatically wraps page.tsx in Suspense
|
|
1109
|
+
|
|
1110
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
1111
|
+
|
|
1112
|
+
export default function DashboardLoading() {
|
|
1113
|
+
return (
|
|
1114
|
+
<div className="space-y-4">
|
|
1115
|
+
<Skeleton className="h-8 w-[200px]" />
|
|
1116
|
+
<div className="grid grid-cols-4 gap-4">
|
|
1117
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
1118
|
+
<Skeleton key={i} className="h-24" />
|
|
1119
|
+
))}
|
|
1120
|
+
</div>
|
|
1121
|
+
<Skeleton className="h-[400px]" />
|
|
1122
|
+
</div>
|
|
1123
|
+
)
|
|
1124
|
+
}
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
### Parallel Routes with Loading States
|
|
1128
|
+
|
|
1129
|
+
```typescript
|
|
1130
|
+
// app/dashboard/layout.tsx
|
|
1131
|
+
export default function DashboardLayout({
|
|
1132
|
+
children,
|
|
1133
|
+
analytics,
|
|
1134
|
+
team,
|
|
1135
|
+
}: {
|
|
1136
|
+
children: React.ReactNode
|
|
1137
|
+
analytics: React.ReactNode // @analytics/page.tsx
|
|
1138
|
+
team: React.ReactNode // @team/page.tsx
|
|
1139
|
+
}) {
|
|
1140
|
+
return (
|
|
1141
|
+
<div className="grid grid-cols-3 gap-4">
|
|
1142
|
+
<div className="col-span-2">{children}</div>
|
|
1143
|
+
<div className="space-y-4">
|
|
1144
|
+
{analytics}
|
|
1145
|
+
{team}
|
|
1146
|
+
</div>
|
|
1147
|
+
</div>
|
|
1148
|
+
)
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// app/dashboard/@analytics/loading.tsx
|
|
1152
|
+
export default function AnalyticsLoading() {
|
|
1153
|
+
return <Skeleton className="h-[200px]" />
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// app/dashboard/@team/loading.tsx
|
|
1157
|
+
export default function TeamLoading() {
|
|
1158
|
+
return <Skeleton className="h-[150px]" />
|
|
1159
|
+
}
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
### Streaming with AI/LLM
|
|
1163
|
+
|
|
1164
|
+
```typescript
|
|
1165
|
+
// app/api/chat/route.ts
|
|
1166
|
+
import { OpenAIStream, StreamingTextResponse } from 'ai'
|
|
1167
|
+
import OpenAI from 'openai'
|
|
1168
|
+
|
|
1169
|
+
const openai = new OpenAI()
|
|
1170
|
+
|
|
1171
|
+
export async function POST(req: Request) {
|
|
1172
|
+
const { messages } = await req.json()
|
|
1173
|
+
|
|
1174
|
+
const response = await openai.chat.completions.create({
|
|
1175
|
+
model: 'gpt-4-turbo-preview',
|
|
1176
|
+
stream: true,
|
|
1177
|
+
messages,
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
const stream = OpenAIStream(response)
|
|
1181
|
+
|
|
1182
|
+
return new StreamingTextResponse(stream)
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Client component
|
|
1186
|
+
'use client'
|
|
1187
|
+
|
|
1188
|
+
import { useChat } from 'ai/react'
|
|
1189
|
+
|
|
1190
|
+
export function Chat() {
|
|
1191
|
+
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat()
|
|
1192
|
+
|
|
1193
|
+
return (
|
|
1194
|
+
<div>
|
|
1195
|
+
{messages.map(m => (
|
|
1196
|
+
<div key={m.id}>
|
|
1197
|
+
<strong>{m.role}:</strong> {m.content}
|
|
1198
|
+
</div>
|
|
1199
|
+
))}
|
|
1200
|
+
|
|
1201
|
+
<form onSubmit={handleSubmit}>
|
|
1202
|
+
<input
|
|
1203
|
+
value={input}
|
|
1204
|
+
onChange={handleInputChange}
|
|
1205
|
+
placeholder="Say something..."
|
|
1206
|
+
disabled={isLoading}
|
|
1207
|
+
/>
|
|
1208
|
+
</form>
|
|
1209
|
+
</div>
|
|
1210
|
+
)
|
|
1211
|
+
}
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
---
|
|
1215
|
+
|
|
1216
|
+
## Error Handling
|
|
1217
|
+
|
|
1218
|
+
### Error Boundary (error.tsx)
|
|
1219
|
+
|
|
1220
|
+
```typescript
|
|
1221
|
+
// app/dashboard/error.tsx
|
|
1222
|
+
'use client' // Error boundaries must be client components
|
|
1223
|
+
|
|
1224
|
+
import { useEffect } from 'react'
|
|
1225
|
+
import { Button } from '@/components/ui/button'
|
|
1226
|
+
|
|
1227
|
+
export default function DashboardError({
|
|
1228
|
+
error,
|
|
1229
|
+
reset,
|
|
1230
|
+
}: {
|
|
1231
|
+
error: Error & { digest?: string }
|
|
1232
|
+
reset: () => void
|
|
1233
|
+
}) {
|
|
1234
|
+
useEffect(() => {
|
|
1235
|
+
// Log error to monitoring service
|
|
1236
|
+
console.error('Dashboard error:', error)
|
|
1237
|
+
|
|
1238
|
+
// Send to Sentry, etc.
|
|
1239
|
+
// Sentry.captureException(error)
|
|
1240
|
+
}, [error])
|
|
1241
|
+
|
|
1242
|
+
return (
|
|
1243
|
+
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
|
1244
|
+
<h2 className="text-2xl font-bold">Something went wrong!</h2>
|
|
1245
|
+
<p className="text-muted-foreground">
|
|
1246
|
+
{error.message || 'An unexpected error occurred'}
|
|
1247
|
+
</p>
|
|
1248
|
+
{error.digest && (
|
|
1249
|
+
<p className="text-xs text-muted-foreground">
|
|
1250
|
+
Error ID: {error.digest}
|
|
1251
|
+
</p>
|
|
1252
|
+
)}
|
|
1253
|
+
<Button onClick={reset}>Try again</Button>
|
|
1254
|
+
</div>
|
|
1255
|
+
)
|
|
1256
|
+
}
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
### Global Error Boundary
|
|
1260
|
+
|
|
1261
|
+
```typescript
|
|
1262
|
+
// app/global-error.tsx
|
|
1263
|
+
'use client'
|
|
1264
|
+
|
|
1265
|
+
export default function GlobalError({
|
|
1266
|
+
error,
|
|
1267
|
+
reset,
|
|
1268
|
+
}: {
|
|
1269
|
+
error: Error & { digest?: string }
|
|
1270
|
+
reset: () => void
|
|
1271
|
+
}) {
|
|
1272
|
+
return (
|
|
1273
|
+
// Must include html and body tags
|
|
1274
|
+
<html>
|
|
1275
|
+
<body>
|
|
1276
|
+
<div className="flex flex-col items-center justify-center min-h-screen">
|
|
1277
|
+
<h2>Something went wrong!</h2>
|
|
1278
|
+
<button onClick={reset}>Try again</button>
|
|
1279
|
+
</div>
|
|
1280
|
+
</body>
|
|
1281
|
+
</html>
|
|
1282
|
+
)
|
|
1283
|
+
}
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
### Not Found Handling
|
|
1287
|
+
|
|
1288
|
+
```typescript
|
|
1289
|
+
// app/not-found.tsx
|
|
1290
|
+
import Link from 'next/link'
|
|
1291
|
+
|
|
1292
|
+
export default function NotFound() {
|
|
1293
|
+
return (
|
|
1294
|
+
<div className="flex flex-col items-center justify-center min-h-[400px]">
|
|
1295
|
+
<h2 className="text-2xl font-bold">Page Not Found</h2>
|
|
1296
|
+
<p className="text-muted-foreground">
|
|
1297
|
+
The page you're looking for doesn't exist.
|
|
1298
|
+
</p>
|
|
1299
|
+
<Link href="/" className="mt-4 underline">
|
|
1300
|
+
Go home
|
|
1301
|
+
</Link>
|
|
1302
|
+
</div>
|
|
1303
|
+
)
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Trigger not-found programmatically
|
|
1307
|
+
import { notFound } from 'next/navigation'
|
|
1308
|
+
|
|
1309
|
+
export default async function PostPage({ params }: { params: { id: string } }) {
|
|
1310
|
+
const post = await getPost(params.id)
|
|
1311
|
+
|
|
1312
|
+
if (!post) {
|
|
1313
|
+
notFound() // Renders not-found.tsx
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
return <PostDisplay post={post} />
|
|
1317
|
+
}
|
|
1318
|
+
```
|
|
1319
|
+
|
|
1320
|
+
### Error Handling in Server Actions
|
|
1321
|
+
|
|
1322
|
+
```typescript
|
|
1323
|
+
'use server'
|
|
1324
|
+
|
|
1325
|
+
import { z } from 'zod'
|
|
1326
|
+
|
|
1327
|
+
// Type-safe error responses
|
|
1328
|
+
type ActionResult<T> =
|
|
1329
|
+
| { success: true; data: T }
|
|
1330
|
+
| { success: false; error: string; fieldErrors?: Record<string, string[]> }
|
|
1331
|
+
|
|
1332
|
+
export async function createUser(formData: FormData): Promise<ActionResult<User>> {
|
|
1333
|
+
try {
|
|
1334
|
+
const validated = UserSchema.safeParse({
|
|
1335
|
+
email: formData.get('email'),
|
|
1336
|
+
name: formData.get('name'),
|
|
1337
|
+
})
|
|
1338
|
+
|
|
1339
|
+
if (!validated.success) {
|
|
1340
|
+
return {
|
|
1341
|
+
success: false,
|
|
1342
|
+
error: 'Validation failed',
|
|
1343
|
+
fieldErrors: validated.error.flatten().fieldErrors,
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const user = await db.user.create({ data: validated.data })
|
|
1348
|
+
|
|
1349
|
+
return { success: true, data: user }
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
// Log error
|
|
1352
|
+
console.error('Create user error:', error)
|
|
1353
|
+
|
|
1354
|
+
// Return generic error (don't expose internal details)
|
|
1355
|
+
return {
|
|
1356
|
+
success: false,
|
|
1357
|
+
error: 'Failed to create user. Please try again.'
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
```
|
|
1362
|
+
|
|
1363
|
+
---
|
|
1364
|
+
|
|
1365
|
+
## Metadata & SEO
|
|
1366
|
+
|
|
1367
|
+
### Static Metadata
|
|
1368
|
+
|
|
1369
|
+
```typescript
|
|
1370
|
+
// app/layout.tsx
|
|
1371
|
+
import type { Metadata } from 'next'
|
|
1372
|
+
|
|
1373
|
+
export const metadata: Metadata = {
|
|
1374
|
+
metadataBase: new URL('https://example.com'),
|
|
1375
|
+
title: {
|
|
1376
|
+
default: 'My App',
|
|
1377
|
+
template: '%s | My App', // Pages can override: "About | My App"
|
|
1378
|
+
},
|
|
1379
|
+
description: 'The best app ever',
|
|
1380
|
+
keywords: ['Next.js', 'React', 'TypeScript'],
|
|
1381
|
+
authors: [{ name: 'John Doe', url: 'https://johndoe.com' }],
|
|
1382
|
+
creator: 'John Doe',
|
|
1383
|
+
publisher: 'My Company',
|
|
1384
|
+
|
|
1385
|
+
// Open Graph
|
|
1386
|
+
openGraph: {
|
|
1387
|
+
type: 'website',
|
|
1388
|
+
locale: 'en_US',
|
|
1389
|
+
url: 'https://example.com',
|
|
1390
|
+
siteName: 'My App',
|
|
1391
|
+
title: 'My App',
|
|
1392
|
+
description: 'The best app ever',
|
|
1393
|
+
images: [
|
|
1394
|
+
{
|
|
1395
|
+
url: '/og-image.png',
|
|
1396
|
+
width: 1200,
|
|
1397
|
+
height: 630,
|
|
1398
|
+
alt: 'My App',
|
|
1399
|
+
},
|
|
1400
|
+
],
|
|
1401
|
+
},
|
|
1402
|
+
|
|
1403
|
+
// Twitter
|
|
1404
|
+
twitter: {
|
|
1405
|
+
card: 'summary_large_image',
|
|
1406
|
+
title: 'My App',
|
|
1407
|
+
description: 'The best app ever',
|
|
1408
|
+
creator: '@johndoe',
|
|
1409
|
+
images: ['/twitter-image.png'],
|
|
1410
|
+
},
|
|
1411
|
+
|
|
1412
|
+
// Robots
|
|
1413
|
+
robots: {
|
|
1414
|
+
index: true,
|
|
1415
|
+
follow: true,
|
|
1416
|
+
googleBot: {
|
|
1417
|
+
index: true,
|
|
1418
|
+
follow: true,
|
|
1419
|
+
'max-video-preview': -1,
|
|
1420
|
+
'max-image-preview': 'large',
|
|
1421
|
+
'max-snippet': -1,
|
|
1422
|
+
},
|
|
1423
|
+
},
|
|
1424
|
+
|
|
1425
|
+
// Icons
|
|
1426
|
+
icons: {
|
|
1427
|
+
icon: '/favicon.ico',
|
|
1428
|
+
shortcut: '/favicon-16x16.png',
|
|
1429
|
+
apple: '/apple-touch-icon.png',
|
|
1430
|
+
},
|
|
1431
|
+
|
|
1432
|
+
// Manifest
|
|
1433
|
+
manifest: '/site.webmanifest',
|
|
1434
|
+
|
|
1435
|
+
// Verification
|
|
1436
|
+
verification: {
|
|
1437
|
+
google: 'google-site-verification-code',
|
|
1438
|
+
yandex: 'yandex-verification-code',
|
|
1439
|
+
},
|
|
1440
|
+
}
|
|
1441
|
+
```
|
|
1442
|
+
|
|
1443
|
+
### Dynamic Metadata
|
|
1444
|
+
|
|
1445
|
+
```typescript
|
|
1446
|
+
// app/blog/[slug]/page.tsx
|
|
1447
|
+
import type { Metadata, ResolvingMetadata } from 'next'
|
|
1448
|
+
|
|
1449
|
+
interface Props {
|
|
1450
|
+
params: { slug: string }
|
|
1451
|
+
searchParams: { [key: string]: string | string[] | undefined }
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
export async function generateMetadata(
|
|
1455
|
+
{ params, searchParams }: Props,
|
|
1456
|
+
parent: ResolvingMetadata
|
|
1457
|
+
): Promise<Metadata> {
|
|
1458
|
+
const post = await getPost(params.slug)
|
|
1459
|
+
|
|
1460
|
+
// Optionally access parent metadata
|
|
1461
|
+
const previousImages = (await parent).openGraph?.images || []
|
|
1462
|
+
|
|
1463
|
+
return {
|
|
1464
|
+
title: post.title,
|
|
1465
|
+
description: post.excerpt,
|
|
1466
|
+
openGraph: {
|
|
1467
|
+
title: post.title,
|
|
1468
|
+
description: post.excerpt,
|
|
1469
|
+
type: 'article',
|
|
1470
|
+
publishedTime: post.createdAt,
|
|
1471
|
+
authors: [post.author.name],
|
|
1472
|
+
images: [
|
|
1473
|
+
{
|
|
1474
|
+
url: post.image,
|
|
1475
|
+
width: 1200,
|
|
1476
|
+
height: 630,
|
|
1477
|
+
},
|
|
1478
|
+
...previousImages,
|
|
1479
|
+
],
|
|
1480
|
+
},
|
|
1481
|
+
twitter: {
|
|
1482
|
+
card: 'summary_large_image',
|
|
1483
|
+
title: post.title,
|
|
1484
|
+
description: post.excerpt,
|
|
1485
|
+
images: [post.image],
|
|
1486
|
+
},
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
```
|
|
1490
|
+
|
|
1491
|
+
### JSON-LD Structured Data
|
|
1492
|
+
|
|
1493
|
+
```typescript
|
|
1494
|
+
// app/blog/[slug]/page.tsx
|
|
1495
|
+
|
|
1496
|
+
export default async function BlogPost({ params }: Props) {
|
|
1497
|
+
const post = await getPost(params.slug)
|
|
1498
|
+
|
|
1499
|
+
const jsonLd = {
|
|
1500
|
+
'@context': 'https://schema.org',
|
|
1501
|
+
'@type': 'BlogPosting',
|
|
1502
|
+
headline: post.title,
|
|
1503
|
+
description: post.excerpt,
|
|
1504
|
+
image: post.image,
|
|
1505
|
+
datePublished: post.createdAt,
|
|
1506
|
+
dateModified: post.updatedAt,
|
|
1507
|
+
author: {
|
|
1508
|
+
'@type': 'Person',
|
|
1509
|
+
name: post.author.name,
|
|
1510
|
+
url: post.author.url,
|
|
1511
|
+
},
|
|
1512
|
+
publisher: {
|
|
1513
|
+
'@type': 'Organization',
|
|
1514
|
+
name: 'My Company',
|
|
1515
|
+
logo: {
|
|
1516
|
+
'@type': 'ImageObject',
|
|
1517
|
+
url: 'https://example.com/logo.png',
|
|
1518
|
+
},
|
|
1519
|
+
},
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
return (
|
|
1523
|
+
<>
|
|
1524
|
+
<script
|
|
1525
|
+
type="application/ld+json"
|
|
1526
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
1527
|
+
/>
|
|
1528
|
+
<article>{/* content */}</article>
|
|
1529
|
+
</>
|
|
1530
|
+
)
|
|
1531
|
+
}
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
### Sitemap & Robots
|
|
1535
|
+
|
|
1536
|
+
```typescript
|
|
1537
|
+
// app/sitemap.ts
|
|
1538
|
+
import { MetadataRoute } from 'next'
|
|
1539
|
+
|
|
1540
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
1541
|
+
const posts = await db.post.findMany({
|
|
1542
|
+
select: { slug: true, updatedAt: true },
|
|
1543
|
+
})
|
|
1544
|
+
|
|
1545
|
+
const postUrls = posts.map((post) => ({
|
|
1546
|
+
url: `https://example.com/blog/${post.slug}`,
|
|
1547
|
+
lastModified: post.updatedAt,
|
|
1548
|
+
changeFrequency: 'weekly' as const,
|
|
1549
|
+
priority: 0.8,
|
|
1550
|
+
}))
|
|
1551
|
+
|
|
1552
|
+
return [
|
|
1553
|
+
{
|
|
1554
|
+
url: 'https://example.com',
|
|
1555
|
+
lastModified: new Date(),
|
|
1556
|
+
changeFrequency: 'daily',
|
|
1557
|
+
priority: 1,
|
|
1558
|
+
},
|
|
1559
|
+
{
|
|
1560
|
+
url: 'https://example.com/about',
|
|
1561
|
+
lastModified: new Date(),
|
|
1562
|
+
changeFrequency: 'monthly',
|
|
1563
|
+
priority: 0.5,
|
|
1564
|
+
},
|
|
1565
|
+
...postUrls,
|
|
1566
|
+
]
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// app/robots.ts
|
|
1570
|
+
import { MetadataRoute } from 'next'
|
|
1571
|
+
|
|
1572
|
+
export default function robots(): MetadataRoute.Robots {
|
|
1573
|
+
return {
|
|
1574
|
+
rules: [
|
|
1575
|
+
{
|
|
1576
|
+
userAgent: '*',
|
|
1577
|
+
allow: '/',
|
|
1578
|
+
disallow: ['/api/', '/admin/', '/private/'],
|
|
1579
|
+
},
|
|
1580
|
+
{
|
|
1581
|
+
userAgent: 'Googlebot',
|
|
1582
|
+
allow: '/',
|
|
1583
|
+
},
|
|
1584
|
+
],
|
|
1585
|
+
sitemap: 'https://example.com/sitemap.xml',
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
```
|
|
1589
|
+
|
|
1590
|
+
---
|
|
1591
|
+
|
|
1592
|
+
## Authentication Patterns
|
|
1593
|
+
|
|
1594
|
+
### NextAuth.js v5 Setup
|
|
1595
|
+
|
|
1596
|
+
```typescript
|
|
1597
|
+
// auth.ts
|
|
1598
|
+
import NextAuth from 'next-auth'
|
|
1599
|
+
import GitHub from 'next-auth/providers/github'
|
|
1600
|
+
import Google from 'next-auth/providers/google'
|
|
1601
|
+
import Credentials from 'next-auth/providers/credentials'
|
|
1602
|
+
import { PrismaAdapter } from '@auth/prisma-adapter'
|
|
1603
|
+
import { db } from '@/lib/db'
|
|
1604
|
+
import bcrypt from 'bcryptjs'
|
|
1605
|
+
|
|
1606
|
+
export const { auth, handlers, signIn, signOut } = NextAuth({
|
|
1607
|
+
adapter: PrismaAdapter(db),
|
|
1608
|
+
session: { strategy: 'jwt' },
|
|
1609
|
+
|
|
1610
|
+
providers: [
|
|
1611
|
+
GitHub({
|
|
1612
|
+
clientId: process.env.GITHUB_ID,
|
|
1613
|
+
clientSecret: process.env.GITHUB_SECRET,
|
|
1614
|
+
}),
|
|
1615
|
+
Google({
|
|
1616
|
+
clientId: process.env.GOOGLE_ID,
|
|
1617
|
+
clientSecret: process.env.GOOGLE_SECRET,
|
|
1618
|
+
}),
|
|
1619
|
+
Credentials({
|
|
1620
|
+
credentials: {
|
|
1621
|
+
email: { label: 'Email', type: 'email' },
|
|
1622
|
+
password: { label: 'Password', type: 'password' },
|
|
1623
|
+
},
|
|
1624
|
+
async authorize(credentials) {
|
|
1625
|
+
if (!credentials?.email || !credentials?.password) {
|
|
1626
|
+
return null
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const user = await db.user.findUnique({
|
|
1630
|
+
where: { email: credentials.email as string },
|
|
1631
|
+
})
|
|
1632
|
+
|
|
1633
|
+
if (!user || !user.password) {
|
|
1634
|
+
return null
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
const isValid = await bcrypt.compare(
|
|
1638
|
+
credentials.password as string,
|
|
1639
|
+
user.password
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
if (!isValid) {
|
|
1643
|
+
return null
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
return {
|
|
1647
|
+
id: user.id,
|
|
1648
|
+
email: user.email,
|
|
1649
|
+
name: user.name,
|
|
1650
|
+
role: user.role,
|
|
1651
|
+
}
|
|
1652
|
+
},
|
|
1653
|
+
}),
|
|
1654
|
+
],
|
|
1655
|
+
|
|
1656
|
+
callbacks: {
|
|
1657
|
+
async jwt({ token, user, trigger, session }) {
|
|
1658
|
+
if (user) {
|
|
1659
|
+
token.id = user.id
|
|
1660
|
+
token.role = user.role
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Handle session update
|
|
1664
|
+
if (trigger === 'update' && session) {
|
|
1665
|
+
token.name = session.name
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
return token
|
|
1669
|
+
},
|
|
1670
|
+
|
|
1671
|
+
async session({ session, token }) {
|
|
1672
|
+
if (session.user) {
|
|
1673
|
+
session.user.id = token.id as string
|
|
1674
|
+
session.user.role = token.role as string
|
|
1675
|
+
}
|
|
1676
|
+
return session
|
|
1677
|
+
},
|
|
1678
|
+
|
|
1679
|
+
async signIn({ user, account, profile }) {
|
|
1680
|
+
// Block sign in if email not verified
|
|
1681
|
+
if (account?.provider === 'credentials') {
|
|
1682
|
+
const existingUser = await db.user.findUnique({
|
|
1683
|
+
where: { id: user.id },
|
|
1684
|
+
})
|
|
1685
|
+
if (!existingUser?.emailVerified) {
|
|
1686
|
+
return false
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
return true
|
|
1690
|
+
},
|
|
1691
|
+
},
|
|
1692
|
+
|
|
1693
|
+
pages: {
|
|
1694
|
+
signIn: '/login',
|
|
1695
|
+
error: '/auth/error',
|
|
1696
|
+
verifyRequest: '/auth/verify',
|
|
1697
|
+
},
|
|
1698
|
+
})
|
|
1699
|
+
|
|
1700
|
+
// app/api/auth/[...nextauth]/route.ts
|
|
1701
|
+
import { handlers } from '@/auth'
|
|
1702
|
+
export const { GET, POST } = handlers
|
|
1703
|
+
```
|
|
1704
|
+
|
|
1705
|
+
### Protected Server Component
|
|
1706
|
+
|
|
1707
|
+
```typescript
|
|
1708
|
+
// app/dashboard/page.tsx
|
|
1709
|
+
import { auth } from '@/auth'
|
|
1710
|
+
import { redirect } from 'next/navigation'
|
|
1711
|
+
|
|
1712
|
+
export default async function DashboardPage() {
|
|
1713
|
+
const session = await auth()
|
|
1714
|
+
|
|
1715
|
+
if (!session) {
|
|
1716
|
+
redirect('/login')
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// Role-based check
|
|
1720
|
+
if (session.user.role !== 'admin') {
|
|
1721
|
+
redirect('/unauthorized')
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
return (
|
|
1725
|
+
<div>
|
|
1726
|
+
<h1>Welcome, {session.user.name}</h1>
|
|
1727
|
+
</div>
|
|
1728
|
+
)
|
|
1729
|
+
}
|
|
1730
|
+
```
|
|
1731
|
+
|
|
1732
|
+
### Protected Client Component
|
|
1733
|
+
|
|
1734
|
+
```typescript
|
|
1735
|
+
'use client'
|
|
1736
|
+
|
|
1737
|
+
import { useSession } from 'next-auth/react'
|
|
1738
|
+
import { useRouter } from 'next/navigation'
|
|
1739
|
+
import { useEffect } from 'react'
|
|
1740
|
+
|
|
1741
|
+
export function ProtectedComponent() {
|
|
1742
|
+
const { data: session, status } = useSession()
|
|
1743
|
+
const router = useRouter()
|
|
1744
|
+
|
|
1745
|
+
useEffect(() => {
|
|
1746
|
+
if (status === 'unauthenticated') {
|
|
1747
|
+
router.push('/login')
|
|
1748
|
+
}
|
|
1749
|
+
}, [status, router])
|
|
1750
|
+
|
|
1751
|
+
if (status === 'loading') {
|
|
1752
|
+
return <div>Loading...</div>
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
if (!session) {
|
|
1756
|
+
return null
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
return <div>Protected content for {session.user.name}</div>
|
|
1760
|
+
}
|
|
1761
|
+
```
|
|
1762
|
+
|
|
1763
|
+
### Sign In/Out Server Actions
|
|
1764
|
+
|
|
1765
|
+
```typescript
|
|
1766
|
+
// app/actions/auth.ts
|
|
1767
|
+
'use server'
|
|
1768
|
+
|
|
1769
|
+
import { signIn, signOut } from '@/auth'
|
|
1770
|
+
import { AuthError } from 'next-auth'
|
|
1771
|
+
import { redirect } from 'next/navigation'
|
|
1772
|
+
|
|
1773
|
+
export async function signInWithCredentials(formData: FormData) {
|
|
1774
|
+
try {
|
|
1775
|
+
await signIn('credentials', {
|
|
1776
|
+
email: formData.get('email'),
|
|
1777
|
+
password: formData.get('password'),
|
|
1778
|
+
redirectTo: '/dashboard',
|
|
1779
|
+
})
|
|
1780
|
+
} catch (error) {
|
|
1781
|
+
if (error instanceof AuthError) {
|
|
1782
|
+
switch (error.type) {
|
|
1783
|
+
case 'CredentialsSignin':
|
|
1784
|
+
return { error: 'Invalid credentials' }
|
|
1785
|
+
default:
|
|
1786
|
+
return { error: 'Something went wrong' }
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
throw error
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
export async function signInWithGitHub() {
|
|
1794
|
+
await signIn('github', { redirectTo: '/dashboard' })
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
export async function signOutAction() {
|
|
1798
|
+
await signOut({ redirectTo: '/' })
|
|
1799
|
+
}
|
|
1800
|
+
```
|
|
1801
|
+
|
|
1802
|
+
---
|
|
1803
|
+
|
|
1804
|
+
## Database Integration
|
|
1805
|
+
|
|
1806
|
+
### Prisma Setup
|
|
1807
|
+
|
|
1808
|
+
```typescript
|
|
1809
|
+
// lib/db.ts
|
|
1810
|
+
import { PrismaClient } from '@prisma/client'
|
|
1811
|
+
|
|
1812
|
+
const globalForPrisma = globalThis as unknown as {
|
|
1813
|
+
prisma: PrismaClient | undefined
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
export const db = globalForPrisma.prisma ?? new PrismaClient({
|
|
1817
|
+
log: process.env.NODE_ENV === 'development'
|
|
1818
|
+
? ['query', 'error', 'warn']
|
|
1819
|
+
: ['error'],
|
|
1820
|
+
})
|
|
1821
|
+
|
|
1822
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
1823
|
+
globalForPrisma.prisma = db
|
|
1824
|
+
}
|
|
1825
|
+
```
|
|
1826
|
+
|
|
1827
|
+
### Drizzle ORM Setup
|
|
1828
|
+
|
|
1829
|
+
```typescript
|
|
1830
|
+
// lib/db.ts
|
|
1831
|
+
import { drizzle } from 'drizzle-orm/postgres-js'
|
|
1832
|
+
import postgres from 'postgres'
|
|
1833
|
+
import * as schema from './schema'
|
|
1834
|
+
|
|
1835
|
+
const connectionString = process.env.DATABASE_URL!
|
|
1836
|
+
|
|
1837
|
+
// For query purposes
|
|
1838
|
+
const queryClient = postgres(connectionString)
|
|
1839
|
+
export const db = drizzle(queryClient, { schema })
|
|
1840
|
+
|
|
1841
|
+
// lib/schema.ts
|
|
1842
|
+
import { pgTable, text, timestamp, uuid, boolean } from 'drizzle-orm/pg-core'
|
|
1843
|
+
|
|
1844
|
+
export const users = pgTable('users', {
|
|
1845
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
1846
|
+
email: text('email').notNull().unique(),
|
|
1847
|
+
name: text('name'),
|
|
1848
|
+
emailVerified: timestamp('email_verified'),
|
|
1849
|
+
image: text('image'),
|
|
1850
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
1851
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
1852
|
+
})
|
|
1853
|
+
|
|
1854
|
+
export const posts = pgTable('posts', {
|
|
1855
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
1856
|
+
title: text('title').notNull(),
|
|
1857
|
+
content: text('content'),
|
|
1858
|
+
published: boolean('published').default(false).notNull(),
|
|
1859
|
+
authorId: uuid('author_id').references(() => users.id).notNull(),
|
|
1860
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
1861
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
1862
|
+
})
|
|
1863
|
+
```
|
|
1864
|
+
|
|
1865
|
+
### Database Query Patterns
|
|
1866
|
+
|
|
1867
|
+
```typescript
|
|
1868
|
+
// lib/queries/posts.ts
|
|
1869
|
+
import { cache } from 'react'
|
|
1870
|
+
import { db } from '@/lib/db'
|
|
1871
|
+
import { unstable_cache } from 'next/cache'
|
|
1872
|
+
|
|
1873
|
+
// Request-level caching (deduplication)
|
|
1874
|
+
export const getPost = cache(async (id: string) => {
|
|
1875
|
+
return db.post.findUnique({
|
|
1876
|
+
where: { id },
|
|
1877
|
+
include: { author: true },
|
|
1878
|
+
})
|
|
1879
|
+
})
|
|
1880
|
+
|
|
1881
|
+
// Time-based caching
|
|
1882
|
+
export const getPublishedPosts = unstable_cache(
|
|
1883
|
+
async () => {
|
|
1884
|
+
return db.post.findMany({
|
|
1885
|
+
where: { published: true },
|
|
1886
|
+
orderBy: { createdAt: 'desc' },
|
|
1887
|
+
include: { author: { select: { name: true, image: true } } },
|
|
1888
|
+
})
|
|
1889
|
+
},
|
|
1890
|
+
['published-posts'],
|
|
1891
|
+
{ revalidate: 3600, tags: ['posts'] }
|
|
1892
|
+
)
|
|
1893
|
+
|
|
1894
|
+
// Pagination
|
|
1895
|
+
export async function getPaginatedPosts(page: number, limit: number = 10) {
|
|
1896
|
+
const [posts, total] = await Promise.all([
|
|
1897
|
+
db.post.findMany({
|
|
1898
|
+
skip: (page - 1) * limit,
|
|
1899
|
+
take: limit,
|
|
1900
|
+
orderBy: { createdAt: 'desc' },
|
|
1901
|
+
}),
|
|
1902
|
+
db.post.count(),
|
|
1903
|
+
])
|
|
1904
|
+
|
|
1905
|
+
return {
|
|
1906
|
+
posts,
|
|
1907
|
+
total,
|
|
1908
|
+
pages: Math.ceil(total / limit),
|
|
1909
|
+
hasMore: page * limit < total,
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
```
|
|
1913
|
+
|
|
1914
|
+
---
|
|
1915
|
+
|
|
1916
|
+
## Performance Optimization
|
|
1917
|
+
|
|
1918
|
+
### Image Optimization
|
|
1919
|
+
|
|
1920
|
+
```typescript
|
|
1921
|
+
import Image from 'next/image'
|
|
1922
|
+
|
|
1923
|
+
// Basic usage
|
|
1924
|
+
<Image
|
|
1925
|
+
src="/hero.jpg"
|
|
1926
|
+
alt="Hero image"
|
|
1927
|
+
width={1200}
|
|
1928
|
+
height={600}
|
|
1929
|
+
priority // Load immediately (above the fold)
|
|
1930
|
+
/>
|
|
1931
|
+
|
|
1932
|
+
// Fill container
|
|
1933
|
+
<div className="relative h-64 w-full">
|
|
1934
|
+
<Image
|
|
1935
|
+
src="/background.jpg"
|
|
1936
|
+
alt="Background"
|
|
1937
|
+
fill
|
|
1938
|
+
className="object-cover"
|
|
1939
|
+
sizes="100vw"
|
|
1940
|
+
/>
|
|
1941
|
+
</div>
|
|
1942
|
+
|
|
1943
|
+
// Responsive images
|
|
1944
|
+
<Image
|
|
1945
|
+
src="/photo.jpg"
|
|
1946
|
+
alt="Photo"
|
|
1947
|
+
width={800}
|
|
1948
|
+
height={600}
|
|
1949
|
+
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
|
1950
|
+
placeholder="blur"
|
|
1951
|
+
blurDataURL="data:image/jpeg;base64,..." // Or use static import for auto blur
|
|
1952
|
+
/>
|
|
1953
|
+
|
|
1954
|
+
// Remote images (configure in next.config.js)
|
|
1955
|
+
// next.config.js
|
|
1956
|
+
module.exports = {
|
|
1957
|
+
images: {
|
|
1958
|
+
remotePatterns: [
|
|
1959
|
+
{
|
|
1960
|
+
protocol: 'https',
|
|
1961
|
+
hostname: 'cdn.example.com',
|
|
1962
|
+
pathname: '/images/**',
|
|
1963
|
+
},
|
|
1964
|
+
],
|
|
1965
|
+
},
|
|
1966
|
+
}
|
|
1967
|
+
```
|
|
1968
|
+
|
|
1969
|
+
### Font Optimization
|
|
1970
|
+
|
|
1971
|
+
```typescript
|
|
1972
|
+
// app/layout.tsx
|
|
1973
|
+
import { Inter, Roboto_Mono } from 'next/font/google'
|
|
1974
|
+
import localFont from 'next/font/local'
|
|
1975
|
+
|
|
1976
|
+
// Google Fonts
|
|
1977
|
+
const inter = Inter({
|
|
1978
|
+
subsets: ['latin'],
|
|
1979
|
+
display: 'swap',
|
|
1980
|
+
variable: '--font-inter',
|
|
1981
|
+
})
|
|
1982
|
+
|
|
1983
|
+
const robotoMono = Roboto_Mono({
|
|
1984
|
+
subsets: ['latin'],
|
|
1985
|
+
display: 'swap',
|
|
1986
|
+
variable: '--font-roboto-mono',
|
|
1987
|
+
})
|
|
1988
|
+
|
|
1989
|
+
// Local fonts
|
|
1990
|
+
const customFont = localFont({
|
|
1991
|
+
src: [
|
|
1992
|
+
{
|
|
1993
|
+
path: '../fonts/CustomFont-Regular.woff2',
|
|
1994
|
+
weight: '400',
|
|
1995
|
+
style: 'normal',
|
|
1996
|
+
},
|
|
1997
|
+
{
|
|
1998
|
+
path: '../fonts/CustomFont-Bold.woff2',
|
|
1999
|
+
weight: '700',
|
|
2000
|
+
style: 'normal',
|
|
2001
|
+
},
|
|
2002
|
+
],
|
|
2003
|
+
variable: '--font-custom',
|
|
2004
|
+
})
|
|
2005
|
+
|
|
2006
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
2007
|
+
return (
|
|
2008
|
+
<html lang="en" className={`${inter.variable} ${robotoMono.variable} ${customFont.variable}`}>
|
|
2009
|
+
<body className="font-sans">{children}</body>
|
|
2010
|
+
</html>
|
|
2011
|
+
)
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// tailwind.config.ts
|
|
2015
|
+
module.exports = {
|
|
2016
|
+
theme: {
|
|
2017
|
+
extend: {
|
|
2018
|
+
fontFamily: {
|
|
2019
|
+
sans: ['var(--font-inter)'],
|
|
2020
|
+
mono: ['var(--font-roboto-mono)'],
|
|
2021
|
+
custom: ['var(--font-custom)'],
|
|
2022
|
+
},
|
|
2023
|
+
},
|
|
2024
|
+
},
|
|
2025
|
+
}
|
|
2026
|
+
```
|
|
2027
|
+
|
|
2028
|
+
### Bundle Analysis
|
|
2029
|
+
|
|
2030
|
+
```bash
|
|
2031
|
+
# Install analyzer
|
|
2032
|
+
npm install @next/bundle-analyzer
|
|
2033
|
+
|
|
2034
|
+
# next.config.js
|
|
2035
|
+
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
|
2036
|
+
enabled: process.env.ANALYZE === 'true',
|
|
2037
|
+
})
|
|
2038
|
+
|
|
2039
|
+
module.exports = withBundleAnalyzer({
|
|
2040
|
+
// your config
|
|
2041
|
+
})
|
|
2042
|
+
|
|
2043
|
+
# Run analysis
|
|
2044
|
+
ANALYZE=true npm run build
|
|
2045
|
+
```
|
|
2046
|
+
|
|
2047
|
+
### Dynamic Imports
|
|
2048
|
+
|
|
2049
|
+
```typescript
|
|
2050
|
+
import dynamic from 'next/dynamic'
|
|
2051
|
+
|
|
2052
|
+
// Basic dynamic import
|
|
2053
|
+
const DynamicComponent = dynamic(() => import('./HeavyComponent'), {
|
|
2054
|
+
loading: () => <p>Loading...</p>,
|
|
2055
|
+
})
|
|
2056
|
+
|
|
2057
|
+
// Disable SSR (for browser-only components)
|
|
2058
|
+
const MapComponent = dynamic(() => import('./Map'), {
|
|
2059
|
+
ssr: false,
|
|
2060
|
+
loading: () => <MapSkeleton />,
|
|
2061
|
+
})
|
|
2062
|
+
|
|
2063
|
+
// Named exports
|
|
2064
|
+
const Modal = dynamic(() =>
|
|
2065
|
+
import('./Modal').then((mod) => mod.Modal)
|
|
2066
|
+
)
|
|
2067
|
+
|
|
2068
|
+
// Usage
|
|
2069
|
+
export default function Page() {
|
|
2070
|
+
return (
|
|
2071
|
+
<div>
|
|
2072
|
+
<DynamicComponent />
|
|
2073
|
+
<MapComponent />
|
|
2074
|
+
</div>
|
|
2075
|
+
)
|
|
2076
|
+
}
|
|
2077
|
+
```
|
|
2078
|
+
|
|
2079
|
+
---
|
|
2080
|
+
|
|
2081
|
+
## Security Best Practices
|
|
2082
|
+
|
|
2083
|
+
### Environment Variables
|
|
2084
|
+
|
|
2085
|
+
```typescript
|
|
2086
|
+
// ❌ Wrong - exposes to client
|
|
2087
|
+
const apiKey = process.env.API_KEY // undefined on client
|
|
2088
|
+
|
|
2089
|
+
// ✅ Correct - server only
|
|
2090
|
+
const apiKey = process.env.API_KEY // Only in Server Components/Actions/Route Handlers
|
|
2091
|
+
|
|
2092
|
+
// ✅ Correct - exposed to client (prefix with NEXT_PUBLIC_)
|
|
2093
|
+
const publicKey = process.env.NEXT_PUBLIC_STRIPE_KEY // Available everywhere
|
|
2094
|
+
|
|
2095
|
+
// lib/env.ts - Validate environment variables
|
|
2096
|
+
import { z } from 'zod'
|
|
2097
|
+
|
|
2098
|
+
const envSchema = z.object({
|
|
2099
|
+
DATABASE_URL: z.string().url(),
|
|
2100
|
+
NEXTAUTH_SECRET: z.string().min(32),
|
|
2101
|
+
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
|
|
2102
|
+
NEXT_PUBLIC_APP_URL: z.string().url(),
|
|
2103
|
+
})
|
|
2104
|
+
|
|
2105
|
+
export const env = envSchema.parse(process.env)
|
|
2106
|
+
```
|
|
2107
|
+
|
|
2108
|
+
### Content Security Policy
|
|
2109
|
+
|
|
2110
|
+
```typescript
|
|
2111
|
+
// next.config.js
|
|
2112
|
+
const ContentSecurityPolicy = `
|
|
2113
|
+
default-src 'self';
|
|
2114
|
+
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://cdn.example.com;
|
|
2115
|
+
style-src 'self' 'unsafe-inline';
|
|
2116
|
+
img-src 'self' blob: data: https://cdn.example.com;
|
|
2117
|
+
font-src 'self';
|
|
2118
|
+
connect-src 'self' https://api.example.com;
|
|
2119
|
+
frame-ancestors 'none';
|
|
2120
|
+
`
|
|
2121
|
+
|
|
2122
|
+
const securityHeaders = [
|
|
2123
|
+
{
|
|
2124
|
+
key: 'Content-Security-Policy',
|
|
2125
|
+
value: ContentSecurityPolicy.replace(/\s{2,}/g, ' ').trim(),
|
|
2126
|
+
},
|
|
2127
|
+
{
|
|
2128
|
+
key: 'X-Frame-Options',
|
|
2129
|
+
value: 'DENY',
|
|
2130
|
+
},
|
|
2131
|
+
{
|
|
2132
|
+
key: 'X-Content-Type-Options',
|
|
2133
|
+
value: 'nosniff',
|
|
2134
|
+
},
|
|
2135
|
+
{
|
|
2136
|
+
key: 'Referrer-Policy',
|
|
2137
|
+
value: 'strict-origin-when-cross-origin',
|
|
2138
|
+
},
|
|
2139
|
+
{
|
|
2140
|
+
key: 'Permissions-Policy',
|
|
2141
|
+
value: 'camera=(), microphone=(), geolocation=()',
|
|
2142
|
+
},
|
|
2143
|
+
]
|
|
2144
|
+
|
|
2145
|
+
module.exports = {
|
|
2146
|
+
async headers() {
|
|
2147
|
+
return [
|
|
2148
|
+
{
|
|
2149
|
+
source: '/:path*',
|
|
2150
|
+
headers: securityHeaders,
|
|
2151
|
+
},
|
|
2152
|
+
]
|
|
2153
|
+
},
|
|
2154
|
+
}
|
|
2155
|
+
```
|
|
2156
|
+
|
|
2157
|
+
### Input Validation
|
|
2158
|
+
|
|
2159
|
+
```typescript
|
|
2160
|
+
// lib/validations/post.ts
|
|
2161
|
+
import { z } from 'zod'
|
|
2162
|
+
|
|
2163
|
+
export const createPostSchema = z.object({
|
|
2164
|
+
title: z
|
|
2165
|
+
.string()
|
|
2166
|
+
.min(1, 'Title is required')
|
|
2167
|
+
.max(100, 'Title must be less than 100 characters')
|
|
2168
|
+
.transform((val) => val.trim()),
|
|
2169
|
+
content: z
|
|
2170
|
+
.string()
|
|
2171
|
+
.min(1, 'Content is required')
|
|
2172
|
+
.max(10000, 'Content must be less than 10000 characters'),
|
|
2173
|
+
tags: z
|
|
2174
|
+
.array(z.string().max(20))
|
|
2175
|
+
.max(5, 'Maximum 5 tags allowed')
|
|
2176
|
+
.optional(),
|
|
2177
|
+
})
|
|
2178
|
+
|
|
2179
|
+
export type CreatePostInput = z.infer<typeof createPostSchema>
|
|
2180
|
+
|
|
2181
|
+
// Server Action
|
|
2182
|
+
'use server'
|
|
2183
|
+
|
|
2184
|
+
import { createPostSchema } from '@/lib/validations/post'
|
|
2185
|
+
import { auth } from '@/auth'
|
|
2186
|
+
import DOMPurify from 'isomorphic-dompurify'
|
|
2187
|
+
|
|
2188
|
+
export async function createPost(input: unknown) {
|
|
2189
|
+
const session = await auth()
|
|
2190
|
+
if (!session) throw new Error('Unauthorized')
|
|
2191
|
+
|
|
2192
|
+
// Validate input
|
|
2193
|
+
const validated = createPostSchema.parse(input)
|
|
2194
|
+
|
|
2195
|
+
// Sanitize HTML content if allowing rich text
|
|
2196
|
+
const sanitizedContent = DOMPurify.sanitize(validated.content, {
|
|
2197
|
+
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
|
|
2198
|
+
ALLOWED_ATTR: ['href'],
|
|
2199
|
+
})
|
|
2200
|
+
|
|
2201
|
+
return db.post.create({
|
|
2202
|
+
data: {
|
|
2203
|
+
...validated,
|
|
2204
|
+
content: sanitizedContent,
|
|
2205
|
+
authorId: session.user.id,
|
|
2206
|
+
},
|
|
2207
|
+
})
|
|
2208
|
+
}
|
|
2209
|
+
```
|
|
2210
|
+
|
|
2211
|
+
### CSRF Protection
|
|
2212
|
+
|
|
2213
|
+
```typescript
|
|
2214
|
+
// Server Actions have built-in CSRF protection via the Origin header check
|
|
2215
|
+
|
|
2216
|
+
// For API Routes, implement manually:
|
|
2217
|
+
// app/api/webhook/route.ts
|
|
2218
|
+
import { headers } from 'next/headers'
|
|
2219
|
+
import crypto from 'crypto'
|
|
2220
|
+
|
|
2221
|
+
export async function POST(request: Request) {
|
|
2222
|
+
const headersList = headers()
|
|
2223
|
+
const signature = headersList.get('x-webhook-signature')
|
|
2224
|
+
|
|
2225
|
+
if (!signature) {
|
|
2226
|
+
return new Response('Missing signature', { status: 401 })
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
const body = await request.text()
|
|
2230
|
+
const expectedSignature = crypto
|
|
2231
|
+
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
|
|
2232
|
+
.update(body)
|
|
2233
|
+
.digest('hex')
|
|
2234
|
+
|
|
2235
|
+
if (!crypto.timingSafeEqual(
|
|
2236
|
+
Buffer.from(signature),
|
|
2237
|
+
Buffer.from(expectedSignature)
|
|
2238
|
+
)) {
|
|
2239
|
+
return new Response('Invalid signature', { status: 401 })
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// Process webhook...
|
|
2243
|
+
}
|
|
2244
|
+
```
|
|
2245
|
+
|
|
2246
|
+
---
|
|
2247
|
+
|
|
2248
|
+
## Testing Strategies
|
|
2249
|
+
|
|
2250
|
+
### Unit Testing with Vitest
|
|
2251
|
+
|
|
2252
|
+
```typescript
|
|
2253
|
+
// __tests__/utils.test.ts
|
|
2254
|
+
import { describe, it, expect } from 'vitest'
|
|
2255
|
+
import { formatDate, slugify } from '@/lib/utils'
|
|
2256
|
+
|
|
2257
|
+
describe('formatDate', () => {
|
|
2258
|
+
it('formats date correctly', () => {
|
|
2259
|
+
const date = new Date('2024-01-15')
|
|
2260
|
+
expect(formatDate(date)).toBe('January 15, 2024')
|
|
2261
|
+
})
|
|
2262
|
+
})
|
|
2263
|
+
|
|
2264
|
+
describe('slugify', () => {
|
|
2265
|
+
it('converts string to slug', () => {
|
|
2266
|
+
expect(slugify('Hello World')).toBe('hello-world')
|
|
2267
|
+
expect(slugify('Test 123!')).toBe('test-123')
|
|
2268
|
+
})
|
|
2269
|
+
})
|
|
2270
|
+
```
|
|
2271
|
+
|
|
2272
|
+
### Component Testing with React Testing Library
|
|
2273
|
+
|
|
2274
|
+
```typescript
|
|
2275
|
+
// __tests__/components/Button.test.tsx
|
|
2276
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2277
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2278
|
+
import { Button } from '@/components/ui/button'
|
|
2279
|
+
|
|
2280
|
+
describe('Button', () => {
|
|
2281
|
+
it('renders children correctly', () => {
|
|
2282
|
+
render(<Button>Click me</Button>)
|
|
2283
|
+
expect(screen.getByRole('button')).toHaveTextContent('Click me')
|
|
2284
|
+
})
|
|
2285
|
+
|
|
2286
|
+
it('calls onClick when clicked', () => {
|
|
2287
|
+
const handleClick = vi.fn()
|
|
2288
|
+
render(<Button onClick={handleClick}>Click me</Button>)
|
|
2289
|
+
|
|
2290
|
+
fireEvent.click(screen.getByRole('button'))
|
|
2291
|
+
expect(handleClick).toHaveBeenCalledTimes(1)
|
|
2292
|
+
})
|
|
2293
|
+
|
|
2294
|
+
it('is disabled when disabled prop is true', () => {
|
|
2295
|
+
render(<Button disabled>Click me</Button>)
|
|
2296
|
+
expect(screen.getByRole('button')).toBeDisabled()
|
|
2297
|
+
})
|
|
2298
|
+
})
|
|
2299
|
+
```
|
|
2300
|
+
|
|
2301
|
+
### Integration Testing with Playwright
|
|
2302
|
+
|
|
2303
|
+
```typescript
|
|
2304
|
+
// tests/e2e/auth.spec.ts
|
|
2305
|
+
import { test, expect } from '@playwright/test'
|
|
2306
|
+
|
|
2307
|
+
test.describe('Authentication', () => {
|
|
2308
|
+
test('should redirect to login when accessing protected route', async ({ page }) => {
|
|
2309
|
+
await page.goto('/dashboard')
|
|
2310
|
+
await expect(page).toHaveURL('/login?callbackUrl=%2Fdashboard')
|
|
2311
|
+
})
|
|
2312
|
+
|
|
2313
|
+
test('should login successfully with valid credentials', async ({ page }) => {
|
|
2314
|
+
await page.goto('/login')
|
|
2315
|
+
|
|
2316
|
+
await page.fill('input[name="email"]', 'test@example.com')
|
|
2317
|
+
await page.fill('input[name="password"]', 'password123')
|
|
2318
|
+
await page.click('button[type="submit"]')
|
|
2319
|
+
|
|
2320
|
+
await expect(page).toHaveURL('/dashboard')
|
|
2321
|
+
await expect(page.locator('h1')).toContainText('Welcome')
|
|
2322
|
+
})
|
|
2323
|
+
|
|
2324
|
+
test('should show error with invalid credentials', async ({ page }) => {
|
|
2325
|
+
await page.goto('/login')
|
|
2326
|
+
|
|
2327
|
+
await page.fill('input[name="email"]', 'wrong@example.com')
|
|
2328
|
+
await page.fill('input[name="password"]', 'wrongpassword')
|
|
2329
|
+
await page.click('button[type="submit"]')
|
|
2330
|
+
|
|
2331
|
+
await expect(page.locator('[data-testid="error-message"]')).toBeVisible()
|
|
2332
|
+
})
|
|
2333
|
+
})
|
|
2334
|
+
```
|
|
2335
|
+
|
|
2336
|
+
### API Route Testing
|
|
2337
|
+
|
|
2338
|
+
```typescript
|
|
2339
|
+
// __tests__/api/posts.test.ts
|
|
2340
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2341
|
+
import { GET, POST } from '@/app/api/posts/route'
|
|
2342
|
+
import { db } from '@/lib/db'
|
|
2343
|
+
|
|
2344
|
+
vi.mock('@/lib/db')
|
|
2345
|
+
vi.mock('@/auth', () => ({
|
|
2346
|
+
auth: vi.fn(() => Promise.resolve({ user: { id: '1' } })),
|
|
2347
|
+
}))
|
|
2348
|
+
|
|
2349
|
+
describe('POST /api/posts', () => {
|
|
2350
|
+
beforeEach(() => {
|
|
2351
|
+
vi.clearAllMocks()
|
|
2352
|
+
})
|
|
2353
|
+
|
|
2354
|
+
it('creates a post successfully', async () => {
|
|
2355
|
+
const mockPost = { id: '1', title: 'Test', content: 'Content' }
|
|
2356
|
+
vi.mocked(db.post.create).mockResolvedValue(mockPost)
|
|
2357
|
+
|
|
2358
|
+
const request = new Request('http://localhost/api/posts', {
|
|
2359
|
+
method: 'POST',
|
|
2360
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2361
|
+
body: JSON.stringify({ title: 'Test', content: 'Content' }),
|
|
2362
|
+
})
|
|
2363
|
+
|
|
2364
|
+
const response = await POST(request)
|
|
2365
|
+
const data = await response.json()
|
|
2366
|
+
|
|
2367
|
+
expect(response.status).toBe(201)
|
|
2368
|
+
expect(data).toEqual(mockPost)
|
|
2369
|
+
})
|
|
2370
|
+
})
|
|
2371
|
+
```
|
|
2372
|
+
|
|
2373
|
+
---
|
|
2374
|
+
|
|
2375
|
+
## Deployment
|
|
2376
|
+
|
|
2377
|
+
### Vercel Configuration
|
|
2378
|
+
|
|
2379
|
+
```json
|
|
2380
|
+
// vercel.json
|
|
2381
|
+
{
|
|
2382
|
+
"buildCommand": "prisma generate && next build",
|
|
2383
|
+
"framework": "nextjs",
|
|
2384
|
+
"regions": ["iad1"],
|
|
2385
|
+
"env": {
|
|
2386
|
+
"DATABASE_URL": "@database-url",
|
|
2387
|
+
"NEXTAUTH_SECRET": "@nextauth-secret"
|
|
2388
|
+
},
|
|
2389
|
+
"crons": [
|
|
2390
|
+
{
|
|
2391
|
+
"path": "/api/cron/cleanup",
|
|
2392
|
+
"schedule": "0 0 * * *"
|
|
2393
|
+
}
|
|
2394
|
+
]
|
|
2395
|
+
}
|
|
2396
|
+
```
|
|
2397
|
+
|
|
2398
|
+
### Docker Deployment
|
|
2399
|
+
|
|
2400
|
+
```dockerfile
|
|
2401
|
+
# Dockerfile
|
|
2402
|
+
FROM node:20-alpine AS base
|
|
2403
|
+
|
|
2404
|
+
# Install dependencies only when needed
|
|
2405
|
+
FROM base AS deps
|
|
2406
|
+
RUN apk add --no-cache libc6-compat
|
|
2407
|
+
WORKDIR /app
|
|
2408
|
+
|
|
2409
|
+
COPY package.json package-lock.json* ./
|
|
2410
|
+
RUN npm ci
|
|
2411
|
+
|
|
2412
|
+
# Rebuild the source code only when needed
|
|
2413
|
+
FROM base AS builder
|
|
2414
|
+
WORKDIR /app
|
|
2415
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
2416
|
+
COPY . .
|
|
2417
|
+
|
|
2418
|
+
ENV NEXT_TELEMETRY_DISABLED 1
|
|
2419
|
+
|
|
2420
|
+
RUN npx prisma generate
|
|
2421
|
+
RUN npm run build
|
|
2422
|
+
|
|
2423
|
+
# Production image
|
|
2424
|
+
FROM base AS runner
|
|
2425
|
+
WORKDIR /app
|
|
2426
|
+
|
|
2427
|
+
ENV NODE_ENV production
|
|
2428
|
+
ENV NEXT_TELEMETRY_DISABLED 1
|
|
2429
|
+
|
|
2430
|
+
RUN addgroup --system --gid 1001 nodejs
|
|
2431
|
+
RUN adduser --system --uid 1001 nextjs
|
|
2432
|
+
|
|
2433
|
+
COPY --from=builder /app/public ./public
|
|
2434
|
+
COPY --from=builder /app/.next/standalone ./
|
|
2435
|
+
COPY --from=builder /app/.next/static ./.next/static
|
|
2436
|
+
|
|
2437
|
+
USER nextjs
|
|
2438
|
+
|
|
2439
|
+
EXPOSE 3000
|
|
2440
|
+
|
|
2441
|
+
ENV PORT 3000
|
|
2442
|
+
ENV HOSTNAME "0.0.0.0"
|
|
2443
|
+
|
|
2444
|
+
CMD ["node", "server.js"]
|
|
2445
|
+
```
|
|
2446
|
+
|
|
2447
|
+
```javascript
|
|
2448
|
+
// next.config.js for standalone output
|
|
2449
|
+
module.exports = {
|
|
2450
|
+
output: 'standalone',
|
|
2451
|
+
}
|
|
2452
|
+
```
|
|
2453
|
+
|
|
2454
|
+
---
|
|
2455
|
+
|
|
2456
|
+
## Anti-Patterns to Avoid
|
|
2457
|
+
|
|
2458
|
+
### 1. Fetching in Client Components Unnecessarily
|
|
2459
|
+
|
|
2460
|
+
```typescript
|
|
2461
|
+
// ❌ Bad - unnecessary client-side fetch
|
|
2462
|
+
'use client'
|
|
2463
|
+
export function Posts() {
|
|
2464
|
+
const [posts, setPosts] = useState([])
|
|
2465
|
+
|
|
2466
|
+
useEffect(() => {
|
|
2467
|
+
fetch('/api/posts').then(r => r.json()).then(setPosts)
|
|
2468
|
+
}, [])
|
|
2469
|
+
|
|
2470
|
+
return <PostList posts={posts} />
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
// ✅ Good - fetch in Server Component
|
|
2474
|
+
export default async function Posts() {
|
|
2475
|
+
const posts = await db.post.findMany()
|
|
2476
|
+
return <PostList posts={posts} />
|
|
2477
|
+
}
|
|
2478
|
+
```
|
|
2479
|
+
|
|
2480
|
+
### 2. Waterfall Requests
|
|
2481
|
+
|
|
2482
|
+
```typescript
|
|
2483
|
+
// ❌ Bad - sequential fetches
|
|
2484
|
+
async function Page() {
|
|
2485
|
+
const user = await getUser()
|
|
2486
|
+
const posts = await getPosts() // Waits for user
|
|
2487
|
+
const comments = await getComments() // Waits for posts
|
|
2488
|
+
return <Content user={user} posts={posts} comments={comments} />
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// ✅ Good - parallel fetches
|
|
2492
|
+
async function Page() {
|
|
2493
|
+
const [user, posts, comments] = await Promise.all([
|
|
2494
|
+
getUser(),
|
|
2495
|
+
getPosts(),
|
|
2496
|
+
getComments(),
|
|
2497
|
+
])
|
|
2498
|
+
return <Content user={user} posts={posts} comments={comments} />
|
|
2499
|
+
}
|
|
2500
|
+
```
|
|
2501
|
+
|
|
2502
|
+
### 3. Passing Functions as Props to Client Components
|
|
2503
|
+
|
|
2504
|
+
```typescript
|
|
2505
|
+
// ❌ Bad - can't serialize functions
|
|
2506
|
+
async function Page() {
|
|
2507
|
+
async function handleSubmit(data: FormData) {
|
|
2508
|
+
'use server'
|
|
2509
|
+
await db.post.create({ data })
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
return <Form onSubmit={handleSubmit} /> // Error!
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// ✅ Good - import Server Action
|
|
2516
|
+
import { createPost } from './actions'
|
|
2517
|
+
|
|
2518
|
+
export default function Page() {
|
|
2519
|
+
return <Form action={createPost} />
|
|
2520
|
+
}
|
|
2521
|
+
```
|
|
2522
|
+
|
|
2523
|
+
### 4. Using useEffect for Data Fetching
|
|
2524
|
+
|
|
2525
|
+
```typescript
|
|
2526
|
+
// ❌ Bad - client-side fetch with useEffect
|
|
2527
|
+
'use client'
|
|
2528
|
+
function Dashboard() {
|
|
2529
|
+
const [data, setData] = useState(null)
|
|
2530
|
+
|
|
2531
|
+
useEffect(() => {
|
|
2532
|
+
fetchData().then(setData)
|
|
2533
|
+
}, [])
|
|
2534
|
+
|
|
2535
|
+
if (!data) return <Loading />
|
|
2536
|
+
return <DashboardContent data={data} />
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
// ✅ Good - Server Component with Suspense
|
|
2540
|
+
async function Dashboard() {
|
|
2541
|
+
const data = await fetchData()
|
|
2542
|
+
return <DashboardContent data={data} />
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// Wrap in Suspense at parent level
|
|
2546
|
+
<Suspense fallback={<Loading />}>
|
|
2547
|
+
<Dashboard />
|
|
2548
|
+
</Suspense>
|
|
2549
|
+
```
|
|
2550
|
+
|
|
2551
|
+
### 5. Overusing 'use client'
|
|
2552
|
+
|
|
2553
|
+
```typescript
|
|
2554
|
+
// ❌ Bad - entire page is client
|
|
2555
|
+
'use client'
|
|
2556
|
+
export default function Page() {
|
|
2557
|
+
const [count, setCount] = useState(0)
|
|
2558
|
+
const data = useData() // Client-side fetch
|
|
2559
|
+
|
|
2560
|
+
return (
|
|
2561
|
+
<div>
|
|
2562
|
+
<Header />
|
|
2563
|
+
<Navigation />
|
|
2564
|
+
<Content data={data} />
|
|
2565
|
+
<Counter count={count} setCount={setCount} />
|
|
2566
|
+
<Footer />
|
|
2567
|
+
</div>
|
|
2568
|
+
)
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// ✅ Good - only interactive parts are client
|
|
2572
|
+
export default async function Page() {
|
|
2573
|
+
const data = await getData() // Server fetch
|
|
2574
|
+
|
|
2575
|
+
return (
|
|
2576
|
+
<div>
|
|
2577
|
+
<Header />
|
|
2578
|
+
<Navigation />
|
|
2579
|
+
<Content data={data} />
|
|
2580
|
+
<Counter /> {/* Only this is 'use client' */}
|
|
2581
|
+
<Footer />
|
|
2582
|
+
</div>
|
|
2583
|
+
)
|
|
2584
|
+
}
|
|
2585
|
+
```
|
|
2586
|
+
|
|
2587
|
+
### 6. Not Handling Loading States
|
|
2588
|
+
|
|
2589
|
+
```typescript
|
|
2590
|
+
// ❌ Bad - no loading state
|
|
2591
|
+
export default async function Page() {
|
|
2592
|
+
const data = await slowFetch() // User sees blank page
|
|
2593
|
+
return <Content data={data} />
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
// ✅ Good - with loading state
|
|
2597
|
+
// loading.tsx
|
|
2598
|
+
export default function Loading() {
|
|
2599
|
+
return <Skeleton />
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
// page.tsx
|
|
2603
|
+
export default async function Page() {
|
|
2604
|
+
const data = await slowFetch()
|
|
2605
|
+
return <Content data={data} />
|
|
2606
|
+
}
|
|
2607
|
+
```
|
|
2608
|
+
|
|
2609
|
+
---
|
|
2610
|
+
|
|
2611
|
+
## File Structure Conventions
|
|
2612
|
+
|
|
2613
|
+
### Recommended Structure
|
|
2614
|
+
|
|
2615
|
+
```
|
|
2616
|
+
src/
|
|
2617
|
+
├── app/ # App Router
|
|
2618
|
+
│ ├── (auth)/ # Auth route group
|
|
2619
|
+
│ │ ├── login/
|
|
2620
|
+
│ │ └── register/
|
|
2621
|
+
│ ├── (marketing)/ # Marketing route group
|
|
2622
|
+
│ │ ├── about/
|
|
2623
|
+
│ │ └── pricing/
|
|
2624
|
+
│ ├── (dashboard)/ # Dashboard route group
|
|
2625
|
+
│ │ ├── layout.tsx
|
|
2626
|
+
│ │ ├── dashboard/
|
|
2627
|
+
│ │ └── settings/
|
|
2628
|
+
│ ├── api/ # Route Handlers
|
|
2629
|
+
│ ├── layout.tsx
|
|
2630
|
+
│ ├── page.tsx
|
|
2631
|
+
│ ├── loading.tsx
|
|
2632
|
+
│ ├── error.tsx
|
|
2633
|
+
│ ├── not-found.tsx
|
|
2634
|
+
│ └── providers.tsx
|
|
2635
|
+
│
|
|
2636
|
+
├── components/
|
|
2637
|
+
│ ├── ui/ # Shadcn/UI components
|
|
2638
|
+
│ │ ├── button.tsx
|
|
2639
|
+
│ │ ├── input.tsx
|
|
2640
|
+
│ │ └── dialog.tsx
|
|
2641
|
+
│ ├── forms/ # Form components
|
|
2642
|
+
│ │ ├── login-form.tsx
|
|
2643
|
+
│ │ └── post-form.tsx
|
|
2644
|
+
│ ├── layouts/ # Layout components
|
|
2645
|
+
│ │ ├── header.tsx
|
|
2646
|
+
│ │ ├── footer.tsx
|
|
2647
|
+
│ │ └── sidebar.tsx
|
|
2648
|
+
│ └── [feature]/ # Feature-specific components
|
|
2649
|
+
│ ├── post-card.tsx
|
|
2650
|
+
│ └── post-list.tsx
|
|
2651
|
+
│
|
|
2652
|
+
├── lib/
|
|
2653
|
+
│ ├── db.ts # Database client
|
|
2654
|
+
│ ├── auth.ts # Auth configuration
|
|
2655
|
+
│ ├── utils.ts # Utility functions
|
|
2656
|
+
│ └── validations/ # Zod schemas
|
|
2657
|
+
│ ├── auth.ts
|
|
2658
|
+
│ └── post.ts
|
|
2659
|
+
│
|
|
2660
|
+
├── hooks/ # Custom React hooks
|
|
2661
|
+
│ ├── use-media-query.ts
|
|
2662
|
+
│ └── use-debounce.ts
|
|
2663
|
+
│
|
|
2664
|
+
├── actions/ # Server Actions
|
|
2665
|
+
│ ├── auth.ts
|
|
2666
|
+
│ ├── posts.ts
|
|
2667
|
+
│ └── users.ts
|
|
2668
|
+
│
|
|
2669
|
+
├── types/ # TypeScript types
|
|
2670
|
+
│ ├── index.d.ts
|
|
2671
|
+
│ └── next-auth.d.ts
|
|
2672
|
+
│
|
|
2673
|
+
├── styles/
|
|
2674
|
+
│ └── globals.css
|
|
2675
|
+
│
|
|
2676
|
+
└── config/
|
|
2677
|
+
├── site.ts # Site configuration
|
|
2678
|
+
└── dashboard.ts # Dashboard navigation
|
|
2679
|
+
```
|
|
2680
|
+
|
|
2681
|
+
---
|
|
2682
|
+
|
|
2683
|
+
## Quick Reference
|
|
2684
|
+
|
|
2685
|
+
### Common Imports
|
|
2686
|
+
|
|
2687
|
+
```typescript
|
|
2688
|
+
// Navigation
|
|
2689
|
+
import { redirect, notFound } from 'next/navigation'
|
|
2690
|
+
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
|
2691
|
+
import Link from 'next/link'
|
|
2692
|
+
|
|
2693
|
+
// Headers/Cookies
|
|
2694
|
+
import { headers, cookies } from 'next/headers'
|
|
2695
|
+
|
|
2696
|
+
// Caching
|
|
2697
|
+
import { revalidatePath, revalidateTag } from 'next/cache'
|
|
2698
|
+
import { unstable_cache } from 'next/cache'
|
|
2699
|
+
import { cache } from 'react'
|
|
2700
|
+
|
|
2701
|
+
// Images/Fonts
|
|
2702
|
+
import Image from 'next/image'
|
|
2703
|
+
import { Inter } from 'next/font/google'
|
|
2704
|
+
|
|
2705
|
+
// Dynamic
|
|
2706
|
+
import dynamic from 'next/dynamic'
|
|
2707
|
+
|
|
2708
|
+
// Metadata
|
|
2709
|
+
import type { Metadata, ResolvingMetadata } from 'next'
|
|
2710
|
+
```
|
|
2711
|
+
|
|
2712
|
+
### Essential Patterns Cheat Sheet
|
|
2713
|
+
|
|
2714
|
+
| Pattern | Use Case |
|
|
2715
|
+
|---------|----------|
|
|
2716
|
+
| `async function Component()` | Server Component data fetching |
|
|
2717
|
+
| `'use client'` | Interactive components |
|
|
2718
|
+
| `'use server'` | Server Actions |
|
|
2719
|
+
| `<Suspense>` | Streaming/loading states |
|
|
2720
|
+
| `loading.tsx` | Route-level loading UI |
|
|
2721
|
+
| `error.tsx` | Route-level error boundary |
|
|
2722
|
+
| `generateStaticParams` | Static generation for dynamic routes |
|
|
2723
|
+
| `generateMetadata` | Dynamic SEO metadata |
|
|
2724
|
+
| `revalidatePath/Tag` | On-demand revalidation |
|
|
2725
|
+
| `unstable_cache` | Cache non-fetch functions |
|
|
2726
|
+
| `cache()` | Request memoization |
|
|
2727
|
+
|
|
2728
|
+
---
|
|
2729
|
+
|
|
2730
|
+
*This skill provides comprehensive Next.js expertise. Reference specific sections as needed during development.*
|