mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 09:37:15 +00:00
Add domain expert agents with comprehensive skill sets: - service-mesh-expert (cloud-infrastructure): Istio/Linkerd patterns, mTLS, observability - event-sourcing-architect (backend-development): CQRS, event stores, projections, sagas - vector-database-engineer (llm-application-dev): embeddings, similarity search, hybrid search - monorepo-architect (developer-essentials): Nx, Turborepo, Bazel, pnpm workspaces - threat-modeling-expert (security-scanning): STRIDE, attack trees, security requirements Update all documentation to reflect correct counts: - 67 plugins, 99 agents, 107 skills, 71 commands
545 lines
13 KiB
Markdown
545 lines
13 KiB
Markdown
---
|
|
name: nextjs-app-router-patterns
|
|
description: Master Next.js 14+ App Router with Server Components, streaming, parallel routes, and advanced data fetching. Use when building Next.js applications, implementing SSR/SSG, or optimizing React Server Components.
|
|
---
|
|
|
|
# Next.js App Router Patterns
|
|
|
|
Comprehensive patterns for Next.js 14+ App Router architecture, Server Components, and modern full-stack React development.
|
|
|
|
## When to Use This Skill
|
|
|
|
- Building new Next.js applications with App Router
|
|
- Migrating from Pages Router to App Router
|
|
- Implementing Server Components and streaming
|
|
- Setting up parallel and intercepting routes
|
|
- Optimizing data fetching and caching
|
|
- Building full-stack features with Server Actions
|
|
|
|
## Core Concepts
|
|
|
|
### 1. Rendering Modes
|
|
|
|
| Mode | Where | When to Use |
|
|
|------|-------|-------------|
|
|
| **Server Components** | Server only | Data fetching, heavy computation, secrets |
|
|
| **Client Components** | Browser | Interactivity, hooks, browser APIs |
|
|
| **Static** | Build time | Content that rarely changes |
|
|
| **Dynamic** | Request time | Personalized or real-time data |
|
|
| **Streaming** | Progressive | Large pages, slow data sources |
|
|
|
|
### 2. File Conventions
|
|
|
|
```
|
|
app/
|
|
├── layout.tsx # Shared UI wrapper
|
|
├── page.tsx # Route UI
|
|
├── loading.tsx # Loading UI (Suspense)
|
|
├── error.tsx # Error boundary
|
|
├── not-found.tsx # 404 UI
|
|
├── route.ts # API endpoint
|
|
├── template.tsx # Re-mounted layout
|
|
├── default.tsx # Parallel route fallback
|
|
└── opengraph-image.tsx # OG image generation
|
|
```
|
|
|
|
## Quick Start
|
|
|
|
```typescript
|
|
// app/layout.tsx
|
|
import { Inter } from 'next/font/google'
|
|
import { Providers } from './providers'
|
|
|
|
const inter = Inter({ subsets: ['latin'] })
|
|
|
|
export const metadata = {
|
|
title: { default: 'My App', template: '%s | My App' },
|
|
description: 'Built with Next.js App Router',
|
|
}
|
|
|
|
export default function RootLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<html lang="en" suppressHydrationWarning>
|
|
<body className={inter.className}>
|
|
<Providers>{children}</Providers>
|
|
</body>
|
|
</html>
|
|
)
|
|
}
|
|
|
|
// app/page.tsx - Server Component by default
|
|
async function getProducts() {
|
|
const res = await fetch('https://api.example.com/products', {
|
|
next: { revalidate: 3600 }, // ISR: revalidate every hour
|
|
})
|
|
return res.json()
|
|
}
|
|
|
|
export default async function HomePage() {
|
|
const products = await getProducts()
|
|
|
|
return (
|
|
<main>
|
|
<h1>Products</h1>
|
|
<ProductGrid products={products} />
|
|
</main>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Patterns
|
|
|
|
### Pattern 1: Server Components with Data Fetching
|
|
|
|
```typescript
|
|
// app/products/page.tsx
|
|
import { Suspense } from 'react'
|
|
import { ProductList, ProductListSkeleton } from '@/components/products'
|
|
import { FilterSidebar } from '@/components/filters'
|
|
|
|
interface SearchParams {
|
|
category?: string
|
|
sort?: 'price' | 'name' | 'date'
|
|
page?: string
|
|
}
|
|
|
|
export default async function ProductsPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<SearchParams>
|
|
}) {
|
|
const params = await searchParams
|
|
|
|
return (
|
|
<div className="flex gap-8">
|
|
<FilterSidebar />
|
|
<Suspense
|
|
key={JSON.stringify(params)}
|
|
fallback={<ProductListSkeleton />}
|
|
>
|
|
<ProductList
|
|
category={params.category}
|
|
sort={params.sort}
|
|
page={Number(params.page) || 1}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// components/products/ProductList.tsx - Server Component
|
|
async function getProducts(filters: ProductFilters) {
|
|
const res = await fetch(
|
|
`${process.env.API_URL}/products?${new URLSearchParams(filters)}`,
|
|
{ next: { tags: ['products'] } }
|
|
)
|
|
if (!res.ok) throw new Error('Failed to fetch products')
|
|
return res.json()
|
|
}
|
|
|
|
export async function ProductList({ category, sort, page }: ProductFilters) {
|
|
const { products, totalPages } = await getProducts({ category, sort, page })
|
|
|
|
return (
|
|
<div>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
{products.map((product) => (
|
|
<ProductCard key={product.id} product={product} />
|
|
))}
|
|
</div>
|
|
<Pagination currentPage={page} totalPages={totalPages} />
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Pattern 2: Client Components with 'use client'
|
|
|
|
```typescript
|
|
// components/products/AddToCartButton.tsx
|
|
'use client'
|
|
|
|
import { useState, useTransition } from 'react'
|
|
import { addToCart } from '@/app/actions/cart'
|
|
|
|
export function AddToCartButton({ productId }: { productId: string }) {
|
|
const [isPending, startTransition] = useTransition()
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const handleClick = () => {
|
|
setError(null)
|
|
startTransition(async () => {
|
|
const result = await addToCart(productId)
|
|
if (result.error) {
|
|
setError(result.error)
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
onClick={handleClick}
|
|
disabled={isPending}
|
|
className="btn-primary"
|
|
>
|
|
{isPending ? 'Adding...' : 'Add to Cart'}
|
|
</button>
|
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Pattern 3: Server Actions
|
|
|
|
```typescript
|
|
// app/actions/cart.ts
|
|
'use server'
|
|
|
|
import { revalidateTag } from 'next/cache'
|
|
import { cookies } from 'next/headers'
|
|
import { redirect } from 'next/navigation'
|
|
|
|
export async function addToCart(productId: string) {
|
|
const cookieStore = await cookies()
|
|
const sessionId = cookieStore.get('session')?.value
|
|
|
|
if (!sessionId) {
|
|
redirect('/login')
|
|
}
|
|
|
|
try {
|
|
await db.cart.upsert({
|
|
where: { sessionId_productId: { sessionId, productId } },
|
|
update: { quantity: { increment: 1 } },
|
|
create: { sessionId, productId, quantity: 1 },
|
|
})
|
|
|
|
revalidateTag('cart')
|
|
return { success: true }
|
|
} catch (error) {
|
|
return { error: 'Failed to add item to cart' }
|
|
}
|
|
}
|
|
|
|
export async function checkout(formData: FormData) {
|
|
const address = formData.get('address') as string
|
|
const payment = formData.get('payment') as string
|
|
|
|
// Validate
|
|
if (!address || !payment) {
|
|
return { error: 'Missing required fields' }
|
|
}
|
|
|
|
// Process order
|
|
const order = await processOrder({ address, payment })
|
|
|
|
// Redirect to confirmation
|
|
redirect(`/orders/${order.id}/confirmation`)
|
|
}
|
|
```
|
|
|
|
### Pattern 4: Parallel Routes
|
|
|
|
```typescript
|
|
// app/dashboard/layout.tsx
|
|
export default function DashboardLayout({
|
|
children,
|
|
analytics,
|
|
team,
|
|
}: {
|
|
children: React.ReactNode
|
|
analytics: React.ReactNode
|
|
team: React.ReactNode
|
|
}) {
|
|
return (
|
|
<div className="dashboard-grid">
|
|
<main>{children}</main>
|
|
<aside className="analytics-panel">{analytics}</aside>
|
|
<aside className="team-panel">{team}</aside>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// app/dashboard/@analytics/page.tsx
|
|
export default async function AnalyticsSlot() {
|
|
const stats = await getAnalytics()
|
|
return <AnalyticsChart data={stats} />
|
|
}
|
|
|
|
// app/dashboard/@analytics/loading.tsx
|
|
export default function AnalyticsLoading() {
|
|
return <ChartSkeleton />
|
|
}
|
|
|
|
// app/dashboard/@team/page.tsx
|
|
export default async function TeamSlot() {
|
|
const members = await getTeamMembers()
|
|
return <TeamList members={members} />
|
|
}
|
|
```
|
|
|
|
### Pattern 5: Intercepting Routes (Modal Pattern)
|
|
|
|
```typescript
|
|
// File structure for photo modal
|
|
// app/
|
|
// ├── @modal/
|
|
// │ ├── (.)photos/[id]/page.tsx # Intercept
|
|
// │ └── default.tsx
|
|
// ├── photos/
|
|
// │ └── [id]/page.tsx # Full page
|
|
// └── layout.tsx
|
|
|
|
// app/@modal/(.)photos/[id]/page.tsx
|
|
import { Modal } from '@/components/Modal'
|
|
import { PhotoDetail } from '@/components/PhotoDetail'
|
|
|
|
export default async function PhotoModal({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>
|
|
}) {
|
|
const { id } = await params
|
|
const photo = await getPhoto(id)
|
|
|
|
return (
|
|
<Modal>
|
|
<PhotoDetail photo={photo} />
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
// app/photos/[id]/page.tsx - Full page version
|
|
export default async function PhotoPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>
|
|
}) {
|
|
const { id } = await params
|
|
const photo = await getPhoto(id)
|
|
|
|
return (
|
|
<div className="photo-page">
|
|
<PhotoDetail photo={photo} />
|
|
<RelatedPhotos photoId={id} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// app/layout.tsx
|
|
export default function RootLayout({
|
|
children,
|
|
modal,
|
|
}: {
|
|
children: React.ReactNode
|
|
modal: React.ReactNode
|
|
}) {
|
|
return (
|
|
<html>
|
|
<body>
|
|
{children}
|
|
{modal}
|
|
</body>
|
|
</html>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Pattern 6: Streaming with Suspense
|
|
|
|
```typescript
|
|
// app/product/[id]/page.tsx
|
|
import { Suspense } from 'react'
|
|
|
|
export default async function ProductPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>
|
|
}) {
|
|
const { id } = await params
|
|
|
|
// This data loads first (blocking)
|
|
const product = await getProduct(id)
|
|
|
|
return (
|
|
<div>
|
|
{/* Immediate render */}
|
|
<ProductHeader product={product} />
|
|
|
|
{/* Stream in reviews */}
|
|
<Suspense fallback={<ReviewsSkeleton />}>
|
|
<Reviews productId={id} />
|
|
</Suspense>
|
|
|
|
{/* Stream in recommendations */}
|
|
<Suspense fallback={<RecommendationsSkeleton />}>
|
|
<Recommendations productId={id} />
|
|
</Suspense>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// These components fetch their own data
|
|
async function Reviews({ productId }: { productId: string }) {
|
|
const reviews = await getReviews(productId) // Slow API
|
|
return <ReviewList reviews={reviews} />
|
|
}
|
|
|
|
async function Recommendations({ productId }: { productId: string }) {
|
|
const products = await getRecommendations(productId) // ML-based, slow
|
|
return <ProductCarousel products={products} />
|
|
}
|
|
```
|
|
|
|
### Pattern 7: Route Handlers (API Routes)
|
|
|
|
```typescript
|
|
// app/api/products/route.ts
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const searchParams = request.nextUrl.searchParams
|
|
const category = searchParams.get('category')
|
|
|
|
const products = await db.product.findMany({
|
|
where: category ? { category } : undefined,
|
|
take: 20,
|
|
})
|
|
|
|
return NextResponse.json(products)
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const body = await request.json()
|
|
|
|
const product = await db.product.create({
|
|
data: body,
|
|
})
|
|
|
|
return NextResponse.json(product, { status: 201 })
|
|
}
|
|
|
|
// app/api/products/[id]/route.ts
|
|
export async function GET(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const { id } = await params
|
|
const product = await db.product.findUnique({ where: { id } })
|
|
|
|
if (!product) {
|
|
return NextResponse.json(
|
|
{ error: 'Product not found' },
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json(product)
|
|
}
|
|
```
|
|
|
|
### Pattern 8: Metadata and SEO
|
|
|
|
```typescript
|
|
// app/products/[slug]/page.tsx
|
|
import { Metadata } from 'next'
|
|
import { notFound } from 'next/navigation'
|
|
|
|
type Props = {
|
|
params: Promise<{ slug: string }>
|
|
}
|
|
|
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
const { slug } = await params
|
|
const product = await getProduct(slug)
|
|
|
|
if (!product) return {}
|
|
|
|
return {
|
|
title: product.name,
|
|
description: product.description,
|
|
openGraph: {
|
|
title: product.name,
|
|
description: product.description,
|
|
images: [{ url: product.image, width: 1200, height: 630 }],
|
|
},
|
|
twitter: {
|
|
card: 'summary_large_image',
|
|
title: product.name,
|
|
description: product.description,
|
|
images: [product.image],
|
|
},
|
|
}
|
|
}
|
|
|
|
export async function generateStaticParams() {
|
|
const products = await db.product.findMany({ select: { slug: true } })
|
|
return products.map((p) => ({ slug: p.slug }))
|
|
}
|
|
|
|
export default async function ProductPage({ params }: Props) {
|
|
const { slug } = await params
|
|
const product = await getProduct(slug)
|
|
|
|
if (!product) notFound()
|
|
|
|
return <ProductDetail product={product} />
|
|
}
|
|
```
|
|
|
|
## Caching Strategies
|
|
|
|
### Data Cache
|
|
|
|
```typescript
|
|
// No cache (always fresh)
|
|
fetch(url, { cache: 'no-store' })
|
|
|
|
// Cache forever (static)
|
|
fetch(url, { cache: 'force-cache' })
|
|
|
|
// ISR - revalidate after 60 seconds
|
|
fetch(url, { next: { revalidate: 60 } })
|
|
|
|
// Tag-based invalidation
|
|
fetch(url, { next: { tags: ['products'] } })
|
|
|
|
// Invalidate via Server Action
|
|
'use server'
|
|
import { revalidateTag, revalidatePath } from 'next/cache'
|
|
|
|
export async function updateProduct(id: string, data: ProductData) {
|
|
await db.product.update({ where: { id }, data })
|
|
revalidateTag('products')
|
|
revalidatePath('/products')
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### Do's
|
|
- **Start with Server Components** - Add 'use client' only when needed
|
|
- **Colocate data fetching** - Fetch data where it's used
|
|
- **Use Suspense boundaries** - Enable streaming for slow data
|
|
- **Leverage parallel routes** - Independent loading states
|
|
- **Use Server Actions** - For mutations with progressive enhancement
|
|
|
|
### Don'ts
|
|
- **Don't pass serializable data** - Server → Client boundary limitations
|
|
- **Don't use hooks in Server Components** - No useState, useEffect
|
|
- **Don't fetch in Client Components** - Use Server Components or React Query
|
|
- **Don't over-nest layouts** - Each layout adds to the component tree
|
|
- **Don't ignore loading states** - Always provide loading.tsx or Suspense
|
|
|
|
## Resources
|
|
|
|
- [Next.js App Router Documentation](https://nextjs.org/docs/app)
|
|
- [Server Components RFC](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md)
|
|
- [Vercel Templates](https://vercel.com/templates/next.js)
|