Back to Blog

Next.js Layouts and Pages: Complete Architecture Guide

Introduction: The File-System Router Mental Model

Next.js App Router fundamentally reimagines how we think about routing. Instead of configuring routes through code, the file system becomes the router. This isn't just a convenience—it's an architectural decision that enables automatic code splitting, parallel data fetching, and streaming at the route segment level.

Understanding layouts and pages means understanding how Next.js transforms your folder structure into a component tree, how it optimizes rendering across navigations, and how it handles data flow from server to client.

Core Concepts Visualization

┌─────────────────────────────────────────────────────────────────────┐
│                    ROUTE SEGMENT ARCHITECTURE                        │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  URL: /blog/hello-world                                             │
│                                                                     │
│  Segments:    /           blog           hello-world                │
│              (root)     (segment)      (dynamic segment)            │
│                │            │               │                       │
│                ▼            ▼               ▼                       │
│  Folders:    app/        blog/          [slug]/                     │
│                │            │               │                       │
│                ▼            ▼               ▼                       │
│  Files:   layout.tsx    layout.tsx      page.tsx                    │
│           page.tsx      page.tsx                                    │
│                                                                     │
│  ═══════════════════════════════════════════════════════════════   │
│                                                                     │
│  Component Tree (rendered):                                         │
│                                                                     │
│  <RootLayout>                    ← app/layout.tsx (persists)        │
│    <BlogLayout>                  ← app/blog/layout.tsx (persists)   │
│      <BlogPostPage />            ← app/blog/[slug]/page.tsx         │
│    </BlogLayout>                                                    │
│  </RootLayout>                                                      │
│                                                                     │
│  On navigation /blog/hello-world → /blog/another-post:              │
│  • RootLayout: preserved (no re-render)                             │
│  • BlogLayout: preserved (no re-render)                             │
│  • BlogPostPage: re-rendered with new params                        │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Pages: Route Entry Points

What Makes a Route Accessible

A folder in the app directory only becomes a publicly accessible route when it contains a page.tsx (or page.js) file. Without this file, the folder is purely organizational.

app/
├── dashboard/           # NOT accessible - no page.tsx
│   ├── _components/     # Private folder, never accessible
│   └── settings/
│       └── page.tsx     # /dashboard/settings IS accessible
├── blog/
│   ├── page.tsx         # /blog IS accessible
│   └── [slug]/
│       └── page.tsx     # /blog/:slug IS accessible
└── page.tsx             # / IS accessible

Basic Page Structure

// app/page.tsx - Home page (/)

export default function HomePage() {
  return (
    <main>
      <h1>Welcome to My App</h1>
      <p>This is the home page.</p>
    </main>
  );
}

Pages are Server Components by default. This means:

  • They can be async and fetch data directly
  • They have access to server-only resources (databases, file system, environment secrets)
  • They don't ship JavaScript to the client unless needed
  • They cannot use hooks like useState, useEffect, or browser APIs

Page Props: params and searchParams

Every page component receives two props that provide access to URL information:

// app/products/[category]/[id]/page.tsx

interface PageProps {
  params: Promise<{
    category: string;
    id: string;
  }>;
  searchParams: Promise<{
    [key: string]: string | string[] | undefined;
  }>;
}

export default async function ProductPage({
  params,
  searchParams,
}: PageProps) {
  // Await params - they're now Promises in Next.js 15+
  const { category, id } = await params;
  const { sort, filter } = await searchParams;

  // Fetch data using the params
  const product = await getProduct(category, id);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>Category: {category}</p>
      <p>Sort: {sort ?? 'default'}</p>
    </div>
  );
}

Important: In Next.js 15+, both params and searchParams are Promises that must be awaited. This enables streaming and partial prerendering.

Type-Safe Route Props with PageProps Helper

Next.js auto-generates type helpers based on your route structure:

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

// PageProps is globally available - no import needed
// It infers the correct params type from the route path
export default async function BlogPost(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params;
  // TypeScript knows slug is string

  const post = await getPost(slug);

  return <article>{post.content}</article>;
}

// For catch-all routes
// app/docs/[...slug]/page.tsx
export default async function DocsPage(props: PageProps<'/docs/[...slug]'>) {
  const { slug } = await props.params;
  // TypeScript knows slug is string[]

  return <div>Path: {slug.join('/')}</div>;
}

