Resources / Blog / Next.js Deep Dive

Next.js 14 App Router: Complete Guide with Best Practices

Master Next.js 14's App Router with comprehensive coverage of server components, streaming, caching, and production-ready patterns.

Jan 26, 202615 min readJames Wilson
Next.jsReactApp RouterServer ComponentsFull StackPerformanceTypeScript

Introduction to Next.js 14 App Router

Next.js 14 introduces significant architectural changes with the stable App Router, moving from a page-based to a component-based routing system. This shift enables better performance, improved developer experience, and more flexible data fetching patterns.

App Router vs Pages Router

  • File-based routing with enhanced conventions
  • Server Components by default for better performance
  • Nested layouts and parallel routes
  • Improved data fetching with async components
  • Built-in loading and error states
  • Streaming and Suspense integration
  • Enhanced metadata API

Server Components Architecture

Server Components run exclusively on the server, sending minimal JavaScript to the client. This reduces bundle size and enables direct database access without API layers.

// Server Component - runs on server, no JavaScript sent to client
export default async function ProductPage({ params }: { params: { id: string } }) {
  // Direct database access - secure and efficient
  const product = await db.products.findUnique({
    where: { id: params.id }
  });

  // No useState, useEffect, or event handlers here
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      {/* Client Component for interactivity */}
      <AddToCartButton productId={product.id} />
    </div>
  );
}

// Client Component - marked with 'use client'
'use client';
export function AddToCartButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false);
  
  const handleClick = async () => {
    setLoading(true);
    await addToCart(productId);
    setLoading(false);
  };

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

Data Fetching Patterns

App Router introduces new data fetching patterns that optimize for performance and user experience.

// Parallel data fetching
export default async function Dashboard() {
  // Fetch multiple data sources in parallel
  const [userData, orders, analytics] = await Promise.all([
    fetchUserData(),
    fetchOrders(),
    fetchAnalytics(),
  ]);

  return (
    <div>
      <UserProfile data={userData} />
      <OrderList orders={orders} />
      <AnalyticsChart data={analytics} />
    </div>
  );
}

// Sequential with loading states
export default function UserPage() {
  return (
    <Suspense fallback={<UserSkeleton />}>
      <UserProfile />
    </Suspense>
  );
}

async function UserProfile() {
  // This component will stream in when data is ready
  const user = await fetchUser();
  return <div>{user.name}</div>;
}

// Using React Cache for deduplication
import { cache } from 'react';

const getUser = cache(async (id: string) => {
  return db.users.findUnique({ where: { id } });
});

Caching Strategies

Next.js 14 provides multiple caching layers that work together to optimize performance.

// Route Segment Config
export const revalidate = 3600; // Revalidate every hour
export const dynamic = 'force-static'; // Static generation
export const dynamicParams = true; // Allow dynamic params not in generateStaticParams

// fetch API options
async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: {
      revalidate: 60, // ISR: Revalidate every minute
      tags: ['products'], // For on-demand revalidation
    },
    cache: 'force-cache', // Default: Cache unless opted out
  });

  // or for dynamic data
  const dynamicRes = await fetch(`https://api.example.com/live-data/${id}`, {
    cache: 'no-store', // Don't cache - always fresh
  });
}

// On-demand revalidation
export async function revalidateProducts() {
  revalidateTag('products'); // Revalidate all fetch calls with 'products' tag
  revalidatePath('/products'); // Revalidate specific path
}

// Time-based revalidation in layout
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <header>My App</header>
        {children}
        <footer>© 2024</footer>
      </body>
    </html>
  );
}

export const revalidate = 60; // Revalidate entire layout every minute

Streaming and Suspense

Streaming allows sending UI in chunks as they become ready, improving perceived performance.

// Streaming with loading.js
export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<CardSkeleton />}>
        <RevenueChart />
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

// loading.js file automatically wraps page in Suspense
// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
      <div className="space-y-4">
        {[1, 2, 3].map(i => (
          <div key={i} className="h-20 bg-gray-200 rounded"></div>
        ))}
      </div>
    </div>
  );
}

// Streaming server actions
'use server';
export async function submitForm(formData: FormData) {
  // Process form data
  await db.forms.create({ data: { content: formData.get('content') } });
  
  // Revalidate cache
  revalidatePath('/forms');
  
  // Return streamable data
  return { success: true, message: 'Form submitted' };
}

Metadata and SEO

Next.js 14 provides a powerful metadata API for managing SEO and social sharing cards.

// Static metadata
export const metadata: Metadata = {
  title: 'My App',
  description: 'A wonderful application',
  openGraph: {
    title: 'My App',
    description: 'A wonderful application',
    images: ['/og-image.png'],
  },
  twitter: {
    card: 'summary_large_image',
    title: 'My App',
    description: 'A wonderful application',
    images: ['/twitter-image.png'],
  },
};

// Dynamic metadata
export async function generateMetadata({ 
  params, 
  searchParams 
}: {
  params: { id: string };
  searchParams: { [key: string]: string | string[] | undefined };
}): Promise<Metadata> {
  const product = await fetchProduct(params.id);
  
  return {
    title: product.name,
    description: product.description,
    openGraph: {
      images: [product.imageUrl],
    },
  };
}

// Viewport configuration
export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,
  themeColor: '#000000',
};

// JSON-LD structured data
export default function ProductPage({ product }) {
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    image: product.imageUrl,
    description: product.description,
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      {/* Page content */}
    </>
  );
}

Middleware and Authentication

Middleware runs before requests are completed, enabling authentication, redirects, and more.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Check authentication
  const token = request.cookies.get('auth-token');
  const isAuthPage = request.nextUrl.pathname.startsWith('/auth');
  const isProtectedPage = request.nextUrl.pathname.startsWith('/dashboard');

  // Redirect unauthenticated users from protected pages
  if (isProtectedPage && !token) {
    return NextResponse.redirect(new URL('/auth/login', request.url));
  }

  // Redirect authenticated users away from auth pages
  if (isAuthPage && token) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  // Add headers to all responses
  const response = NextResponse.next();
  response.headers.set('x-custom-header', 'my-value');
  
  return response;
}

// Configuring middleware matcher
export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

// API route protection
export async function GET(request: NextRequest) {
  const session = await getSession(request);
  
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  
  return NextResponse.json({ data: 'Protected data' });
}

Performance Optimization

  • Use server components for static content
  • Implement proper caching strategies
  • Optimize images with next/image
  • Code split with dynamic imports
  • Use React.memo for expensive client components
  • Implement proper loading states
  • Monitor Core Web Vitals
  • Use next/script for third-party scripts

Production Deployment Checklist

# Environment variables
NEXT_PUBLIC_API_URL=https://api.example.com
DATABASE_URL=postgresql://...

# next.config.js optimizations
/** @type {import('next').NextConfig} */
const nextConfig = {
  compress: true,
  poweredByHeader: false,
  generateEtags: true,
  swcMinify: true,
  experimental: {
    // Enable production features
    serverActions: {
      bodySizeLimit: '2mb',
    },
  },
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**.example.com',
      },
    ],
  },
};

module.exports = nextConfig;

Common Pitfalls and Solutions

  • Mixing server and client component patterns - solution: clearly separate concerns
  • Over-fetching in server components - solution: use selective data fetching
  • Ignoring loading states - solution: implement proper Suspense boundaries
  • Poor caching strategy - solution: analyze data update frequency
  • Large bundle sizes - solution: analyze with @next/bundle-analyzer
  • Missing error boundaries - solution: implement error.js files