mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 09:37:15 +00:00
feat: add 5 new specialized agents with 20 skills
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
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
---
|
||||
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)
|
||||
Reference in New Issue
Block a user