Async Data Fetching in Pages

Since pages are Server Components, data fetching is straightforward:

// app/users/page.tsx

import { db } from '@/lib/database';
import { UserCard } from './_components/UserCard';

// This runs on the server - no API route needed
export default async function UsersPage() {
  // Direct database access
  const users = await db.user.findMany({
    orderBy: { createdAt: 'desc' },
    take: 20,
  });

  return (
    <div className="users-grid">
      {users.map((user) => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

Metadata Generation

Pages can export metadata for SEO:

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

import { Metadata } from 'next';
import { notFound } from 'next/navigation';

interface Props {
  params: Promise<{ slug: string }>;
}

// Dynamic metadata based on route params
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    return {
      title: 'Post Not Found',
    };
  }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage }],
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author.name],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound();
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Layouts: Persistent UI Wrappers

The Fundamental Behavior

Layouts are the key to Next.js's navigation performance. Unlike pages, layouts persist across navigations between child routes. They:

  • Don't re-render when navigating between child pages
  • Preserve React state (including useState, useReducer)
  • Maintain interactive elements (open modals, scroll position in sidebars)
  • Don't re-fetch data
┌─────────────────────────────────────────────────────────────────────┐
│                    LAYOUT PERSISTENCE                                │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Navigation: /dashboard/analytics → /dashboard/settings             │
│                                                                     │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │  RootLayout (app/layout.tsx)                                 │   │
│  │  ┌─────────────────────────────────────────────────────────┐│   │
│  │  │  DashboardLayout (app/dashboard/layout.tsx)             ││   │
│  │  │  ┌───────────────────┐  ┌─────────────────────────────┐││   │
│  │  │  │                   │  │                             │││   │
│  │  │  │  <Sidebar />      │  │  {children}                 │││   │
│  │  │  │  ─────────────    │  │                             │││   │
│  │  │  │  State preserved  │  │  ┌─────────────────────────┐│││   │
│  │  │  │  No re-render     │  │  │ AnalyticsPage           ││││   │
│  │  │  │                   │  │  │ ──────────────────────  ││││   │
│  │  │  │                   │  │  │ UNMOUNTS                ││││   │
│  │  │  │                   │  │  └─────────────────────────┘│││   │
│  │  │  │                   │  │  ┌─────────────────────────┐│││   │
│  │  │  │                   │  │  │ SettingsPage            ││││   │
│  │  │  │                   │  │  │ ──────────────────────  ││││   │
│  │  │  │                   │  │  │ MOUNTS (new)            ││││   │
│  │  │  │                   │  │  └─────────────────────────┘│││   │
│  │  │  └───────────────────┘  └─────────────────────────────┘││   │
│  │  └─────────────────────────────────────────────────────────┘│   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                     │
│  Only the page component changes - layouts stay mounted             │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Root Layout: The Required Foundation

Every Next.js app must have a root layout at app/layout.tsx. It's the only layout that requires <html> and <body> tags:

// app/layout.tsx

import { Inter } from 'next/font/google';
import { Providers } from './_components/Providers';
import { Analytics } from './_components/Analytics';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

// Root metadata
export const metadata = {
  title: {
    template: '%s | My App',
    default: 'My App',
  },
  description: 'A Next.js application',
  metadataBase: new URL('https://myapp.com'),
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={inter.className}>
      <body>
        <Providers>
          {/* Global UI elements */}
          <header>
            <nav>{/* Navigation */}</nav>
          </header>

          {/* Children = page or nested layout */}
          {children}

          {/* Analytics loaded after hydration */}
          <Analytics />
        </Providers>
      </body>
    </html>
  );
}

Nested Layouts

Each route segment can have its own layout that wraps its children:

// app/dashboard/layout.tsx

import { Sidebar } from './_components/Sidebar';
import { DashboardHeader } from './_components/DashboardHeader';
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  // Server-side auth check
  const session = await auth();

  if (!session) {
    redirect('/login');
  }

  return (
    <div className="dashboard-container">
      <Sidebar user={session.user} />

      <div className="dashboard-main">
        <DashboardHeader user={session.user} />

        <main className="dashboard-content">
          {children}
        </main>
      </div>
    </div>
  );
}

Layout Props with LayoutProps Helper

// app/dashboard/layout.tsx

