Claude Agent Skill · by Wshobson

Nextjs App Router Patterns

The nextjs-app-router-patterns skill provides comprehensive guidance on building applications with Next.js 14+ App Router, covering Server Components, rendering

Install
Terminal · npx
$npx skills add https://github.com/wshobson/agents --skill nextjs-app-router-patterns
Works with Paperclip

How Nextjs App Router Patterns fits into a Paperclip company.

Nextjs App Router Patterns drops into any Paperclip agent that handles this kind of work. Assign it to a specialist inside a pre-configured PaperclipOrg company and the skill becomes available on every heartbeat — no prompt engineering, no tool wiring.

S
SaaS FactoryPaired

Pre-configured AI company — 18 agents, 18 skills, one-time purchase.

$27$59
Explore pack
Source file
SKILL.md537 lines
Expand
---name: nextjs-app-router-patternsdescription: 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.tsximport { 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 defaultasync 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.tsximport { 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 Componentasync 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.tsxexport 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.tsxexport default async function AnalyticsSlot() {  const stats = await getAnalytics()  return <AnalyticsChart data={stats} />} // app/dashboard/@analytics/loading.tsxexport default function AnalyticsLoading() {  return <ChartSkeleton />} // app/dashboard/@team/page.tsxexport 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.tsximport { 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 versionexport 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.tsxexport 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.tsximport { 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 dataasync 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.tsimport { 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.tsexport 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.tsximport { 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 secondsfetch(url, { next: { revalidate: 60 } }); // Tag-based invalidationfetch(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