Chapter 4

Layout, Template and the Nested Layout System

The Nature of Layouts: Persistent UI Frames

In web applications, some UI is fundamentally "constant" — navigation bars, sidebars, footers, authentication contexts. Re-rendering these components on every page navigation is wasteful: they don't need to remount, don't need to re-request data, and don't need to replay animations.

This is the design intent of layout.tsx: it does not re-render when routes change within its scope.

How does Next.js achieve this? Through React's reconciliation mechanism. When a route transitions from /dashboard to /dashboard/settings, Next.js identifies that both routes share the same layout component and preserves that component's React instance intact. Only the children subtree is replaced. From the DOM's perspective, the layout's nodes are never removed or recreated.

This mechanism has an important implication: state inside a layout — useState, useRef, refs to animations — persists across navigation. If a sidebar in a layout is in an expanded state, it remains expanded when the user navigates between pages. This is not a side effect; it is the intended behavior.

The Root Layout: The Application Shell

app/layout.tsx is the root layout for the entire application and the only required convention file in App Router. It has a special responsibility: it must render the <html> and <body> tags, because Next.js does not add them automatically.

// app/layout.tsx — The canonical root layout
import type { Metadata } from 'next';
import { Inter, Noto_Sans_SC } from 'next/font/google';
import { Providers } from './providers';
import './globals.css';

// next/font downloads fonts at build time and generates CSS variables.
// Fonts are self-hosted by Next.js — no runtime requests to Google CDN.
const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
  display: 'swap',
});

const notoSansSC = Noto_Sans_SC({
  subsets: ['chinese-simplified'],
  weight: ['400', '500', '700'],
  variable: '--font-noto',
  display: 'swap',
});

export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: {
    template: '%s | My App',
    default: 'My App',
  },
  description: 'A Next.js 15 full-stack application',
  robots: {
    index: true,
    follow: true,
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={`${inter.variable} ${notoSansSC.variable}`}>
      <body>
        {/* Providers is a client component wrapping context-dependent children */}
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Several important characteristics of the root layout deserve attention.

It is a server component by default. Unless you add 'use client', the root layout runs on the server. This means you can perform server-side operations directly — reading sessions, fetching global configuration, accessing environment variables.

Font optimization is built in. next/font/google downloads font files at build time and serves them from Next.js's own infrastructure. This eliminates runtime external requests to Google CDN, improving load performance and user privacy (no third-party request leakage).

Metadata templates cascade. The %s in title.template is replaced by the title field in any child page's metadata export. A page with metadata.title = 'Dashboard' renders as <title>Dashboard | My App</title>. If no child defines a title, the default value is used.

Nested Layouts: Composition Over Inheritance

Next.js's layout system is hierarchically nested. Any directory can have its own layout.tsx, and child directory layouts nest inside parent directory layouts:

app/
├── layout.tsx               # Level 1: Root (html, body, global nav)
├── (app)/
│   └── layout.tsx           # Level 2: App shell (sidebar, auth guard)
│       └── dashboard/
│           └── layout.tsx   # Level 3: Dashboard (dashboard-specific header)
│               └── page.tsx # Level 4: Page content

When visiting /dashboard, the render tree is:

RootLayout
  └── AppLayout
        └── DashboardLayout
              └── DashboardPage

Each layout layer is concerned only with its own UI. It knows nothing about the implementation of inner layers. This is a textbook application of the composition pattern: each layer is independent, replaceable, and the overall complexity is distributed rather than concentrated.

// app/(app)/layout.tsx — Auth guard + application shell
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';
import { Sidebar } from '@/components/Sidebar';
import { TopBar } from '@/components/TopBar';

export default async function AppLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  // Authentication check at the layout level.
  // All child routes are automatically protected.
  const session = await getSession();
  if (!session) {
    redirect('/login');
  }

  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar user={session.user} />
      <div className="flex flex-col flex-1 overflow-hidden">
        <TopBar user={session.user} />
        <main className="flex-1 overflow-y-auto p-6">
          {children}
        </main>
      </div>
    </div>
  );
}
// app/(app)/dashboard/layout.tsx — Dashboard-specific layout
import { DashboardHeader } from './DashboardHeader';
import { DateRangePicker } from './DateRangePicker';

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div>
      <DashboardHeader>
        {/* Date picker only in the dashboard section */}
        <DateRangePicker />
      </DashboardHeader>
      <div className="mt-6">{children}</div>
    </div>
  );
}