// LayoutProps is globally available
// Automatically includes children and any parallel route slots
export default function DashboardLayout(
  props: LayoutProps<'/dashboard'>
) {
  return (
    <div>
      <Sidebar />
      <main>{props.children}</main>
    </div>
  );
}

// With parallel routes (slots)
// app/dashboard/layout.tsx with @analytics and @notifications slots
export default function DashboardLayout(
  props: LayoutProps<'/dashboard'>
) {
  return (
    <div>
      <main>{props.children}</main>
      <aside>
        {props.analytics}    {/* From @analytics slot */}
        {props.notifications} {/* From @notifications slot */}
      </aside>
    </div>
  );
}

Layouts Can Access Params (But Not SearchParams)

Layouts receive params but not searchParams. This is intentional—search params change without navigation, and layouts shouldn't re-render for them:

// app/shop/[category]/layout.tsx

interface LayoutProps {
  children: React.ReactNode;
  params: Promise<{ category: string }>;
}

export default async function CategoryLayout({
  children,
  params,
}: LayoutProps) {
  const { category } = await params;

  // Fetch category-specific data for the layout
  const categoryInfo = await getCategoryInfo(category);

  return (
    <div>
      <header>
        <h1>{categoryInfo.name}</h1>
        <p>{categoryInfo.description}</p>
      </header>

      <nav>
        {/* Category-specific navigation */}
        <CategoryFilters category={category} />
      </nav>

      <main>{children}</main>
    </div>
  );
}

Layout vs Template: When to Use Each

Layouts persist across navigations. Templates remount on every navigation:

// app/blog/template.tsx

'use client';

import { motion } from 'framer-motion';

// Templates are useful for:
// 1. Enter/exit animations
// 2. Features that need fresh state on every page
// 3. useEffect that should run on every navigation

export default function BlogTemplate({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <motion.div
      initial={{ opacity: 0, x: 20 }}
      animate={{ opacity: 1, x: 0 }}
      exit={{ opacity: 0, x: -20 }}
      transition={{ duration: 0.3 }}
    >
      {children}
    </motion.div>
  );
}

Decision Framework:

Use Layout WhenUse Template When
Persistent sidebar/navigationPage transition animations
Shared authentication stateAnalytics that should fire per-page
Data that shouldn't refetchForms that should reset on navigation
Interactive elements that persistFeatures requiring fresh component state

Dynamic Segments: Parameterized Routes

Single Dynamic Segment

Wrap folder name in brackets to create a parameter:

// app/users/[id]/page.tsx
// Matches: /users/123, /users/abc, /users/john-doe

interface UserPageProps {
  params: Promise<{ id: string }>;
}

export default async function UserPage({ params }: UserPageProps) {
  const { id } = await params;

  const user = await db.user.findUnique({
    where: { id },
    include: { posts: true },
  });

  if (!user) {
    notFound();
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <PostList posts={user.posts} />
    </div>
  );
}

Multiple Dynamic Segments

// app/[locale]/products/[category]/[id]/page.tsx
// Matches: /en/products/electronics/iphone-15

interface ProductPageProps {
  params: Promise<{
    locale: string;
    category: string;
    id: string;
  }>;
}

export default async function ProductPage({ params }: ProductPageProps) {
  const { locale, category, id } = await params;

  const product = await getProduct(id, locale);
  const categoryInfo = await getCategory(category, locale);

  return (
    <div>
      <Breadcrumb
        items={[
          { label: categoryInfo.name, href: `/${locale}/products/${category}` },
          { label: product.name },
        ]}
      />
      <ProductDetail product={product} />
    </div>
  );
}

Catch-All Segments

// app/docs/[...slug]/page.tsx
// Matches: /docs/intro, /docs/api/users, /docs/guides/getting-started/installation

interface DocsPageProps {
  params: Promise<{ slug: string[] }>;
}

export default async function DocsPage({ params }: DocsPageProps) {
  const { slug } = await params;
  // slug = ['guides', 'getting-started', 'installation']

  const docPath = slug.join('/');
  const doc = await getDocByPath(docPath);

  if (!doc) {
    notFound();
  }

  return (
    <article>
      <DocBreadcrumb segments={slug} />
      <h1>{doc.title}</h1>
      <TableOfContents headings={doc.headings} />
      <MDXContent source={doc.content} />
    </article>
  );
}

