Brain Busters
QuizzesMock TestsGamesLibrary
UpdatesCommunityAboutContactPremium
Brain BustersLearning and Exam Intelligence

A student learning app built for practice discipline, exam simulation, and visible improvement.

Move from reading to execution with guided quizzes, mock tests, performance signals, and current exam updates in one system.

Student-first
Built for focused learners
More than content
Practice, revise, and measure
Progress system
Study with exam-ready feedback

Platform

  • Practice Quizzes
  • Mock Tests
  • Brain Games
  • Learning Library
  • Premium Plans

Resources

  • About Us
  • Exam Updates
  • Community
  • Contact
Weekly Signals

Join the intelligence loop

Receive product updates, study prompts, and exam alerts without the noise.

Location
Azamgarh, Uttar Pradesh, India
Support Line
+91 9161060447
Direct Email
support@brainbusters.in

© 2026 Brain Busters. Practice with intent.

PrivacyTermsSitemap
    Back to library
    Learning article
    Web Development
    JavaScript

    The Complete Guide to Next.js App Router: Building Scalable Modern Web Applications

    The App Router is Next.js's modern routing system, introduced in version 13 and stabilized in version 13.4. Unlike the older Pages Router that used file-based routing with a pages/ directory.

    RC

    R.S. Chauhan

    Brain Busters editorial

    September 29, 2025
    37 min read
    0 likes

    Article snapshot

    Read with revision in mind.

    Use the article to understand the topic, identify weak areas, and move back into quizzes with more context.

    Best for concept review
    Start here before timed practice if the topic feels rusty.
    Revision friendly
    Use the tags and related posts to build a tighter study path around the same theme.
    Discuss and clarify
    Add a comment if you want examples, clarifications, or a follow-up explanation.
    The Complete Guide to Next.js App Router: Building Scalable Modern Web Applications

    I still remember the first time I opened the Next.js App Router documentation. After years of working with the Pages Router, I felt like I was learning a new framework entirely. The concepts seemed foreign—server components, parallel routes, intercepting routes—what did it all mean? Fast forward to today, and I can't imagine building a Next.js application any other way.

    Let me share everything I've learned about mastering the App Router, complete with real examples and practical patterns I use daily.

    1. Introduction: Understanding the App Router

    What is the App Router?

    The App Router is Next.js's modern routing system, introduced in version 13 and stabilized in version 13.4. Unlike the older Pages Router that used file-based routing with a pages/ directory, the App Router uses an app/ directory with enhanced features and a fundamentally different architecture.

    Pages Router (the old way):

    pages/
      index.js
      about.js
      blog/
        [slug].js

    App Router (the new way):

    app/
      page.tsx
      about/
        page.tsx
      blog/
        [slug]/
          page.tsx

    Key Benefits

    Server Components by Default: Every component in the App Router is a React Server Component unless you explicitly mark it as a client component with 'use client'. This means:

    • Smaller JavaScript bundles shipped to the browser
    • Direct database queries without API routes
    • Automatic code splitting

    Simplified Data Fetching: Remember creating API routes just to fetch data? Those days are over. You can now fetch directly in your components:

    // app/posts/page.tsx
    async function getPosts() {
      const res = await fetch('https://api.example.com/posts')
      return res.json()
    }
    
    export default async function PostsPage() {
      const posts = await getPosts()
      
      return (
        <div>
          {posts.map(post => (
            <article key={post.id}>{post.title}</article>
          ))}
        </div>
      )
    }

    Code Co-location: You can place components, styles, tests, and utilities right next to the routes that use them. This keeps related code together and makes refactoring much easier.

    Who Should Care?

    If you're maintaining a Pages Router app and wondering whether to migrate, or starting a fresh project and debating which router to use, here's my take:

    • New projects: Use App Router without hesitation
    • Existing large apps: Gradual migration is supported and practical
    • Small existing apps: Migration might take a weekend and is worth it

    2. Project Structure & Folder Conventions

    After building several production apps with the App Router, I've settled on this structure:

    my-app/
    ├── app/
    │   ├── (auth)/
    │   │   ├── login/
    │   │   │   └── page.tsx
    │   │   └── layout.tsx
    │   ├── (dashboard)/
    │   │   ├── analytics/
    │   │   │   ├── page.tsx
    │   │   │   └── loading.tsx
    │   │   ├── settings/
    │   │   │   └── page.tsx
    │   │   └── layout.tsx
    │   ├── api/
    │   │   └── webhooks/
    │   │       └── route.ts
    │   ├── layout.tsx
    │   ├── page.tsx
    │   └── global.css
    ├── components/
    │   ├── ui/
    │   │   ├── button.tsx
    │   │   └── card.tsx
    │   └── shared/
    │       ├── header.tsx
    │       └── footer.tsx
    ├── lib/
    │   ├── db.ts
    │   ├── utils.ts
    │   └── validation.ts
    └── public/
        └── images/

    Special File Conventions

    The App Router introduces special file names that Next.js recognizes:

    • page.tsx: Creates a publicly accessible route
    • layout.tsx: Wraps pages with shared UI (persists across navigation)
    • template.tsx: Like layout, but re-renders on navigation
    • loading.tsx: Automatic loading UI with Suspense
    • error.tsx: Error boundary for the route segment
    • not-found.tsx: Custom 404 UI
    • route.ts: API endpoint (replaces Pages Router's API routes)

    Important: Only page.tsx files create accessible routes. You can have a folder without a page.tsx, and it won't be routable.

    File Colocation Example

    Here's a real pattern I use for complex features:

    app/
      products/
        [id]/
          page.tsx              # The route
          loading.tsx           # Loading state
          error.tsx             # Error boundary
          product-gallery.tsx   # Component used only here
          product-details.tsx   # Another local component
          actions.ts            # Server actions for this route
          types.ts              # TypeScript types

    This keeps everything related to the product detail page in one place. When I need to refactor or remove this feature, I know exactly where everything lives.

    3. Layouts: Building Reusable UI

    Layouts are where the App Router really shines. They let you wrap multiple pages with shared UI that persists across navigation—no re-mounting, no flickering.

    Root Layout: The Foundation

    Every App Router project needs a root layout at app/layout.tsx:

     
    // app/layout.tsx
    export default function RootLayout({
      children,
    }: {
      children: React.ReactNode
    }) {
      return (
        <html lang="en">
          <body>
            <header>
              <nav>
                {/* Your navigation */}
              </nav>
            </header>
            <main>{children}</main>
            <footer>
              {/* Your footer */}
            </footer>
          </body>
        </html>
      )
    }

    Critical rule: The root layout must contain <html> and <body> tags. This is the only layout where you need them.

    Nested Layouts: Composable UI

    Layouts can nest, creating composition patterns. Here's a dashboard example I built:

     
    // app/(dashboard)/layout.tsx
    export default function DashboardLayout({
      children,
    }: {
      children: React.ReactNode
    }) {
      return (
        <div className="flex h-screen">
          <aside className="w-64 bg-gray-100 p-4">
            <DashboardSidebar />
          </aside>
          <div className="flex-1 overflow-auto">
            {children}
          </div>
        </div>
      )
    }

    Now every route under (dashboard)/ gets this sidebar automatically:

    • /dashboard/analytics → Has sidebar
    • /dashboard/settings → Has sidebar
    • /login → No sidebar (different route group)

    Template vs Layout: When to Re-render

    Here's something that confused me initially: the difference between template.tsx and layout.tsx.

    Layout: Maintains state between navigations Template: Re-creates state on every navigation

    Use layout when:

    • You have a sidebar that should keep scroll position
    • You're maintaining open/closed state of a collapsible menu
    • You want better performance (no re-render)

    Use template when:

    • You need CSS animations on route changes
    • You want to reset form state between pages
    • You're tracking page views (needs to fire on each navigation)

    Example template for page transition animations:

     
    // app/template.tsx
    'use client'
    
    import { motion } from 'framer-motion'
    
    export default function Template({ children }: { children: React.ReactNode }) {
      return (
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.3 }}
        >
          {children}
        </motion.div>
      )
    }

    Best Practices for Layouts

    1. Avoid Prop Drilling with Server Components

    Instead of passing data through multiple layout levels, fetch where you need it:

     
    // ❌ Don't do this - prop drilling through layouts
    export default async function Layout({ user, children }) {
      return <Sidebar user={user}>{children}</Sidebar>
    }
    
    // ✅ Do this - fetch directly in the component that needs it
    async function Sidebar() {
      const user = await getCurrentUser()
      return <nav>{user.name}</nav>
    }

    2. Lazy Load Heavy Shared Components

     
    import dynamic from 'next/dynamic'
    
    const HeavyAnalyticsWidget = dynamic(() => 
      import('@/components/analytics-widget'),
      { 
        loading: () => <AnalyticsSkeleton />,
        ssr: false 
      }
    )

    3. Use Context Sparingly

    Since layouts are server components by default, you can't use React Context directly. When you need shared client state:

     
    // app/providers.tsx
    'use client'
    
    import { createContext, useContext } from 'react'
    
    const ThemeContext = createContext({})
    
    export function Providers({ children }: { children: React.ReactNode }) {
      return (
        <ThemeContext.Provider value={{ theme: 'dark' }}>
          {children}
        </ThemeContext.Provider>
      )
    }
    
    // app/layout.tsx
    import { Providers } from './providers'
    
    export default function RootLayout({ children }) {
      return (
        <html>
          <body>
            <Providers>{children}</Providers>
          </body>
        </html>
      )
    }

    4. Nested Routes & Route Groups

    Defining Child Routes

    Routes nest based on folder structure. Each folder represents a URL segment:

    app/
      blog/              → /blog
        [slug]/          → /blog/my-post
          page.tsx
        categories/      → /blog/categories
          [id]/          → /blog/categories/tech
            page.tsx

    Route Groups: Organization Without URLs

    Route groups use parentheses (name) to organize files without adding URL segments. This is brilliant for organizing large apps:

    app/
      (marketing)/
        page.tsx           → /
        about/
          page.tsx         → /about
        layout.tsx         # Marketing layout
      (shop)/
        products/
          page.tsx         → /products
        cart/
          page.tsx         → /cart
        layout.tsx         # Shop layout

    Both route groups share the same root URL namespace but can have completely different layouts.

    Real Pattern: Admin Dashboard

    Here's how I structure admin sections:

    app/
      (admin)/
        dashboard/
          page.tsx         → /dashboard
        users/
          page.tsx         → /users
          [id]/
            edit/
              page.tsx     → /users/123/edit
        layout.tsx         # Admin layout with auth check

    The admin layout handles authentication:

     
    // app/(admin)/layout.tsx
    import { redirect } from 'next/navigation'
    import { getServerSession } from '@/lib/auth'
    
    export default async function AdminLayout({
      children,
    }: {
      children: React.ReactNode
    }) {
      const session = await getServerSession()
      
      if (!session?.user?.isAdmin) {
        redirect('/login')
      }
    
      return (
        <div>
          <AdminHeader />
          {children}
        </div>
      )
    }

    Dynamic Segments with TypeScript

    Type safety for dynamic routes is crucial:

     
    // app/products/[id]/page.tsx
    type Props = {
      params: { id: string }
      searchParams: { [key: string]: string | string[] | undefined }
    }
    
    export default async function ProductPage({ params, searchParams }: Props) {
      const product = await getProduct(params.id)
      const sortOrder = searchParams.sort as string | undefined
      
      return <ProductDetails product={product} />
    }
    
    // Generate static params for build time
    export async function generateStaticParams() {
      const products = await getProducts()
      
      return products.map((product) => ({
        id: product.id.toString(),
      }))
    }

    Catch-all routes use [...slug]:

    app/
      docs/
        [...slug]/
          page.tsx
    
    // Matches:
    // /docs/introduction
    // /docs/api/authentication
    // /docs/guides/getting-started/installation

    Optional catch-all uses [[...slug]]:

    app/
      shop/
        [[...categories]]/
          page.tsx
    
    // Matches:
    // /shop (categories is undefined)
    // /shop/electronics
    // /shop/electronics/laptops

    5. Parallel Routes

    Parallel routes let you render multiple pages in the same layout simultaneously. They use named slots with the @folder convention.

    The Concept

    Think of parallel routes as rendering multiple independent "pages" side by side, each with their own loading and error states.

    app/
      dashboard/
        @analytics/
          page.tsx
        @recent/
          page.tsx
        layout.tsx
        page.tsx

    The layout receives each slot as a prop:

     
    // app/dashboard/layout.tsx
    export default function DashboardLayout({
      children,
      analytics,
      recent,
    }: {
      children: React.ReactNode
      analytics: React.ReactNode
      recent: React.ReactNode
    }) {
      return (
        <div className="grid grid-cols-3 gap-4">
          <div className="col-span-2">{children}</div>
          <aside>
            <section>{analytics}</section>
            <section>{recent}</section>
          </aside>
        </div>
      )
    }

    Real Use Case: Messaging App

    I built a messaging interface using parallel routes:

    app/
      messages/
        @list/
          page.tsx           # Message list
          loading.tsx        # List loading state
        @conversation/
          [id]/
            page.tsx         # Message thread
          default.tsx        # Empty state
        layout.tsx
     
     
    // app/messages/layout.tsx
    export default function MessagesLayout({
      list,
      conversation,
    }: {
      list: React.ReactNode
      conversation: React.ReactNode
    }) {
      return (
        <div className="flex h-screen">
          <div className="w-80 border-r">{list}</div>
          <div className="flex-1">{conversation}</div>
        </div>
      )
    }

    The magic: When you navigate to /messages/123, only the conversation slot updates. The list stays rendered with its scroll position preserved.

    State Preservation

    Parallel routes maintain their own state. If the @list slot loads data, that data persists even when you navigate between conversations.

    Combining with Suspense

    Each parallel route can have independent loading states:

     
    // app/dashboard/@analytics/loading.tsx
    export default function AnalyticsLoading() {
      return <AnalyticsSkeleton />
    }
    
    // app/dashboard/@recent/loading.tsx
    export default function RecentLoading() {
      return <RecentActivitySkeleton />
    }

    The dashboard shows immediately with skeletons while each section loads independently. It's a significant UX improvement over blocking the entire page.

    Default.tsx for Soft Navigation

    When using parallel routes, you need default.tsx files to handle soft navigation (client-side routing):

     
    // app/dashboard/@analytics/default.tsx
    export default function Default() {
      return null // or a default state
    }

    Without this, navigating to a sibling route might cause the slot to disappear.

    6. Intercepting Routes

    Intercepting routes are one of the most powerful—and initially confusing—features. They let you load a route in a modal or drawer while keeping the background page visible.

    The Syntax

    Intercepting routes use special folder prefixes:

    • (.) - Same level
    • (..) - One level up
    • (..)(..) - Two levels up
    • (...) - From app root

    Instagram-Style Photo Modal

    This is the classic example everyone shows, but here's a complete implementation:

    app/
      photos/
        page.tsx              # Photo grid
        [id]/
          page.tsx            # Full photo page
        @modal/
          (.)photos/
            [id]/
              page.tsx        # Modal version
        layout.tsx
     
    // app/photos/layout.tsx
    export default function PhotosLayout({
      children,
      modal,
    }: {
      children: React.ReactNode
      modal: React.ReactNode
    }) {
      return (
        <>
          {children}
          {modal}
        </>
      )
    }
    
    // app/photos/@modal/(.)photos/[id]/page.tsx
    import { Modal } from '@/components/modal'
    import { getPhoto } from '@/lib/photos'
    
    export default async function PhotoModal({ 
      params 
    }: { 
      params: { id: string } 
    }) {
      const photo = await getPhoto(params.id)
      
      return (
        <Modal>
          <img src={photo.url} alt={photo.title} />
        </Modal>
      )
    }

    The behavior:

    • Click a photo in the grid → Modal opens with the photo, URL changes to /photos/123, background stays visible
    • Refresh the page or share the link → Full page loads at /photos/123
    • Close modal → Navigate back to /photos

    Drawer Navigation Pattern

    I use this for mobile-friendly detail views:

     
    // components/drawer.tsx
    'use client'
    
    import { useRouter } from 'next/navigation'
    import { Dialog } from '@headlessui/react'
    
    export function Drawer({ children }: { children: React.ReactNode }) {
      const router = useRouter()
    
      return (
        <Dialog 
          open={true} 
          onClose={() => router.back()}
          className="relative z-50"
        >
          <div className="fixed inset-0 bg-black/30" />
          <div className="fixed inset-y-0 right-0 w-96 bg-white p-6">
            {children}
          </div>
        </Dialog>
      )
    }

    Best Practices

    1. Manage Focus and Accessibility

    'use client'
    
    import { useEffect, useRef } from 'react'
    
    export function Modal({ children }: { children: React.ReactNode }) {
      const closeButtonRef = useRef<HTMLButtonElement>(null)
    
      useEffect(() => {
        closeButtonRef.current?.focus()
        
        // Trap focus in modal
        const handleKeyDown = (e: KeyboardEvent) => {
          if (e.key === 'Escape') {
            router.back()
          }
        }
        
        document.addEventListener('keydown', handleKeyDown)
        return () => document.removeEventListener('keydown', handleKeyDown)
      }, [])
    
      return (
        <div role="dialog" aria-modal="true">
          {children}
        </div>
      )
    }

    2. SEO Considerations

    Both the full page and intercepted route should have proper metadata:

    // app/products/[id]/page.tsx
    export async function generateMetadata({ params }: { params: { id: string } }) {
      const product = await getProduct(params.id)
      
      return {
        title: product.name,
        description: product.description,
        openGraph: {
          images: [product.image],
        },
      }
    }

    The intercepted route inherits this metadata automatically.

    3. Prevent Body Scroll

    'use client'
    
    import { useEffect } from 'react'
    
    export function Modal({ children }: { children: React.ReactNode }) {
      useEffect(() => {
        document.body.style.overflow = 'hidden'
        return () => {
          document.body.style.overflow = 'unset'
        }
      }, [])
    
      return <div className="modal">{children}</div>
    }

    7. Data Fetching & React Server Components

    This is where everything clicks. Server Components fundamentally change how we think about data fetching.

    Why RSC Matters

    Traditional React apps fetch data client-side:

    1. Ship JavaScript bundle to browser
    2. Browser parses and executes code
    3. Code makes API request
    4. Wait for response
    5. Render with data

    With Server Components:

    1. Fetch data on the server
    2. Render components with data
    3. Stream HTML to browser
    4. Browser displays content (no JS needed for static content)

    Fetching in Layouts vs Pages

    In Layouts - For data needed by the entire section:

    // app/(dashboard)/layout.tsx
    async function getUser() {
      const session = await getServerSession()
      return session.user
    }
    
    export default async function DashboardLayout({
      children,
    }: {
      children: React.ReactNode
    }) {
      const user = await getUser()
      
      return (
        <div>
          <DashboardNav user={user} />
          {children}
        </div>
      )
    }

    In Pages - For route-specific data:

    // app/blog/[slug]/page.tsx
    async function getPost(slug: string) {
      const res = await fetch(`https://api.example.com/posts/${slug}`, {
        next: { revalidate: 3600 } // Cache for 1 hour
      })
      return res.json()
    }
    
    async function getRelatedPosts(postId: string) {
      const res = await fetch(`https://api.example.com/posts/${postId}/related`)
      return res.json()
    }
    
    export default async function BlogPost({ 
      params 
    }: { 
      params: { slug: string } 
    }) {
      // These fetch in parallel automatically
      const [post, related] = await Promise.all([
        getPost(params.slug),
        getRelatedPosts(params.slug)
      ])
      
      return (
        <article>
          <h1>{post.title}</h1>
          <PostContent content={post.content} />
          <RelatedPosts posts={related} />
        </article>
      )
    }

    Fetch Caching Options

    Next.js extends the native fetch with caching options:

    // Cached by default (static)
    fetch('https://api.example.com/data')
    
    // Opt out of caching (dynamic)
    fetch('https://api.example.com/data', {
      cache: 'no-store'
    })
    
    // Revalidate every hour
    fetch('https://api.example.com/data', {
      next: { revalidate: 3600 }
    })
    
    // Tag-based revalidation
    fetch('https://api.example.com/data', {
      next: { tags: ['products'] }
    })

    Combining RSC with Client Components

    Here's a pattern I use constantly—server components for data, client components for interactivity:

    // app/products/page.tsx (Server Component)
    import { ProductGrid } from './product-grid'
    
    async function getProducts() {
      const res = await fetch('https://api.example.com/products')
      return res.json()
    }
    
    export default async function ProductsPage() {
      const products = await getProducts()
      
      return (
        <div>
          <h1>Products</h1>
          <ProductGrid products={products} />
        </div>
      )
    }
    
    // app/products/product-grid.tsx (Client Component)
    'use client'
    
    import { useState } from 'react'
    
    export function ProductGrid({ products }: { products: Product[] }) {
      const [filter, setFilter] = useState('')
      
      const filtered = products.filter(p => 
        p.name.toLowerCase().includes(filter.toLowerCase())
      )
      
      return (
        <div>
          <input
            type="text"
            value={filter}
            onChange={(e) => setFilter(e.target.value)}
            placeholder="Filter products..."
          />
          <div className="grid grid-cols-3 gap-4">
            {filtered.map(product => (
              <ProductCard key={product.id} product={product} />
            ))}
          </div>
        </div>
      )
    }

    The server fetches products, and the client handles filtering. Best of both worlds.

    Direct Database Queries

    One of my favorite patterns—no API layer needed:

    // lib/db.ts
    import { PrismaClient } from '@prisma/client'
    
    const prisma = new PrismaClient()
    
    export { prisma }
    
    // app/users/page.tsx
    import { prisma } from '@/lib/db'
    
    export default async function UsersPage() {
      const users = await prisma.user.findMany({
        select: {
          id: true,
          name: true,
          email: true,
        },
        orderBy: {
          createdAt: 'desc'
        },
        take: 50,
      })
      
      return (
        <div>
          {users.map(user => (
            <UserCard key={user.id} user={user} />
          ))}
        </div>
      )
    }

    No API route, no extra network hop. Just fetch and render.

    8. Performance & Caching Strategies

    Static vs Dynamic Rendering

    Next.js automatically determines if a route can be statically rendered:

    Static (default):

    • No dynamic functions (cookies(), headers(), searchParams)
    • All fetches are cached
    • Rendered at build time

    Dynamic:

    • Uses dynamic functions
    • Has uncached fetches
    • Rendered per request

    Force static with generateStaticParams:

    // app/products/[id]/page.tsx
    export async function generateStaticParams() {
      const products = await getProducts()
      
      return products.map((product) => ({
        id: product.id,
      }))
    }
    
    export default async function ProductPage({ 
      params 
    }: { 
      params: { id: string } 
    }) {
      const product = await getProduct(params.id)
      return <ProductDetails product={product} />
    }

    This generates static pages for all products at build time.

    Incremental Static Regeneration

    ISR lets you update static pages after build:

    // app/blog/[slug]/page.tsx
    async function getPost(slug: string) {
      const res = await fetch(`https://api.example.com/posts/${slug}`, {
        next: { revalidate: 60 } // Revalidate every 60 seconds
      })
      return res.json()
    }

    How it works:

    1. First request: Serve stale content (generated at build)
    2. Background: Fetch fresh data
    3. Next request: Serve updated content

    Tag-Based Revalidation

    For more control, use tags:

    // app/products/page.tsx
    async function getProducts() {
      const res = await fetch('https://api.example.com/products', {
        next: { tags: ['products'] }
      })
      return res.json()
    }
    
    // app/api/revalidate/route.ts
    import { revalidateTag } from 'next/cache'
    
    export async function POST(request: Request) {
      const { tag } = await request.json()
      revalidateTag(tag)
      return Response.json({ revalidated: true })
    }

    Now you can revalidate on-demand:

    curl -X POST http://localhost:3000/api/revalidate \
      -H "Content-Type: application/json" \
      -d '{"tag":"products"}'

    Path-Based Revalidation

    Revalidate specific paths:

    // app/actions.ts
    'use server'
    
    import { revalidatePath } from 'next/cache'
    
    export async function createPost(formData: FormData) {
      // Create post logic
      await db.post.create({ ... })
      
      revalidatePath('/blog')
      revalidatePath('/blog/[slug]', 'page')
    }

    Route Segment Config

    Configure caching per route:

    // app/dashboard/page.tsx
    export const dynamic = 'force-dynamic' // Always dynamic
    export const revalidate = 3600 // Revalidate every hour
    export const fetchCache = 'force-no-store' // Never cache fetches
    
    export default async function Dashboard() {
      const data = await getDashboardData()
      return <DashboardView data={data} />
    }

    9. Error & Loading States

    The App Router makes error and loading states elegant with convention-based files.

    Loading States

    Create a loading.tsx file and it automatically wraps your page with Suspense:

    // app/products/loading.tsx
    export default function ProductsLoading() {
      return (
        <div className="grid grid-cols-3 gap-4">
          {Array.from({ length: 9 }).map((_, i) => (
            <div key={i} className="animate-pulse">
              <div className="h-48 bg-gray-200 rounded" />
              <div className="h-4 bg-gray-200 rounded mt-2" />
              <div className="h-4 bg-gray-200 rounded mt-2 w-2/3" />
            </div>
          ))}
        </div>
      )
    }

    Error Boundaries

    error.tsx creates automatic error boundaries:

    // app/products/error.tsx
    'use client'
    
    import { useEffect } from 'react'
    
    export default function ProductsError({
      error,
      reset,
    }: {
      error: Error & { digest?: string }
      reset: () => void
    }) {
      useEffect(() => {
        // Log to error reporting service
        console.error('Products error:', error)
      }, [error])
    
      return (
        <div className="flex flex-col items-center justify-center min-h-96">
          <h2 className="text-2xl font-bold mb-4">Something went wrong!</h2>
          <p className="text-gray-600 mb-6">
            We couldn't load the products. Please try again.
          </p>
          <button
            onClick={reset}
            className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
          >
            Try again
          </button>
        </div>
      )
    }

    User-Friendly Patterns

    Here's a more sophisticated error component I use:

    // app/error.tsx
    'use client'
    
    export default function Error({
      error,
      reset,
    }: {
      error: Error & { digest?: string }
      reset: () => void
    }) {
      const isDevelopment = process.env.NODE_ENV === 'development'
    
      return (
        <div className="min-h-screen flex items-center justify-center p-4">
          <div className="max-w-md w-full bg-white rounded-lg shadow-lg p-6">
            <div className="flex items-center mb-4">
              <div className="flex-shrink-0">
                <svg className="h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
                </svg>
              </div>
              <div className="ml-4">
                <h2 className="text-lg font-semibold text-gray-900">
                  Oops! Something went wrong
                </h2>
              </div>
            </div>
    
            {isDevelopment && (
              <div className="mb-4 p-3 bg-gray-100 rounded">
                <p className="text-sm font-mono text-gray-700">{error.message}</p>
              </div>
            )}
    
            <p className="text-gray-600 mb-6">
              Don't worry, these things happen. Try refreshing the page or contact support if the problem persists.
            </p>
    
            <div className="flex gap-3">
              <button
                onClick={reset}
                className="flex-1 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
              >
                Try Again
              </button>
              
                href="/"
                className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 text-center" > Go Home </a> 
          </div> 
        </div> 
      </div> 
      ) 
    }

    Handling Async Errors in Server Components

    Server component errors are caught by the nearest error boundary:

    // app/posts/[id]/page.tsx
    async function getPost(id: string) {
      const res = await fetch(`https://api.example.com/posts/${id}`)
      
      if (!res.ok) {
        throw new Error('Failed to fetch post')
      }
      
      return res.json()
    }
    
    export default async function PostPage({ params }: { params: { id: string } }) {
      const post = await getPost(params.id)
      
      if (!post) {
        throw new Error('Post not found')
      }
      
      return <PostContent post={post} />
    }

    Custom Not Found Pages

    Use not-found.tsx for 404 errors:

    // app/not-found.tsx
    import Link from 'next/link'
    
    export default function NotFound() {
      return (
        <div className="min-h-screen flex items-center justify-center">
          <div className="text-center">
            <h1 className="text-6xl font-bold text-gray-900 mb-4">404</h1>
            <h2 className="text-2xl font-semibold text-gray-700 mb-4">
              Page Not Found
            </h2>
            <p className="text-gray-600 mb-8">
              The page you're looking for doesn't exist or has been moved.
            </p>
            <Link 
              href="/"
              className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
            >
              Back to Home
            </Link>
          </div>
        </div>
      )
    }

    Trigger it programmatically:

    import { notFound } from 'next/navigation'
    
    export default async function UserPage({ params }: { params: { id: string } }) {
      const user = await getUser(params.id)
      
      if (!user) {
        notFound()
      }
      
      return <UserProfile user={user} />
    }

    Nested Error Boundaries

    Each route segment can have its own error boundary:

    app/
      dashboard/
        error.tsx              # Catches errors in all dashboard routes
        analytics/
          error.tsx            # Catches only analytics errors
          page.tsx
        settings/
          page.tsx

    This provides granular error handling without breaking the entire app.

    10. Migration Tips

    Migrating from Pages Router to App Router doesn't have to be all-or-nothing. Here's what I've learned from several migrations.

    Gradual Adoption Strategy

    Next.js supports both routers simultaneously:

    my-app/
    ├── app/
    │   ├── dashboard/          # New App Router routes
    │   │   └── page.tsx
    │   └── layout.tsx
    ├── pages/
    │   ├── index.tsx           # Old Pages Router routes
    │   ├── about.tsx
    │   └── api/
    │       └── users.ts

    My recommended migration order:

    1. Create root layout in app/layout.tsx
    2. Migrate one feature at a time (e.g., dashboard)
    3. Move API routes to Route Handlers
    4. Update shared components to be RSC-compatible
    5. Finally, migrate remaining pages

    Common Pitfalls

    1. Client vs Server Components Confusion

    This tripped me up constantly at first:

    // ❌ Won't work - trying to use useState in a Server Component
    export default function Page() {
      const [count, setCount] = useState(0)
      return <button onClick={() => setCount(count + 1)}>{count}</button>
    }
    
    // ✅ Add 'use client' directive
    'use client'
    
    export default function Page() {
      const [count, setCount] = useState(0)
      return <button onClick={() => setCount(count + 1)}>{count}</button>
    }

    2. Middleware Differences

    App Router middleware runs on all routes by default:

    // middleware.ts
    import { NextResponse } from 'next/server'
    import type { NextRequest } from 'next/server'
    
    export function middleware(request: NextRequest) {
      // This now runs for BOTH app/ and pages/ routes
      const token = request.cookies.get('token')
      
      if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
        return NextResponse.redirect(new URL('/login', request.url))
      }
    }
    
    // Optionally configure which routes to match
    export const config = {
      matcher: [
        '/dashboard/:path*',
        '/api/:path*',
      ]
    }

    3. Data Fetching Patterns

    Pages Router getServerSideProps:

    // pages/posts.tsx
    export async function getServerSideProps() {
      const posts = await getPosts()
      return { props: { posts } }
    }
    
    export default function Posts({ posts }) {
      return <PostList posts={posts} />
    }

    App Router equivalent:

    // app/posts/page.tsx
    async function getPosts() {
      const res = await fetch('https://api.example.com/posts')
      return res.json()
    }
    
    export default async function Posts() {
      const posts = await getPosts()
      return <PostList posts={posts} />
    }

    4. getStaticProps Becomes Default Behavior

    // pages/blog/[slug].tsx - Pages Router
    export async function getStaticProps({ params }) {
      const post = await getPost(params.slug)
      return { 
        props: { post },
        revalidate: 60 
      }
    }
    
    export async function getStaticPaths() {
      const posts = await getPosts()
      return {
        paths: posts.map(post => ({ params: { slug: post.slug } })),
        fallback: 'blocking'
      }
    }
    
    // app/blog/[slug]/page.tsx - App Router
    export async function generateStaticParams() {
      const posts = await getPosts()
      return posts.map(post => ({ slug: post.slug }))
    }
    
    async function getPost(slug: string) {
      const res = await fetch(`https://api.example.com/posts/${slug}`, {
        next: { revalidate: 60 }
      })
      return res.json()
    }
    
    export default async function BlogPost({ params }: { params: { slug: string } }) {
      const post = await getPost(params.slug)
      return <PostContent post={post} />
    }

    Handling useRouter Changes

    The router API changed:

    // Pages Router
    import { useRouter } from 'next/router'
    
    function Component() {
      const router = useRouter()
      const { id } = router.query
      router.push('/new-page')
    }
    
    // App Router
    'use client'
    
    import { useRouter, useParams, useSearchParams } from 'next/navigation'
    
    function Component() {
      const router = useRouter()
      const params = useParams()
      const searchParams = useSearchParams()
      
      const id = params.id
      router.push('/new-page')
    }

    Link Component Changes

    The <Link> component no longer requires a child <a> tag:

    // Pages Router
    import Link from 'next/link'
    
    <Link href="/about">
      <a>About</a>
    </Link>
    
    // App Router
    import Link from 'next/link'
    
    <Link href="/about">
      About
    </Link>

    Image Component Remains Similar

    Good news—next/image works the same way:

    import Image from 'next/image'
    
    <Image
      src="/profile.jpg"
      alt="Profile"
      width={500}
      height={500}
    />

    11. Testing & Tooling

    Testing Setup with Playwright

    Here's my testing setup for App Router apps:

    // playwright.config.ts
    import { defineConfig, devices } from '@playwright/test'
    
    export default defineConfig({
      testDir: './tests',
      fullyParallel: true,
      use: {
        baseURL: 'http://localhost:3000',
      },
      webServer: {
        command: 'npm run dev',
        url: 'http://localhost:3000',
        reuseExistingServer: !process.env.CI,
      },
      projects: [
        {
          name: 'chromium',
          use: { ...devices['Desktop Chrome'] },
        },
      ],
    })

    Test navigation and loading states:

    // tests/products.spec.ts
    import { test, expect } from '@playwright/test'
    
    test('product list loads and displays items', async ({ page }) => {
      await page.goto('/products')
      
      // Should show loading state initially
      await expect(page.getByTestId('product-skeleton')).toBeVisible()
      
      // Then show actual products
      await expect(page.getByTestId('product-skeleton')).not.toBeVisible()
      await expect(page.getByRole('heading', { name: /products/i })).toBeVisible()
      
      // Should have product cards
      const products = page.getByTestId('product-card')
      await expect(products).toHaveCount(9)
    })
    
    test('clicking product opens modal on same page', async ({ page }) => {
      await page.goto('/products')
      
      // Click first product
      await page.getByTestId('product-card').first().click()
      
      // URL should change but page background should remain
      await expect(page).toHaveURL(/\/products\/\d+/)
      
      // Modal should be visible
      await expect(page.getByRole('dialog')).toBeVisible()
      
      // Background products should still be visible
      await expect(page.getByTestId('product-grid')).toBeVisible()
    })

    Component Testing with React Testing Library

    For client components:

    // components/product-filter.test.tsx
    import { render, screen, fireEvent } from '@testing-library/react'
    import { ProductFilter } from './product-filter'
    
    describe('ProductFilter', () => {
      const mockProducts = [
        { id: 1, name: 'Laptop', category: 'electronics' },
        { id: 2, name: 'Shirt', category: 'clothing' },
        { id: 3, name: 'Phone', category: 'electronics' },
      ]
    
      it('filters products by search term', () => {
        render(<ProductFilter products={mockProducts} />)
        
        const searchInput = screen.getByPlaceholderText(/search/i)
        fireEvent.change(searchInput, { target: { value: 'laptop' } })
        
        expect(screen.getByText('Laptop')).toBeInTheDocument()
        expect(screen.queryByText('Shirt')).not.toBeInTheDocument()
      })
    
      it('filters products by category', () => {
        render(<ProductFilter products={mockProducts} />)
        
        const categorySelect = screen.getByLabelText(/category/i)
        fireEvent.change(categorySelect, { target: { value: 'electronics' } })
        
        expect(screen.getByText('Laptop')).toBeInTheDocument()
        expect(screen.getByText('Phone')).toBeInTheDocument()
        expect(screen.queryByText('Shirt')).not.toBeInTheDocument()
      })
    })

    Type Safety with TypeScript

    Leverage TypeScript for route params:

    // types/routes.ts
    export type RouteParams<T extends Record<string, string>> = {
      params: T
      searchParams: { [key: string]: string | string[] | undefined }
    }
    
    // app/products/[id]/page.tsx
    import type { RouteParams } from '@/types/routes'
    
    type ProductPageParams = RouteParams<{ id: string }>
    
    export default async function ProductPage({ 
      params, 
      searchParams 
    }: ProductPageParams) {
      // params.id is typed as string
      // searchParams is typed properly
    }

    Create typed navigation helpers:

    // lib/navigation.ts
    export const routes = {
      home: '/',
      products: '/products',
      product: (id: string | number) => `/products/${id}`,
      dashboard: {
        root: '/dashboard',
        analytics: '/dashboard/analytics',
        settings: '/dashboard/settings',
      },
    } as const
    
    // Usage
    import Link from 'next/link'
    import { routes } from '@/lib/navigation'
    
    <Link href={routes.product(123)}>View Product</Link>

    ESLint Configuration

    Catch common routing mistakes:

    // .eslintrc.json
    {
      "extends": ["next/core-web-vitals"],
      "rules": {
        "@next/next/no-html-link-for-pages": "error",
        "react/no-unescaped-entities": "error"
      }
    }

    Useful VS Code Extensions

    My recommended setup:

    • ES7+ React/Redux/React-Native snippets - Quick component scaffolding
    • Tailwind CSS IntelliSense - Auto-complete for Tailwind classes
    • Pretty TypeScript Errors - Better error messages
    • Error Lens - Inline error highlighting

    Custom Snippets

    I created this snippet for new pages:

    // .vscode/nextjs-page.code-snippets
    {
      "Next.js App Router Page": {
        "prefix": "npage",
        "body": [
          "export default async function ${1:Page}() {",
          "  return (",
          "    <div>",
          "      <h1>${1:Page}</h1>",
          "    </div>",
          "  )",
          "}"
        ]
      }
    }

    12. Security & SEO Considerations

    Dynamic Metadata API

    The App Router has a powerful metadata system:

    // app/blog/[slug]/page.tsx
    import type { Metadata } from 'next'
    
    export async function generateMetadata({ 
      params 
    }: { 
      params: { slug: string } 
    }): Promise<Metadata> {
      const post = await getPost(params.slug)
      
      return {
        title: post.title,
        description: post.excerpt,
        openGraph: {
          title: post.title,
          description: post.excerpt,
          images: [
            {
              url: post.coverImage,
              width: 1200,
              height: 630,
              alt: post.title,
            }
          ],
          type: 'article',
          publishedTime: post.publishedAt,
          authors: [post.author.name],
        },
        twitter: {
          card: 'summary_large_image',
          title: post.title,
          description: post.excerpt,
          images: [post.coverImage],
        },
        alternates: {
          canonical: `https://example.com/blog/${params.slug}`,
        },
      }
    }

    Preventing Data Leaks in Server Components

    This is critical—server components can accidentally expose sensitive data:

    // ❌ DANGEROUS - exposes API keys to client
    async function UserProfile({ userId }: { userId: string }) {
      const user = await fetch(`https://api.example.com/users/${userId}`, {
        headers: {
          'Authorization': `Bearer ${process.env.SECRET_API_KEY}`
        }
      }).then(res => res.json())
      
      return <div>{JSON.stringify(user)}</div> // Entire response sent to client
    }
    
    // ✅ SAFE - only sends necessary data
    async function UserProfile({ userId }: { userId: string }) {
      const user = await getUser(userId)
      
      // Only select safe fields
      const safeUser = {
        name: user.name,
        avatar: user.avatar,
        bio: user.bio,
      }
      
      return <UserDisplay user={safeUser} />
    }

    Best practices:

    • Never pass entire database records to client components
    • Use explicit field selection in database queries
    • Create DTOs (Data Transfer Objects) for client-facing data
      // lib/dto.ts
      export function toPublicUser(user: User) {
        return {
          id: user.id,
          name: user.name,
          avatar: user.avatar,
          bio: user.bio,
          // Explicitly omit: email, password, apiKeys, etc.
        }
      }
     

    Authentication in Layouts

    Check auth at the layout level for protected routes:

    // app/(protected)/layout.tsx
    import { redirect } from 'next/navigation'
    import { getServerSession } from '@/lib/auth'
    
    export default async function ProtectedLayout({
      children,
    }: {
      children: React.ReactNode
    }) {
      const session = await getServerSession()
      
      if (!session) {
        redirect('/login')
      }
      
      return <>{children}</>
    }

    Middleware for Authentication

    Use middleware for edge-level auth checks:

    // middleware.ts
    import { NextResponse } from 'next/server'
    import type { NextRequest } from 'next/server'
    import { verifyAuth } from '@/lib/auth'
    
    export async function middleware(request: NextRequest) {
      const token = request.cookies.get('auth-token')?.value
      
      // Public routes
      if (request.nextUrl.pathname.startsWith('/login')) {
        return NextResponse.next()
      }
      
      // Protected routes
      if (request.nextUrl.pathname.startsWith('/dashboard')) {
        if (!token) {
          return NextResponse.redirect(new URL('/login', request.url))
        }
        
        const isValid = await verifyAuth(token)
        if (!isValid) {
          return NextResponse.redirect(new URL('/login', request.url))
        }
      }
      
      return NextResponse.next()
    }
    
    export const config = {
      matcher: ['/dashboard/:path*', '/login'],
    }

    Environment Variables

    Server-side only variables:

    // app/api/data/route.ts
    export async function GET() {
      // This is safe - only runs on server
      const apiKey = process.env.SECRET_API_KEY
      
      const data = await fetch('https://api.example.com/data', {
        headers: { 'Authorization': `Bearer ${apiKey}` }
      })
      
      return Response.json(data)
    }

    Client-side variables (must prefix with NEXT_PUBLIC_):

    // app/analytics.tsx
    'use client'
    
    export function Analytics() {
      // This is exposed to the browser
      const analyticsId = process.env.NEXT_PUBLIC_ANALYTICS_ID
      
      useEffect(() => {
        // Initialize analytics
      }, [])
      
      return null
    }

    Content Security Policy

    Add CSP headers in middleware or next.config.js:

    // next.config.js
    const cspHeader = `
      default-src 'self';
      script-src 'self' 'unsafe-eval' 'unsafe-inline';
      style-src 'self' 'unsafe-inline';
      img-src 'self' blob: data: https:;
      font-src 'self';
      connect-src 'self' https://api.example.com;
      frame-ancestors 'none';
    `
    
    module.exports = {
      async headers() {
        return [
          {
            source: '/:path*',
            headers: [
              {
                key: 'Content-Security-Policy',
                value: cspHeader.replace(/\n/g, ''),
              },
            ],
          },
        ]
      },
    }

    13. Real-World Example: E-commerce Dashboard

    Let me walk you through a complete example that brings everything together—an e-commerce dashboard with modals, parallel routes, and server components.

    Project Structure

    app/
      (dashboard)/
        layout.tsx
        page.tsx
        products/
          @modal/
            (.)products/
              [id]/
                page.tsx
          [id]/
            page.tsx
            edit/
              page.tsx
          page.tsx
          loading.tsx
          actions.ts
        analytics/
          @charts/
            page.tsx
          @recent/
            page.tsx
          layout.tsx
          page.tsx

    Root Dashboard Layout

    // app/(dashboard)/layout.tsx
    import { Sidebar } from '@/components/dashboard/sidebar'
    import { Header } from '@/components/dashboard/header'
    import { getServerSession } from '@/lib/auth'
    import { redirect } from 'next/navigation'
    
    export default async function DashboardLayout({
      children,
    }: {
      children: React.ReactNode
    }) {
      const session = await getServerSession()
      
      if (!session) {
        redirect('/login')
      }
    
      return (
        <div className="min-h-screen bg-gray-50">
          <Header user={session.user} />
          <div className="flex">
            <Sidebar />
            <main className="flex-1 p-8">
              {children}
            </main>
          </div>
        </div>
      )
    }

    Products List with Modal Intercept

    // app/(dashboard)/products/page.tsx
    import { prisma } from '@/lib/db'
    import { ProductCard } from '@/components/products/product-card'
    import Link from 'next/link'
    
    async function getProducts() {
      return await prisma.product.findMany({
        select: {
          id: true,
          name: true,
          price: true,
          image: true,
          stock: true,
        },
        orderBy: { createdAt: 'desc' },
      })
    }
    
    export default async function ProductsPage() {
      const products = await getProducts()
    
      return (
        <div>
          <div className="flex justify-between items-center mb-8">
            <h1 className="text-3xl font-bold">Products</h1>
            <Link 
              href="/products/new"
              className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
            >
              Add Product
            </Link>
          </div>
    
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
            {products.map(product => (
              <ProductCard key={product.id} product={product} />
            ))}
          </div>
        </div>
      )
    }

    Product Card Component

    // components/products/product-card.tsx
    import Link from 'next/link'
    import Image from 'next/image'
    
    type Product = {
      id: number
      name: string
      price: number
      image: string
      stock: number
    }
    
    export function ProductCard({ product }: { product: Product }) {
      return (
        <Link href={`/products/${product.id}`}>
          <div className="bg-white rounded-lg shadow hover:shadow-lg transition-shadow cursor-pointer">
            <div className="relative h-48">
              <Image
                src={product.image}
                alt={product.name}
                fill
                className="object-cover rounded-t-lg"
              />
            </div>
            <div className="p-4">
              <h3 className="font-semibold text-lg mb-2">{product.name}</h3>
              <div className="flex justify-between items-center">
                <span className="text-2xl font-bold">
                  ${product.price.toFixed(2)}
                </span>
                <span className={`px-2 py-1 rounded text-sm ${
                  product.stock > 0 
                    ? 'bg-green-100 text-green-800' 
                    : 'bg-red-100 text-red-800'
                }`}>
                  {product.stock > 0 ? `${product.stock} in stock` : 'Out of stock'}
                </span>
              </div>
            </div>
          </div>
        </Link>
      )
    }

    Intercepted Modal Route

    // app/(dashboard)/products/@modal/(.)products/[id]/page.tsx
    import { prisma } from '@/lib/db'
    import { Modal } from '@/components/ui/modal'
    import { ProductDetails } from '@/components/products/product-details'
    
    async function getProduct(id: string) {
      return await prisma.product.findUnique({
        where: { id: parseInt(id) },
        include: {
          category: true,
          reviews: {
            take: 5,
            orderBy: { createdAt: 'desc' },
          },
        },
      })
    }
    
    export default async function ProductModal({
      params,
    }: {
      params: { id: string }
    }) {
      const product = await getProduct(params.id)
    
      if (!product) {
        return <div>Product not found</div>
      }
    
      return (
        <Modal>
          <ProductDetails product={product} />
        </Modal>
      )
    }

    Modal Component

    // components/ui/modal.tsx
    'use client'
    
    import { useRouter } from 'next/navigation'
    import { useEffect, useRef } from 'react'
    import { createPortal } from 'react-dom'
    
    export function Modal({ children }: { children: React.ReactNode }) {
      const router = useRouter()
      const dialogRef = useRef<HTMLDivElement>(null)
    
      useEffect(() => {
        const handleEscape = (e: KeyboardEvent) => {
          if (e.key === 'Escape') router.back()
        }
    
        const handleClickOutside = (e: MouseEvent) => {
          if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
            router.back()
          }
        }
    
        document.addEventListener('keydown', handleEscape)
        document.addEventListener('mousedown', handleClickOutside)
    
        // Prevent body scroll
        document.body.style.overflow = 'hidden'
    
        return () => {
          document.removeEventListener('keydown', handleEscape)
          document.removeEventListener('mousedown', handleClickOutside)
          document.body.style.overflow = 'unset'
        }
      }, [router])
    
      return createPortal(
        <div className="fixed inset-0 z-50 flex items-center justify-center">
          <div className="absolute inset-0 bg-black/50" />
          <div
            ref={dialogRef}
            className="relative bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-auto m-4"
            role="dialog"
            aria-modal="true"
          >
            <button
              onClick={() => router.back()}
              className="absolute top-4 right-4 text-gray-500 hover:text-gray-700"
              aria-label="Close"
            >
              <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
              </svg>
            </button>
            {children}
          </div>
        </div>,
        document.body
      )
    }

    Full Product Page

    // app/(dashboard)/products/[id]/page.tsx
    import { prisma } from '@/lib/db'
    import { ProductDetails } from '@/components/products/product-details'
    import { notFound } from 'next/navigation'
    import type { Metadata } from 'next'
    
    async function getProduct(id: string) {
      return await prisma.product.findUnique({
        where: { id: parseInt(id) },
        include: {
          category: true,
          reviews: true,
        },
      })
    }
    
    export async function generateMetadata({ 
      params 
    }: { 
      params: { id: string } 
    }): Promise<Metadata> {
      const product = await getProduct(params.id)
      
      if (!product) {
        return { title: 'Product Not Found' }
      }
    
      return {
        title: product.name,
        description: product.description,
        openGraph: {
          images: [product.image],
        },
      }
    }
    
    export default async function ProductPage({
      params,
    }: {
      params: { id: string }
    }) {
      const product = await getProduct(params.id)
    
      if (!product) {
        notFound()
      }
    
      return (
        <div className="max-w-6xl mx-auto">
          <ProductDetails product={product} />
        </div>
      )
    }

    Analytics with Parallel Routes

    // app/(dashboard)/analytics/layout.tsx
    export default function AnalyticsLayout({
      children,
      charts,
      recent,
    }: {
      children: React.ReactNode
      charts: React.ReactNode
      recent: React.ReactNode
    }) {
      return (
        <div>
          <h1 className="text-3xl font-bold mb-8">Analytics Dashboard</h1>
          
          <div className="grid grid-cols-3 gap-6">
            <div className="col-span-2">
              {children}
              <div className="mt-6">{charts}</div>
            </div>
            <aside>{recent}</aside>
          </div>
        </div>
      )
    }

    Server Actions for Mutations

    // app/(dashboard)/products/actions.ts
    'use server'
    
    import { prisma } from '@/lib/db'
    import { revalidatePath } from 'next/cache'
    import { redirect } from 'next/navigation'
    
    export async function updateProduct(formData: FormData) {
      const id = formData.get('id') as string
      const name = formData.get('name') as string
      const price = parseFloat(formData.get('price') as string)
      const stock = parseInt(formData.get('stock') as string)
    
      await prisma.product.update({
        where: { id: parseInt(id) },
        data: { name, price, stock },
      })
    
      revalidatePath('/products')
      revalidatePath(`/products/${id}`)
      redirect('/products')
    }
    
    export async function deleteProduct(id: number) {
      await prisma.product.delete({
        where: { id },
      })
    
      revalidatePath('/products')
      redirect('/products')
    }

    This example demonstrates:

    • Nested layouts for dashboard structure
    • Intercepting routes for modal product views
    • Parallel routes for analytics dashboard
    • Server components for data fetching
    • Server actions for mutations
    • Type-safe routing with TypeScript
    • Metadata API for SEO

    14. Conclusion & Further Resources

    Key Takeaways

    After building multiple production apps with the App Router, here are my most important lessons:

    1. Embrace Server Components They're not just an optimization—they fundamentally change how you architect applications. Fetch data where you need it, no prop drilling required.

    2. Start Simple Don't use parallel and intercepting routes just because they exist. Use them when they solve real UX problems: modals that preserve context, split views with independent loading states.

    3. Type Everything TypeScript makes the App Router significantly better. Type your route params, search params, and server actions.

    4. Test the User Flow Focus on end-to-end tests with Playwright. The App Router's streaming and suspense boundaries make traditional component testing less relevant.

    5. Migration is Incremental You don't need to migrate everything at once. The Pages and App routers coexist peacefully.

    Common Mistakes to Avoid

    ❌ Using client components everywhere because you're used to it
    ✅ Default to server components, add 'use client' only when needed

    ❌ Prop drilling through layouts
    ✅ Fetch data in the component that needs it

    ❌ Over-engineering with route groups
    ✅ Use route groups for clear organizational benefits, not just because

    ❌ Ignoring loading states

    ✅ Add loading.tsx files for better UX during data fetching

    ❌ Exposing sensitive data in server components
    ✅ Filter data before passing to client components

    ❌ Not using TypeScript
    ✅ Leverage types for route params and better DX

    Performance Checklist

    Before deploying, I run through this checklist:

    • Images use next/image with proper sizing
    • Heavy components are dynamically imported
    • Database queries use proper indexing
    • Static routes have generateStaticParams
    • Fetch requests have appropriate caching strategies
    • Loading states exist for slow operations
    • Error boundaries catch and handle errors gracefully
    • Metadata is complete for SEO
    • Client components are minimized

    Further Resources

    Official Documentation

    • Next.js App Router Docs - The source of truth
    • React Server Components - Understanding the foundation
    • Next.js Examples - Official example projects

    Community Resources

    • Next.js Discord - Active community for questions
    • Lee Robinson's Blog - VP of Product at Vercel, great insights
    • Theo's T3 Stack - Modern full-stack TypeScript

    Video Tutorials

    • Next.js Conf talks on YouTube - Yearly conference with deep dives
    • Jack Herrington's channel - Practical Next.js tutorials
    • Vercel's YouTube channel - Official tutorials and updates

    Tools I Use Daily

    • Tailwind UI - Component patterns
    • shadcn/ui - Copy-paste React components
    • Prisma - Type-safe database access
    • Zod - Schema validation
    • React Hook Form - Form handling

    Real Production Tips

    Here are some things I wish I'd known when starting with the App Router:

    1. Monitor Build Times Server components can make builds slower if you're fetching data at build time. Use output: 'export' for truly static sites, or deploy to Vercel/similar platforms that handle this well.

    2. Database Connection Pooling Server components run on every request in development. Use connection pooling or serverless-friendly database adapters:

    // lib/db.ts
    import { PrismaClient } from '@prisma/client'
    
    const globalForPrisma = global as unknown as { prisma: PrismaClient }
    
    export const prisma =
      globalForPrisma.prisma ||
      new PrismaClient({
        log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
      })
    
    if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

    3. Debugging Server Components Use console.log liberally—they show in your terminal, not the browser console. This took me embarrassingly long to figure out.

    4. Hot Reload Issues Sometimes Fast Refresh gets confused with server components. If things seem broken, restart your dev server. It's not you, it's the bleeding edge.

    5. Environment Variables Remember: Server components can access ALL environment variables. Client components only see NEXT_PUBLIC_* variables. This is a feature, not a bug.

    // ✅ This works - server component
    async function getData() {
      const apiKey = process.env.SECRET_API_KEY
      // ...
    }
    
    // ❌ This is undefined - client component
    'use client'
    function Component() {
      const apiKey = process.env.SECRET_API_KEY // undefined!
      // ...
    }

    6. Caching Can Be Confusing The App Router aggressively caches by default. When things don't update as expected:

    • Check your fetch cache options
    • Use cache: 'no-store' for debugging
    • Remember to revalidatePath() or revalidateTag() after mutations
    • Clear .next folder and rebuild if really stuck

    7. Middleware Performance Middleware runs on every request and can't access Node.js APIs. Keep it light:

    // ❌ Don't do heavy operations in middleware
    export async function middleware(request: NextRequest) {
      const user = await db.user.findUnique(...) // Too slow!
      // ...
    }
    
    // ✅ Do lightweight checks
    export async function middleware(request: NextRequest) {
      const token = request.cookies.get('token')
      if (!token) {
        return NextResponse.redirect(new URL('/login', request.url))
      }
    }

    8. Deployment Gotchas

    • Set NODE_ENV=production in your environment
    • Configure your database connection for serverless (if applicable)
    • Set up proper CORS headers if you have external API consumers
    • Use output: 'standalone' for Docker deployments
    • Enable image optimization with proper domain configuration

    Advanced Patterns Worth Exploring

    Once you're comfortable with the basics, these patterns unlock even more power:

    1. Streaming with Suspense Stream different parts of your page at different speeds:

    import { Suspense } from 'react'
    
    export default function Page() {
      return (
        <div>
          <Header />
          <Suspense fallback={<QuickDataSkeleton />}>
            <QuickData />
          </Suspense>
          <Suspense fallback={<SlowDataSkeleton />}>
            <SlowData />
          </Suspense>
        </div>
      )
    }

    2. Optimistic Updates Use experimental useOptimistic for instant UI feedback:

    'use client'
    
    import { experimental_useOptimistic as useOptimistic } from 'react'
    
    export function TodoList({ todos }: { todos: Todo[] }) {
      const [optimisticTodos, addOptimisticTodo] = useOptimistic(
        todos,
        (state, newTodo: Todo) => [...state, newTodo]
      )
    
      async function addTodo(formData: FormData) {
        const todo = { id: Date.now(), text: formData.get('text') }
        addOptimisticTodo(todo)
        await createTodo(formData)
      }
    
      return (
        <form action={addTodo}>
          <input name="text" />
          <button>Add</button>
          <ul>
            {optimisticTodos.map(todo => (
              <li key={todo.id}>{todo.text}</li>
            ))}
          </ul>
        </form>
      )
    }

    3. Route Handlers with Streaming Stream large responses for better perceived performance:

    // app/api/export/route.ts
    export async function GET() {
      const encoder = new TextEncoder()
      
      const stream = new ReadableStream({
        async start(controller) {
          const data = await getLargeDataset()
          
          for (const chunk of data) {
            controller.enqueue(encoder.encode(JSON.stringify(chunk) + '\n'))
            await new Promise(resolve => setTimeout(resolve, 100)) // Simulate processing
          }
          
          controller.close()
        }
      })
    
      return new Response(stream, {
        headers: {
          'Content-Type': 'application/json',
          'Transfer-Encoding': 'chunked',
        },
      })
    }

    4. Progressive Enhancement with Server Actions Forms work without JavaScript:

    // app/contact/page.tsx
    import { submitContact } from './actions'
    
    export default function ContactPage() {
      return (
        <form action={submitContact}>
          <input name="email" type="email" required />
          <textarea name="message" required />
          <button type="submit">Send</button>
        </form>
      )
    }
    
    // app/contact/actions.ts
    'use server'
    
    export async function submitContact(formData: FormData) {
      const email = formData.get('email')
      const message = formData.get('message')
      
      await sendEmail({ email, message })
      
      redirect('/contact/success')
    }

    The Future is Bright

    The App Router represents a fundamental shift in how we build web applications. It's not just a new routing system—it's a new architecture that leverages modern React features to deliver better performance, better DX, and better user experiences.

    Yes, there's a learning curve. Yes, some patterns feel foreign at first. But after building several production apps, I can confidently say: this is the future of Next.js, and it's worth learning.

    The beauty of the App Router is that it makes the right thing easy:

    • Want to fetch data? Just await it.
    • Need loading states? Add a file.
    • Want to handle errors? Add another file.
    • Need a modal? Intercept a route.
    • Want type safety? TypeScript has your back.

    It removes boilerplate, reduces complexity, and lets you focus on building features instead of wiring up infrastructure.

    Start Building

    The best way to learn the App Router is to build something real with it. Pick a project—a dashboard, a blog, an e-commerce site—and dive in. You'll make mistakes, hit confusing errors, and question your choices. That's all part of the process.

    But stick with it. Read error messages carefully. Check the documentation. Ask questions in the community. And soon, you'll find yourself thinking in server components, reaching for parallel routes naturally, and building faster, better applications than ever before.

    The App Router isn't perfect, but it's powerful, well-designed, and constantly improving. It represents the cutting edge of web development, and I'm excited to see what you'll build with it.

    Now go forth and create something amazing! 🚀


    Have questions about the App Router or want to share what you're building? I'd love to hear from you. Drop your thoughts in the comments or reach out on Twitter. Happy coding!

    Topics and tags

    Continue from this topic

    Practice next

    Related quizzes

    No related quizzes are attached to this article yet.

    Discussion

    Comments (0)

    Keep comments specific so learners can benefit from the discussion.

    No comments yet.

    Start the discussion with a question or a study insight.

    Quick facts

    Use this article as

    Primary topicWeb Development
    Read time37 minutes
    Comments0
    UpdatedSeptember 29, 2025

    Author

    RC
    R.S. Chauhan
    Published September 29, 2025

    Tagged with

    javascript
    web development
    React
    NextJS
    Browse library