Smithery Logo
MCPsSkillsDocsPricing
Login
Smithery Logo

Accelerating the Agent Economy

Resources

DocumentationPrivacy PolicySystem Status

Company

PricingAboutBlog

Connect

© 2026 Smithery. All rights reserved.

    ericgrill

    nextjs-app-router-patterns

    ericgrill/nextjs-app-router-patterns
    Coding
    5

    About

    SKILL.md

    Install

    Install via Skills CLI

    or add to your agent
    • Claude Code
      Claude Code
    • Codex
      Codex
    • OpenClaw
      OpenClaw
    • Cursor
      Cursor
    • Amp
      Amp
    • GitHub Copilot
      GitHub Copilot
    • Gemini CLI
      Gemini CLI
    • Kilo Code
      Kilo Code
    • Junie
      Junie
    • Replit
      Replit
    • Windsurf
      Windsurf
    • Cline
      Cline
    • Continue
      Continue
    • OpenCode
      OpenCode
    • OpenHands
      OpenHands
    • Roo Code
      Roo Code
    • Augment
      Augment
    • Goose
      Goose
    • Trae
      Trae
    • Zencoder
      Zencoder
    • Antigravity
      Antigravity
    ├─
    ├─
    └─

    About

    Master Next.js 14+ App Router with Server Components, streaming, parallel routes, and advanced data fetching...

    SKILL.md

    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

    // 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

    // 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'

    // 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

    // 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

    // 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)

    // 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

    // 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)

    // 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

    // 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

    // 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
    Recommended Servers
    InstantDB
    InstantDB
    Svelte
    Svelte
    Parallel Tasks
    Parallel Tasks
    Repository
    ericgrill/agents-skills-plugins
    Files