// Generate all doc pages at build time
export async function generateStaticParams() {
  const docs = await getAllDocs();

  return docs.map((doc) => ({
    slug: doc.path.split('/'),
  }));
}

Optional Catch-All Segments

// app/[[...slug]]/page.tsx
// Matches: /, /about, /about/team, /about/team/leadership

interface PageProps {
  params: Promise<{ slug?: string[] }>;
}

export default async function CatchAllPage({ params }: PageProps) {
  const { slug } = await params;

  // slug is undefined for root path /
  if (!slug) {
    return <HomePage />;
  }

  // Handle other paths
  const page = await getPageByPath(slug.join('/'));

  if (!page) {
    notFound();
  }

  return <DynamicPage page={page} />;
}

Search Params: Query String Handling

In Server Components (Pages)

// app/products/page.tsx
// URL: /products?category=electronics&sort=price&order=asc

interface ProductsPageProps {
  searchParams: Promise<{
    category?: string;
    sort?: string;
    order?: 'asc' | 'desc';
    page?: string;
  }>;
}

export default async function ProductsPage({
  searchParams,
}: ProductsPageProps) {
  const { category, sort, order, page } = await searchParams;

  const products = await db.product.findMany({
    where: category ? { category } : undefined,
    orderBy: sort ? { [sort]: order ?? 'asc' } : undefined,
    skip: page ? (parseInt(page) - 1) * 20 : 0,
    take: 20,
  });

  return (
    <div>
      <ProductFilters
        currentCategory={category}
        currentSort={sort}
        currentOrder={order}
      />
      <ProductGrid products={products} />
      <Pagination currentPage={parseInt(page ?? '1')} />
    </div>
  );
}

Important: Using searchParams opts the page into dynamic rendering. The page cannot be statically generated because search params are only known at request time.

In Client Components

Use the useSearchParams hook for client-side access:

// app/products/_components/ProductFilters.tsx

'use client';

import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { useCallback } from 'react';

export function ProductFilters() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();

  // Create a new URLSearchParams instance
  const createQueryString = useCallback(
    (name: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      params.set(name, value);
      return params.toString();
    },
    [searchParams]
  );

  const handleCategoryChange = (category: string) => {
    router.push(`${pathname}?${createQueryString('category', category)}`);
  };

  const handleSortChange = (sort: string) => {
    router.push(`${pathname}?${createQueryString('sort', sort)}`);
  };

  return (
    <div className="filters">
      <select
        value={searchParams.get('category') ?? ''}
        onChange={(e) => handleCategoryChange(e.target.value)}
      >
        <option value="">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
      </select>

      <select
        value={searchParams.get('sort') ?? ''}
        onChange={(e) => handleSortChange(e.target.value)}
      >
        <option value="">Default Sort</option>
        <option value="price">Price</option>
        <option value="name">Name</option>
      </select>
    </div>
  );
}

Search Params Decision Framework

┌─────────────────────────────────────────────────────────────────────┐
│                    SEARCH PARAMS DECISION TREE                       │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Do you need search params for data fetching?                       │
│  │                                                                  │
│  ├── YES → Use `searchParams` prop in Server Component page         │
│  │         • Data fetching happens on server                        │
│  │         • Page is dynamically rendered                           │
│  │         • SEO-friendly (URL contains state)                      │
│  │                                                                  │
│  └── NO → Is it for client-only filtering/UI state?                 │
│           │                                                         │
│           ├── YES → Use `useSearchParams()` hook                    │
│           │         • Works in Client Components                    │
│           │         • Triggers re-render on change                  │
│           │         • Good for filtering pre-loaded data            │
│           │                                                         │
│           └── NO → Is it in an event handler?                       │
│                    │                                                │
│                    └── YES → Use `window.location.search`           │
│                              • No re-render triggered               │
│                              • Read-only access                     │
│                              • Good for analytics, one-time reads   │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Linking and Navigation

The <Link> Component

Next.js's <Link> component provides client-side navigation with automatic prefetching:

// app/_components/Navigation.tsx

import Link from 'next/link';

