Initial commit with translated description
This commit is contained in:
766
SKILL.md
Normal file
766
SKILL.md
Normal file
@@ -0,0 +1,766 @@
|
||||
---
|
||||
name: nextjs-expert
|
||||
version: 1.0.0
|
||||
description: "使用App Router构建Next.js 14/15应用程序时使用。用于路由、布局、服务器组件、客户端组件、服务器操作、路由处理程序、认证、中间件、数据获取、缓存等。"
|
||||
triggers:
|
||||
- Next.js
|
||||
- Next
|
||||
- nextjs
|
||||
- App Router
|
||||
- Server Components
|
||||
- Client Components
|
||||
- Server Actions
|
||||
- use server
|
||||
- use client
|
||||
- Route Handler
|
||||
- middleware
|
||||
- layout.tsx
|
||||
- page.tsx
|
||||
- loading.tsx
|
||||
- error.tsx
|
||||
- revalidatePath
|
||||
- revalidateTag
|
||||
- NextAuth
|
||||
- Auth.js
|
||||
- generateStaticParams
|
||||
- generateMetadata
|
||||
role: specialist
|
||||
scope: implementation
|
||||
output-format: code
|
||||
---
|
||||
|
||||
# Next.js Expert
|
||||
|
||||
Comprehensive Next.js 15 App Router specialist. Adapted from buildwithclaude by Dave Poon (MIT).
|
||||
|
||||
## Role Definition
|
||||
|
||||
You are a senior Next.js engineer specializing in the App Router, React Server Components, and production-grade full-stack applications with TypeScript.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Server-first**: Components are Server Components by default. Only add `'use client'` when you need hooks, event handlers, or browser APIs.
|
||||
2. **Push client boundaries down**: Keep `'use client'` as low in the tree as possible.
|
||||
3. **Async params**: In Next.js 15, `params` and `searchParams` are `Promise` types — always `await` them.
|
||||
4. **Colocation**: Keep components, tests, and styles near their routes.
|
||||
5. **Type everything**: Use TypeScript strictly.
|
||||
|
||||
---
|
||||
|
||||
## App Router File Conventions
|
||||
|
||||
### Route Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `page.tsx` | Unique UI for a route, makes it publicly accessible |
|
||||
| `layout.tsx` | Shared UI wrapper, preserves state across navigations |
|
||||
| `loading.tsx` | Loading UI using React Suspense |
|
||||
| `error.tsx` | Error boundary for route segment (must be `'use client'`) |
|
||||
| `not-found.tsx` | UI for 404 responses |
|
||||
| `template.tsx` | Like layout but re-renders on navigation |
|
||||
| `default.tsx` | Fallback for parallel routes |
|
||||
| `route.ts` | API endpoint (Route Handler) |
|
||||
|
||||
### Folder Conventions
|
||||
|
||||
| Pattern | Purpose | Example |
|
||||
|---------|---------|---------|
|
||||
| `folder/` | Route segment | `app/blog/` → `/blog` |
|
||||
| `[folder]/` | Dynamic segment | `app/blog/[slug]/` → `/blog/:slug` |
|
||||
| `[...folder]/` | Catch-all segment | `app/docs/[...slug]/` → `/docs/*` |
|
||||
| `[[...folder]]/` | Optional catch-all | `app/shop/[[...slug]]/` → `/shop` or `/shop/*` |
|
||||
| `(folder)/` | Route group (no URL) | `app/(marketing)/about/` → `/about` |
|
||||
| `@folder/` | Named slot (parallel routes) | `app/@modal/login/` |
|
||||
| `_folder/` | Private folder (excluded) | `app/_components/` |
|
||||
|
||||
### File Hierarchy (render order)
|
||||
|
||||
1. `layout.tsx` → 2. `template.tsx` → 3. `error.tsx` (boundary) → 4. `loading.tsx` (boundary) → 5. `not-found.tsx` (boundary) → 6. `page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Pages and Routing
|
||||
|
||||
### Basic Page (Server Component)
|
||||
|
||||
```tsx
|
||||
// app/about/page.tsx
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<main>
|
||||
<h1>About Us</h1>
|
||||
<p>Welcome to our company.</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Routes
|
||||
|
||||
```tsx
|
||||
// app/blog/[slug]/page.tsx
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
export default async function BlogPost({ params }: PageProps) {
|
||||
const { slug } = await params
|
||||
const post = await getPost(slug)
|
||||
return <article>{post.content}</article>
|
||||
}
|
||||
```
|
||||
|
||||
### Search Params
|
||||
|
||||
```tsx
|
||||
// app/search/page.tsx
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ q?: string; page?: string }>
|
||||
}
|
||||
|
||||
export default async function SearchPage({ searchParams }: PageProps) {
|
||||
const { q, page } = await searchParams
|
||||
const results = await search(q, parseInt(page || '1'))
|
||||
return <SearchResults results={results} />
|
||||
}
|
||||
```
|
||||
|
||||
### Static Generation
|
||||
|
||||
```tsx
|
||||
export async function generateStaticParams() {
|
||||
const posts = await getAllPosts()
|
||||
return posts.map((post) => ({ slug: post.slug }))
|
||||
}
|
||||
|
||||
// Allow dynamic params not in generateStaticParams
|
||||
export const dynamicParams = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layouts
|
||||
|
||||
### Root Layout (Required)
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Nested Layout with Data Fetching
|
||||
|
||||
```tsx
|
||||
// app/dashboard/layout.tsx
|
||||
import { getUser } from '@/lib/get-user'
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await getUser()
|
||||
return (
|
||||
<div className="flex">
|
||||
<Sidebar user={user} />
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Route Groups for Multiple Root Layouts
|
||||
|
||||
```
|
||||
app/
|
||||
├── (marketing)/
|
||||
│ ├── layout.tsx # Marketing layout with <html>/<body>
|
||||
│ └── about/page.tsx
|
||||
└── (app)/
|
||||
├── layout.tsx # App layout with <html>/<body>
|
||||
└── dashboard/page.tsx
|
||||
```
|
||||
|
||||
### Metadata
|
||||
|
||||
```tsx
|
||||
// Static
|
||||
export const metadata: Metadata = {
|
||||
title: 'About Us',
|
||||
description: 'Learn more about our company',
|
||||
}
|
||||
|
||||
// Dynamic
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const post = await getPost(slug)
|
||||
return {
|
||||
title: post.title,
|
||||
openGraph: { title: post.title, images: [post.coverImage] },
|
||||
}
|
||||
}
|
||||
|
||||
// Template in layouts
|
||||
export const metadata: Metadata = {
|
||||
title: { template: '%s | Dashboard', default: 'Dashboard' },
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Components vs Client Components
|
||||
|
||||
### Decision Guide
|
||||
|
||||
**Server Component (default) when:**
|
||||
- Fetching data or accessing backend resources
|
||||
- Keeping sensitive info on server (API keys, tokens)
|
||||
- Reducing client JavaScript bundle
|
||||
- No interactivity needed
|
||||
|
||||
**Client Component (`'use client'`) when:**
|
||||
- Using `useState`, `useEffect`, `useReducer`
|
||||
- Using event handlers (`onClick`, `onChange`)
|
||||
- Using browser APIs (`window`, `document`)
|
||||
- Using custom hooks with state
|
||||
|
||||
### Composition Patterns
|
||||
|
||||
**Pattern 1: Server data → Client interactivity**
|
||||
|
||||
```tsx
|
||||
// app/products/page.tsx (Server)
|
||||
export default async function ProductsPage() {
|
||||
const products = await getProducts()
|
||||
return <ProductFilter products={products} />
|
||||
}
|
||||
|
||||
// components/product-filter.tsx (Client)
|
||||
'use client'
|
||||
export function ProductFilter({ products }: { products: Product[] }) {
|
||||
const [filter, setFilter] = useState('')
|
||||
const filtered = products.filter(p => p.name.includes(filter))
|
||||
return (
|
||||
<>
|
||||
<input onChange={e => setFilter(e.target.value)} />
|
||||
{filtered.map(p => <ProductCard key={p.id} product={p} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern 2: Children as Server Components**
|
||||
|
||||
```tsx
|
||||
// components/client-wrapper.tsx
|
||||
'use client'
|
||||
export function ClientWrapper({ children }: { children: React.ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
|
||||
{isOpen && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// app/page.tsx (Server)
|
||||
export default function Page() {
|
||||
return (
|
||||
<ClientWrapper>
|
||||
<ServerContent /> {/* Still renders on server! */}
|
||||
</ClientWrapper>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern 3: Providers at the boundary**
|
||||
|
||||
```tsx
|
||||
// app/providers.tsx
|
||||
'use client'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system">
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Shared Data with `cache()`
|
||||
|
||||
```tsx
|
||||
import { cache } from 'react'
|
||||
|
||||
export const getUser = cache(async () => {
|
||||
const response = await fetch('/api/user')
|
||||
return response.json()
|
||||
})
|
||||
|
||||
// Both layout and page call getUser() — only one fetch happens
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Fetching
|
||||
|
||||
### Async Server Components
|
||||
|
||||
```tsx
|
||||
export default async function PostsPage() {
|
||||
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
|
||||
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
|
||||
}
|
||||
```
|
||||
|
||||
### Parallel Data Fetching
|
||||
|
||||
```tsx
|
||||
export default async function DashboardPage() {
|
||||
const [user, posts, analytics] = await Promise.all([
|
||||
getUser(), getPosts(), getAnalytics()
|
||||
])
|
||||
return <Dashboard user={user} posts={posts} analytics={analytics} />
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming with Suspense
|
||||
|
||||
```tsx
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<SlowStats />
|
||||
</Suspense>
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<SlowChart />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
```tsx
|
||||
// Cache indefinitely (static)
|
||||
const data = await fetch('https://api.example.com/data')
|
||||
|
||||
// Revalidate every hour
|
||||
const data = await fetch(url, { next: { revalidate: 3600 } })
|
||||
|
||||
// No caching (always fresh)
|
||||
const data = await fetch(url, { cache: 'no-store' })
|
||||
|
||||
// Cache with tags
|
||||
const data = await fetch(url, { next: { tags: ['posts'] } })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Loading and Error States
|
||||
|
||||
### Loading UI
|
||||
|
||||
```tsx
|
||||
// app/dashboard/loading.tsx
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded w-full" />
|
||||
<div className="h-4 bg-gray-200 rounded w-5/6" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Error Boundary
|
||||
|
||||
```tsx
|
||||
// app/dashboard/error.tsx
|
||||
'use client'
|
||||
|
||||
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded">
|
||||
<h2 className="text-red-800 font-bold">Something went wrong!</h2>
|
||||
<p className="text-red-600">{error.message}</p>
|
||||
<button onClick={reset} className="mt-2 px-4 py-2 bg-red-600 text-white rounded">
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Not Found
|
||||
|
||||
```tsx
|
||||
// app/posts/[slug]/page.tsx
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export default async function PostPage({ params }: PageProps) {
|
||||
const { slug } = await params
|
||||
const post = await getPost(slug)
|
||||
if (!post) notFound()
|
||||
return <article>{post.content}</article>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Actions
|
||||
|
||||
### Defining Actions
|
||||
|
||||
```tsx
|
||||
// app/actions.ts
|
||||
'use server'
|
||||
|
||||
import { z } from 'zod'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().min(10),
|
||||
})
|
||||
|
||||
export async function createPost(formData: FormData) {
|
||||
const session = await auth()
|
||||
if (!session?.user) throw new Error('Unauthorized')
|
||||
|
||||
const parsed = schema.safeParse({
|
||||
title: formData.get('title'),
|
||||
content: formData.get('content'),
|
||||
})
|
||||
|
||||
if (!parsed.success) return { error: parsed.error.flatten() }
|
||||
|
||||
const post = await db.post.create({
|
||||
data: { ...parsed.data, authorId: session.user.id },
|
||||
})
|
||||
|
||||
revalidatePath('/posts')
|
||||
redirect(`/posts/${post.slug}`)
|
||||
}
|
||||
```
|
||||
|
||||
### Form with useFormState and useFormStatus
|
||||
|
||||
```tsx
|
||||
// components/submit-button.tsx
|
||||
'use client'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
|
||||
export function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// components/create-post-form.tsx
|
||||
'use client'
|
||||
import { useFormState } from 'react-dom'
|
||||
import { createPost } from '@/app/actions'
|
||||
|
||||
export function CreatePostForm() {
|
||||
const [state, formAction] = useFormState(createPost, {})
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<input name="title" />
|
||||
{state.error?.title && <p className="text-red-500">{state.error.title[0]}</p>}
|
||||
<textarea name="content" />
|
||||
<SubmitButton />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Optimistic Updates
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { useOptimistic, useTransition } from 'react'
|
||||
|
||||
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [optimisticTodos, addOptimistic] = useOptimistic(
|
||||
initialTodos,
|
||||
(state, newTodo: string) => [...state, { id: 'temp', title: newTodo, completed: false }]
|
||||
)
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
const title = formData.get('title') as string
|
||||
startTransition(async () => {
|
||||
addOptimistic(title)
|
||||
await addTodo(formData)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form action={handleSubmit}>
|
||||
<input name="title" />
|
||||
<button>Add</button>
|
||||
</form>
|
||||
<ul>
|
||||
{optimisticTodos.map(todo => (
|
||||
<li key={todo.id} className={todo.id === 'temp' ? 'opacity-50' : ''}>{todo.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Revalidation
|
||||
|
||||
```tsx
|
||||
'use server'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
|
||||
export async function updatePost(id: string, formData: FormData) {
|
||||
await db.post.update({ where: { id }, data: { ... } })
|
||||
|
||||
revalidateTag(`post-${id}`) // Invalidate by cache tag
|
||||
revalidatePath('/posts') // Invalidate specific page
|
||||
revalidatePath(`/posts/${id}`) // Invalidate dynamic route
|
||||
revalidatePath('/posts', 'layout') // Invalidate layout and all pages under it
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Route Handlers (API Routes)
|
||||
|
||||
### Basic CRUD
|
||||
|
||||
```tsx
|
||||
// app/api/posts/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const page = parseInt(searchParams.get('page') ?? '1')
|
||||
const limit = parseInt(searchParams.get('limit') ?? '10')
|
||||
|
||||
const [posts, total] = await Promise.all([
|
||||
db.post.findMany({ skip: (page - 1) * limit, take: limit }),
|
||||
db.post.count(),
|
||||
])
|
||||
|
||||
return NextResponse.json({ data: posts, pagination: { page, limit, total } })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const post = await db.post.create({ data: body })
|
||||
return NextResponse.json(post, { status: 201 })
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Route Handler
|
||||
|
||||
```tsx
|
||||
// app/api/posts/[id]/route.ts
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const post = await db.post.findUnique({ where: { id } })
|
||||
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
return NextResponse.json(post)
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
await db.post.delete({ where: { id } })
|
||||
return new NextResponse(null, { status: 204 })
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming / SSE
|
||||
|
||||
```tsx
|
||||
export async function GET() {
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ count: i })}\n\n`))
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return new Response(stream, {
|
||||
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel and Intercepting Routes
|
||||
|
||||
### Parallel Routes (Slots)
|
||||
|
||||
```
|
||||
app/
|
||||
├── @modal/
|
||||
│ ├── (.)photo/[id]/page.tsx # Intercepted route (modal)
|
||||
│ └── default.tsx
|
||||
├── photo/[id]/page.tsx # Full page route
|
||||
├── layout.tsx
|
||||
└── page.tsx
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx
|
||||
export default function Layout({ children, modal }: {
|
||||
children: React.ReactNode
|
||||
modal: React.ReactNode
|
||||
}) {
|
||||
return <>{children}{modal}</>
|
||||
}
|
||||
```
|
||||
|
||||
### Modal Component
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export function Modal({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center"
|
||||
onClick={() => router.back()}>
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl" onClick={e => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication (NextAuth.js v5 / Auth.js)
|
||||
|
||||
### Setup
|
||||
|
||||
```tsx
|
||||
// auth.ts
|
||||
import NextAuth from 'next-auth'
|
||||
import GitHub from 'next-auth/providers/github'
|
||||
import Credentials from 'next-auth/providers/credentials'
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
providers: [
|
||||
GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET }),
|
||||
Credentials({
|
||||
credentials: { email: {}, password: {} },
|
||||
authorize: async (credentials) => {
|
||||
const user = await getUserByEmail(credentials.email as string)
|
||||
if (!user || !await verifyPassword(credentials.password as string, user.password)) return null
|
||||
return user
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt: ({ token, user }) => { if (user) { token.id = user.id; token.role = user.role } return token },
|
||||
session: ({ session, token }) => { session.user.id = token.id as string; session.user.role = token.role as string; return session },
|
||||
},
|
||||
})
|
||||
|
||||
// app/api/auth/[...nextauth]/route.ts
|
||||
import { handlers } from '@/auth'
|
||||
export const { GET, POST } = handlers
|
||||
```
|
||||
|
||||
### Middleware Protection
|
||||
|
||||
```tsx
|
||||
// middleware.ts
|
||||
export { auth as middleware } from '@/auth'
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
|
||||
}
|
||||
```
|
||||
|
||||
### Server Component Auth Check
|
||||
|
||||
```tsx
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth()
|
||||
if (!session) redirect('/login')
|
||||
return <h1>Welcome, {session.user?.name}</h1>
|
||||
}
|
||||
```
|
||||
|
||||
### Server Action Auth Check
|
||||
|
||||
```tsx
|
||||
'use server'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
export async function deletePost(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user) throw new Error('Unauthorized')
|
||||
|
||||
const post = await db.post.findUnique({ where: { id } })
|
||||
if (post?.authorId !== session.user.id) throw new Error('Forbidden')
|
||||
|
||||
await db.post.delete({ where: { id } })
|
||||
revalidatePath('/posts')
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Route Segment Config
|
||||
|
||||
```tsx
|
||||
export const dynamic = 'force-dynamic' // 'auto' | 'force-dynamic' | 'error' | 'force-static'
|
||||
export const revalidate = 3600 // seconds
|
||||
export const runtime = 'nodejs' // or 'edge'
|
||||
export const maxDuration = 30 // seconds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
1. ❌ Adding `'use client'` to entire pages — push it down to interactive leaves
|
||||
2. ❌ Fetching data in Client Components when it could be a Server Component
|
||||
3. ❌ Sequential `await` when fetches are independent — use `Promise.all()`
|
||||
4. ❌ Passing functions as props across server/client boundary (use Server Actions)
|
||||
5. ❌ Using `useEffect` for data fetching in App Router (use async Server Components)
|
||||
6. ❌ Forgetting `await params` in Next.js 15 (they're Promises now)
|
||||
7. ❌ Missing `loading.tsx` or `<Suspense>` boundaries for async pages
|
||||
8. ❌ Not validating Server Action inputs (always validate with zod)
|
||||
6
_meta.json
Normal file
6
_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn783g0k8dgj2wyz27c0e9he9180ce6k",
|
||||
"slug": "nextjs-expert",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1770008209553
|
||||
}
|
||||
Reference in New Issue
Block a user