The key value of nested layouts is that authentication logic, session reading, and layout-level state management happen at the most appropriate layer and are never repeated. /dashboard/settings, /dashboard/reports, and /dashboard/users are all automatically protected by (app)/layout.tsx without any per-page implementation.

Layouts vs. Pages Router's _app.tsx: A Fundamental Difference

The best way to understand App Router layouts is to contrast them with Pages Router's _app.tsx:

_app.tsx is a global singleton. All pages share the same _app.tsx. You cannot have different layout logic for different route sections without conditionals inside _app.tsx — an approach that becomes unmanageable quickly.

_app.tsx re-renders on every navigation. While React reconciliation tries to preserve DOM nodes, _app.tsx as a whole re-renders on route changes, including all its descendants. Components inside need to be carefully memoized to avoid unnecessary work.

App Router layouts are truly persistent. A layout.tsx is not "efficiently re-rendered" on sibling route navigation — it is not rendered at all. Its React instance is unchanged. Its DOM is unchanged. Its state is unchanged.

App Router layouts are composable. Different route sections have their own layouts, assembled through nesting rather than conditionals. Any layer can have its own data fetching and state.

The performance difference is meaningful. A Pages Router application with a nav bar, sidebar, and notification center inside _app.tsx re-renders all three on every route change. In App Router, those components are never recreated — the DOM doesn't change, state is preserved, animations don't interrupt.

Template: The Layout That Deliberately Remounts

template.tsx is a variant of layout.tsx with one crucial behavioral difference: every navigation to that route creates a new instance of the template.

app/
└── dashboard/
    ├── layout.tsx     # Persistent: does NOT remount on navigation
    ├── template.tsx   # Template: DOES remount on every navigation
    └── page.tsx

This seems like a regression — why deliberately choose remounting? Because there are scenarios where per-navigation remounting is exactly the required behavior.

Page enter animations. If you want a fade-in or slide-up animation every time a user enters any page in a section, layout.tsx won't work because it never remounts. template.tsx creates a new instance on each navigation, so the animation runs every time:

// app/dashboard/template.tsx
'use client';

import { motion } from 'framer-motion';

export default function DashboardTemplate({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 8 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.2, ease: 'easeOut' }}
    >
      {children}
    </motion.div>
  );
}

Per-visit state resets. Some UIs must reset on each visit — a multi-step form should restart from step one if the user navigates away and returns. State in a layout persists; state in a template resets every time the user enters the route.

Page-level analytics tracking. useEffect in a layout fires once when the layout mounts, not on subsequent sibling navigations. If you need to fire an event on every route entry — page view tracking, feature exposure logging — a template is the right tool:

// app/(app)/template.tsx
'use client';

import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { trackPageView } from '@/lib/analytics';

export default function AppTemplate({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();

  useEffect(() => {
    // Fires on every navigation within protected routes
    trackPageView(pathname);
  }, [pathname]);

  return <>{children}</>;
}

Layout and template can coexist in the same directory. The rendering order is outer to inner: layout → template → page. The layout provides the persistent shell; the template provides the per-visit behavior; the page provides the content.

Shared State in Layouts: The Context Pattern

Layouts are server components by default, which means they cannot directly hold React state. But they can wrap client-side Context Providers — and this is the standard pattern for sharing global state across the application:

// app/providers.tsx — Centralized client-side context management
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './theme-provider';
import { ToastProvider } from './toast-provider';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  // QueryClient must be created on the client side to prevent
  // state from being shared across server requests
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
        retry: 1,
      },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider defaultTheme="system" storageKey="app-theme">
        <ToastProvider>
          {children}
        </ToastProvider>
      </ThemeProvider>
    </QueryClientProvider>
  );
}
// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