export function Navigation() {
  return (
    <nav>
      {/* Basic link */}
      <Link href="/about">About</Link>

      {/* Dynamic route */}
      <Link href={`/blog/${post.slug}`}>
        {post.title}
      </Link>

      {/* With query params */}
      <Link
        href={{
          pathname: '/products',
          query: { category: 'electronics', sort: 'price' },
        }}
      >
        Electronics
      </Link>

      {/* Replace instead of push (no back button entry) */}
      <Link href="/dashboard" replace>
        Dashboard
      </Link>

      {/* Disable prefetching */}
      <Link href="/large-page" prefetch={false}>
        Large Page
      </Link>

      {/* Scroll to top disabled */}
      <Link href="/long-page#section" scroll={false}>
        Section Link
      </Link>
    </nav>
  );
}
// app/_components/NavLink.tsx

'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';

interface NavLinkProps {
  href: string;
  children: React.ReactNode;
  exact?: boolean;
}

export function NavLink({ href, children, exact = false }: NavLinkProps) {
  const pathname = usePathname();

  const isActive = exact
    ? pathname === href
    : pathname.startsWith(href);

  return (
    <Link
      href={href}
      className={cn(
        'nav-link',
        isActive && 'nav-link-active'
      )}
      aria-current={isActive ? 'page' : undefined}
    >
      {children}
    </Link>
  );
}

Programmatic Navigation

// app/_components/SearchForm.tsx

'use client';

import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';

export function SearchForm() {
  const router = useRouter();
  const [isPending, startTransition] = useTransition();
  const [query, setQuery] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    // Use transition for non-urgent navigation
    startTransition(() => {
      router.push(`/search?q=${encodeURIComponent(query)}`);
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Searching...' : 'Search'}
      </button>
    </form>
  );
}

Router Methods

'use client';

import { useRouter } from 'next/navigation';

export function NavigationExample() {
  const router = useRouter();

  return (
    <div>
      {/* Navigate to a new page */}
      <button onClick={() => router.push('/dashboard')}>
        Go to Dashboard
      </button>

      {/* Replace current history entry */}
      <button onClick={() => router.replace('/login')}>
        Replace with Login
      </button>

      {/* Go back in history */}
      <button onClick={() => router.back()}>
        Go Back
      </button>

      {/* Go forward in history */}
      <button onClick={() => router.forward()}>
        Go Forward
      </button>

      {/* Refresh current route (re-fetch server components) */}
      <button onClick={() => router.refresh()}>
        Refresh Data
      </button>

      {/* Prefetch a route */}
      <button
        onMouseEnter={() => router.prefetch('/heavy-page')}
        onClick={() => router.push('/heavy-page')}
      >
        Heavy Page
      </button>
    </div>
  );
}

Advanced Patterns

Nested Layouts with Shared Data

When multiple layouts need the same data, use React's cache to deduplicate:

// lib/data.ts

import { cache } from 'react';
import { db } from './database';

// Deduplicated across component tree
export const getUser = cache(async (userId: string) => {
  return db.user.findUnique({
    where: { id: userId },
    include: { subscription: true },
  });
});

// app/dashboard/layout.tsx
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getUser(getCurrentUserId());
  // First call - fetches from DB

  return (
    <div>
      <Sidebar user={user} />
      {children}
    </div>
  );
}

// app/dashboard/settings/page.tsx
export default async function SettingsPage() {
  const user = await getUser(getCurrentUserId());
  // Second call - returns cached result (no DB hit)

  return <SettingsForm user={user} />;
}

Conditional Layouts

// app/dashboard/layout.tsx

import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth();

  if (!session) {
    redirect('/login');
  }

  // Different layout based on user role
  if (session.user.role === 'admin') {
    return (
      <div className="admin-layout">
        <AdminSidebar />
        <main>{children}</main>
        <AdminToolbar />
      </div>
    );
  }

  return (
    <div className="user-layout">
      <UserSidebar />
      <main>{children}</main>
    </div>
  );
}

Layout Groups for Different Experiences

app/
├── (marketing)/
│   ├── layout.tsx      # Marketing layout (full-width, promotional)
│   ├── page.tsx        # /
│   ├── pricing/
│   │   └── page.tsx    # /pricing
│   └── about/
│       └── page.tsx    # /about
│
├── (app)/
│   ├── layout.tsx      # App layout (sidebar, authenticated)
│   ├── dashboard/
│   │   └── page.tsx    # /dashboard
│   └── settings/
│       └── page.tsx    # /settings
│
└── (auth)/
    ├── layout.tsx      # Auth layout (centered, minimal)
    ├── login/
    │   └── page.tsx    # /login
    └── register/
        └── page.tsx    # /register

