NextJS DOC
Part 2 of 15Next.js Layouts and Pages: Complete Architecture Guide
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
asyncand 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 When | Use Template When |
|---|---|
| Persistent sidebar/navigation | Page transition animations |
| Shared authentication state | Analytics that should fire per-page |
| Data that shouldn't refetch | Forms that should reset on navigation |
| Interactive elements that persist | Features 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>
);
}
Active Link Styling
// 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
-
Pages make routes accessible: A folder only becomes a route when it contains
page.tsx. Use this for intentional route structure. -
Layouts persist, pages don't: Layouts maintain state across navigations. Design your component hierarchy with this in mind.
-
Root layout is special: It's required, must contain
<html>and<body>, and wraps your entire application. -
Params are Promises: In Next.js 15+, always
awaitparams and searchParams before using them. -
SearchParams = dynamic rendering: Using searchParams opts out of static generation. Consider if you really need server-side access.
-
Use type helpers:
PageProps<'/path'>andLayoutProps<'/path'>provide automatic type safety based on your route structure. -
Templates for fresh state: When you need components to remount on navigation (animations, forms), use
template.tsxinstead oflayout.tsx. -
Parallel fetch, don't waterfall: Use
Promise.all()for independent data fetches. Use Suspense for independent streaming. -
Route groups organize without URL impact:
(groupName)folders create logical groupings and enable multiple layouts at the same URL level. -
Link prefetches automatically:
<Link>prefetches visible links. Disable withprefetch={false}for rarely-visited routes.
What did you think?