There is a subtle but important point here: Providers is a client component, but children passed to it can still be server components. React handles this through the "children as a slot" model — the server renders its components first, producing a pre-rendered React tree, and passes that tree as the children prop to Providers. Providers renders its context without re-rendering the children. This means wrapping server components in a client Provider does not force those server components to become client components.

This is a critical performance pattern in App Router: wrap server components in client components when you need context, rather than converting server components to client components.

The Navigation Bar + Auth Context Pattern

Combining everything from this chapter into a complete, production-ready auth and navigation architecture:

// app/(app)/layout.tsx — Application shell with auth
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { SessionProvider } from '@/components/SessionProvider';
import { NavBar } from '@/components/NavBar';

export default async function AppLayout({ children }: { children: React.ReactNode }) {
  const session = await getSession();
  if (!session) redirect('/login');

  return (
    <SessionProvider initialSession={session}>
      <NavBar />
      <div className="pt-16">
        {children}
      </div>
    </SessionProvider>
  );
}
// components/SessionProvider.tsx
'use client';

import { createContext, useContext } from 'react';
import type { Session } from '@/lib/auth';

const SessionContext = createContext<Session | null>(null);

export function SessionProvider({
  initialSession,
  children,
}: {
  initialSession: Session;
  children: React.ReactNode;
}) {
  return (
    <SessionContext.Provider value={initialSession}>
      {children}
    </SessionContext.Provider>
  );
}

export function useSession(): Session {
  const session = useContext(SessionContext);
  if (!session) throw new Error('useSession must be used within SessionProvider');
  return session;
}
// components/NavBar.tsx — Client component, consumes session context
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useSession } from './SessionProvider';
import { UserMenu } from './UserMenu';

const NAV_LINKS = [
  { href: '/dashboard', label: 'Dashboard' },
  { href: '/dashboard/reports', label: 'Reports' },
  { href: '/dashboard/settings', label: 'Settings' },
];

export function NavBar() {
  const { user } = useSession();
  const pathname = usePathname();

  return (
    <nav className="fixed top-0 w-full h-16 border-b bg-white flex items-center px-6 z-50">
      <Link href="/dashboard" className="font-bold text-xl mr-8">
        MyApp
      </Link>

      <div className="flex gap-1">
        {NAV_LINKS.map(link => (
          <Link
            key={link.href}
            href={link.href}
            className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
              pathname.startsWith(link.href)
                ? 'bg-gray-100 text-gray-900'
                : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
            }`}
          >
            {link.label}
          </Link>
        ))}
      </div>

      <div className="ml-auto flex items-center gap-4">
        <span className="text-sm text-gray-500">{user.email}</span>
        <UserMenu user={user} />
      </div>
    </nav>
  );
}

This architecture achieves several goals simultaneously:

The session is read once on the server, in the layout, using a direct server-side function call with no HTTP overhead. It is passed to SessionProvider as the initialSession prop, which distributes it to all client components via context. Every client component in the application that needs user data calls useSession() — no prop drilling, no redundant server calls.

The nav bar renders with active state based on the current pathname, determined at runtime using usePathname(). Since the nav bar is inside the persistent layout, it never remounts on navigation — active states update reactively.

Authentication is enforced at the layout level. Adding a new protected route under (app)/ requires zero additional auth code. The layout's redirect('/login') handles all unauthenticated access automatically.

Summary

Layout's core value is persistence across navigation — layout components do not remount when sibling routes change, state is preserved, and performance is excellent. Nested layouts distribute UI concerns across layers: authentication, global state, and section-specific layout each occupy the right level. Template provides the deliberate remounting alternative — essential for page entry animations, per-visit state resets, and accurate analytics tracking. The combination of server layouts and client Context Providers allows global state to be initialized on the server and shared through the client component tree, without sacrificing server rendering for either layer. This is the canonical architectural pattern for App Router applications.

Rate this chapter
4.5  / 5  (72 ratings)

💬 Comments