git:20260518.3fc52ce to git:20260610.36508ae

531 added, 4 removed. Audit A to A.

---
name: nextjs-patterns
description: Next.js App Router — Server Components, Actions, streaming, caching. Use when building or migrating Next.js apps.
group: brana
keywords: [nextjs, react, app-router, server-components, server-actions, typescript, streaming]
allowed-tools: [Read, Glob, Grep, WebFetch, AskUserQuestion]
status: experimental
source: "https://skills.sh/wshobson/agents/nextjs-app-router-patterns"
acquired: "2026-05-17"
promoted: "2026-05-17"
---
+ # Next.js App Router Patterns
- <!-- PROCEDURE_FILE: procedures/nextjs-patterns.md -->
- This skill's full procedure is in a separate file for startup performance (ADR-034).
- Read and execute `system/procedures/nextjs-patterns.md` from the plugin root directory.
- If the path doesn't resolve, use Glob to find `**/procedures/nextjs-patterns.md`.
+ 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