Streaming with Suspense Boundaries

// app/dashboard/page.tsx

import { Suspense } from 'react';
import { RevenueChart, RevenueChartSkeleton } from './_components/RevenueChart';
import { RecentOrders, RecentOrdersSkeleton } from './_components/RecentOrders';
import { QuickStats, QuickStatsSkeleton } from './_components/QuickStats';

export default function DashboardPage() {
  return (
    <div className="dashboard-grid">
      {/* Quick stats load first */}
      <Suspense fallback={<QuickStatsSkeleton />}>
        <QuickStats />
      </Suspense>

      {/* Chart can take longer */}
      <Suspense fallback={<RevenueChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      {/* Orders load independently */}
      <Suspense fallback={<RecentOrdersSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

// Each component fetches its own data
async function QuickStats() {
  const stats = await getQuickStats(); // 100ms
  return <QuickStatsDisplay stats={stats} />;
}

async function RevenueChart() {
  const data = await getRevenueData(); // 500ms
  return <Chart data={data} />;
}

async function RecentOrders() {
  const orders = await getRecentOrders(); // 300ms
  return <OrderList orders={orders} />;
}

Parallel Data Fetching

// app/user/[id]/page.tsx

export default async function UserPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  // Parallel fetching - don't await sequentially!
  const [user, posts, followers] = await Promise.all([
    getUser(id),
    getUserPosts(id),
    getUserFollowers(id),
  ]);

  return (
    <div>
      <UserProfile user={user} />
      <UserPosts posts={posts} />
      <UserFollowers followers={followers} />
    </div>
  );
}

Performance Considerations

Layout Re-rendering Rules

┌─────────────────────────────────────────────────────────────────────┐
│                    WHEN LAYOUTS RE-RENDER                            │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Layouts DO NOT re-render when:                                     │
│  ├── Navigating between child pages                                 │
│  ├── Search params change (?query=new)                              │
│  └── Hash changes (#section)                                        │
│                                                                     │
│  Layouts DO re-render when:                                         │
│  ├── Their own props change (params for dynamic segments)           │
│  ├── Parent layout re-renders                                       │
│  ├── router.refresh() is called                                     │
│  └── revalidatePath/revalidateTag invalidates their data            │
│                                                                     │
│  Best Practices:                                                    │
│  ├── Keep layouts lightweight                                       │
│  ├── Avoid expensive computations in layouts                        │
│  ├── Use Suspense for slow data in layouts                          │
│  └── Don't pass frequently-changing props through layouts           │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Static vs Dynamic Rendering

// Force static generation
export const dynamic = 'force-static';

// Force dynamic rendering
export const dynamic = 'force-dynamic';

// Control revalidation
export const revalidate = 3600; // Revalidate every hour

// Example: Mostly static with some dynamic
// app/products/[id]/page.tsx

export const revalidate = 3600; // ISR: regenerate hourly

export async function generateStaticParams() {
  // Pre-generate top 100 products
  const products = await getTopProducts(100);
  return products.map((p) => ({ id: p.id }));
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);

  return <ProductDetail product={product} />;
}

Key Takeaways

  1. Pages make routes accessible: A folder only becomes a route when it contains page.tsx. Use this for intentional route structure.

  2. Layouts persist, pages don't: Layouts maintain state across navigations. Design your component hierarchy with this in mind.

  3. Root layout is special: It's required, must contain <html> and <body>, and wraps your entire application.

  4. Params are Promises: In Next.js 15+, always await params and searchParams before using them.

  5. SearchParams = dynamic rendering: Using searchParams opts out of static generation. Consider if you really need server-side access.

  6. Use type helpers: PageProps<'/path'> and LayoutProps<'/path'> provide automatic type safety based on your route structure.

  7. Templates for fresh state: When you need components to remount on navigation (animations, forms), use template.tsx instead of layout.tsx.

  8. Parallel fetch, don't waterfall: Use Promise.all() for independent data fetches. Use Suspense for independent streaming.

  9. Route groups organize without URL impact: (groupName) folders create logical groupings and enable multiple layouts at the same URL level.

  10. Link prefetches automatically: <Link> prefetches visible links. Disable with prefetch={false} for rarely-visited routes.

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.