Type something to search...
Nested Layouts in Next.js: Sharing UI Across Routes Without Re-rendering

Nested Layouts in Next.js: Sharing UI Across Routes Without Re-rendering

Most apps have UI that repeats across many pages: a top navigation bar, a sidebar inside the dashboard, a row of tabs inside settings. In a traditional React SPA you'd wrap routes in layout components by hand. In the Pages Router you'd reach for _app.tsx and the getLayout pattern. Either way, it was easy to end up with a sidebar that unmounted and remounted on every click, losing its scroll position and any state inside it.

The App Router makes layouts part of the routing system. Put a layout.tsx in a folder and it wraps every route beneath it. Put another one in a subfolder and it nests inside the first. When the user navigates between sibling pages, the layouts they share stay mounted, and only the part of the tree that actually changed is re-rendered.

This post explains how nested layouts compose, what "without re-rendering" actually means in practice, how to fetch data in a layout, how to highlight the active link when a layout can't see the pathname, and the limitations you'll run into.

How Layouts Nest

Every folder in app is a route segment, and each segment can have a layout.tsx. Consider this structure:

app/
├── layout.tsx                    # root layout
├── page.tsx                      # /
└── dashboard/
    ├── layout.tsx                # dashboard layout (sidebar)
    ├── page.tsx                  # /dashboard
    ├── projects/
    │   └── page.tsx              # /dashboard/projects
    └── settings/
        ├── layout.tsx            # settings layout (tabs)
        ├── profile/
        │   └── page.tsx          # /dashboard/settings/profile
        └── billing/
            └── page.tsx          # /dashboard/settings/billing

For /dashboard/settings/billing, Next.js renders the layouts along the path from the root down, each one receiving the next level as children:

// Conceptual output for /dashboard/settings/billing
<RootLayout>
  <DashboardLayout>
    <SettingsLayout>
      <BillingPage />
    </SettingsLayout>
  </DashboardLayout>
</RootLayout>

There's no configuration involved. The folder structure is the layout structure. A layout applies to its own segment and everything below it, and never to siblings or parents.

Building the Layouts

The root layout defines the document. It's the only layout that renders html and body:

// app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";

export const metadata: Metadata = {
  title: { default: "Acme", template: "%s | Acme" },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="min-h-screen bg-slate-50 text-slate-900">
        <header className="border-b bg-white px-6 py-4">
          <Link href="/" className="font-semibold">
            Acme
          </Link>
        </header>
        {children}
      </body>
    </html>
  );
}

The dashboard layout adds a sidebar:

// app/dashboard/layout.tsx
import { SidebarNav } from "./sidebar-nav";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <aside className="w-60 shrink-0 border-r bg-white p-4">
        <SidebarNav />
      </aside>
      <main className="flex-1 p-8">{children}</main>
    </div>
  );
}

The settings layout adds tabs and a heading shared by every settings page:

// app/dashboard/settings/layout.tsx
import type { Metadata } from "next";
import { SettingsTabs } from "./settings-tabs";

export const metadata: Metadata = {
  title: { default: "Settings", template: "%s · Settings | Acme" },
};

export default function SettingsLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <section className="max-w-3xl">
      <h1 className="mb-4 text-2xl font-semibold">Settings</h1>
      <SettingsTabs />
      <div className="mt-6">{children}</div>
    </section>
  );
}

And the pages only contain what's unique to them:

// app/dashboard/settings/billing/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = { title: "Billing" };

export default function BillingPage() {
  return <p>Your plan renews on the 1st of each month.</p>;
}

Metadata nests too. The billing page sets title: "Billing", and the closest parent template, from the settings layout, turns it into "Billing · Settings | Acme". A title.template applies to child segments, not to the segment that defines it, which is why the settings layout also needs a title.default.

What "Without Re-rendering" Means

When you navigate with Link from /dashboard/settings/profile to /dashboard/settings/billing, the root, dashboard, and settings layouts are all shared between the two URLs. Next.js keeps them mounted and swaps only the page. This is called partial rendering, and it has three practical effects.

Less work on the server. Next.js only needs to render the segments that changed. Shared layouts aren't rendered again for that navigation.

Less data over the wire. The client already has the shared layouts, so only the new page's payload is fetched. In Next.js 16, prefetching also deduplicates layouts: if several links on screen share a layout, it's downloaded once.

Client state in layouts survives. This is the one users notice. Any Client Component inside a shared layout keeps its React state and DOM state: a collapsed sidebar stays collapsed, a search box keeps its text, a scrolled navigation list keeps its scroll position, an open dropdown stays open.

Here's a sidebar with a collapsible section to see that in action:

// app/dashboard/sidebar-nav.tsx
"use client";

import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";

const projectLinks = [
  { href: "/dashboard/projects", label: "All projects" },
  { href: "/dashboard/projects/archived", label: "Archived" },
];

export function SidebarNav() {
  const pathname = usePathname();
  const [projectsOpen, setProjectsOpen] = useState(true);

  const linkClass = (href: string) =>
    pathname === href
      ? "block rounded bg-slate-100 px-3 py-2 font-medium"
      : "block rounded px-3 py-2 text-slate-600 hover:bg-slate-50";

  return (
    <nav className="space-y-1 text-sm">
      <Link href="/dashboard" className={linkClass("/dashboard")}>
        Overview
      </Link>

      <button
        type="button"
        onClick={() => setProjectsOpen((open) => !open)}
        className="w-full px-3 py-2 text-left text-slate-600"
        aria-expanded={projectsOpen}
      >
        Projects {projectsOpen ? "▾" : "▸"}
      </button>
      {projectsOpen && (
        <div className="pl-3">
          {projectLinks.map((link) => (
            <Link
              key={link.href}
              href={link.href}
              className={linkClass(link.href)}
            >
              {link.label}
            </Link>
          ))}
        </div>
      )}

      <Link
        href="/dashboard/settings/profile"
        className={linkClass("/dashboard/settings/profile")}
      >
        Settings
      </Link>
    </nav>
  );
}

Collapse "Projects", then click "Settings". The sidebar doesn't remount, so the section stays collapsed. With a hand-rolled layout component rendered inside each page, it would reset to open on every navigation.

When Layouts Do Render Again

"Layouts don't re-render" is true for navigations between pages below them. A few things do cause a layout to render on the server again:

  • A full page load (refresh, typing a URL, or navigating across different root layouts).
  • Calling router.refresh(), which re-renders the current route on the server.
  • A Server Action that calls revalidatePath or revalidateTag for data the layout uses.
  • Navigating to a URL where the layout's own dynamic segment changes. Going from /teams/a/members to /teams/b/members renders app/teams/[team]/layout.tsx again with the new team param.

Even in these cases, re-rendering on the server isn't the same as remounting on the client. When a layout's server output is refreshed, React reconciles it into the existing tree, so Client Components inside it keep their state unless their position or key changes.

If you want the opposite behavior, where something resets on every navigation, a layout is the wrong file. That's what template.tsx is for, and it's covered in the post on templates vs layouts.

Layouts Can't See the Pathname or Search Params

Because a layout isn't rendered again when you move between its child pages, it doesn't receive the current pathname or search params. If it did, those values would go stale the moment the user navigated. So layout.tsx gets children and params (for its own dynamic segments and the ones above it), but not searchParams, and there's no pathname prop.

The solution is to read those values in a Client Component rendered by the layout. Client Components do re-render on navigation, so hooks like usePathname and useSearchParams always return current values. That's why the SidebarNav above is a Client Component.

For tabs inside a nested layout, useSelectedLayoutSegment is often cleaner than comparing full pathnames:

// app/dashboard/settings/settings-tabs.tsx
"use client";

import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";

const tabs = [
  { segment: "profile", label: "Profile" },
  { segment: "billing", label: "Billing" },
  { segment: "notifications", label: "Notifications" },
];

export function SettingsTabs() {
  const active = useSelectedLayoutSegment();

  return (
    <nav className="flex gap-6 border-b text-sm">
      {tabs.map((tab) => (
        <Link
          key={tab.segment}
          href={`/dashboard/settings/${tab.segment}`}
          aria-current={active === tab.segment ? "page" : undefined}
          className={
            active === tab.segment
              ? "-mb-px border-b-2 border-slate-900 pb-2 font-medium"
              : "pb-2 text-slate-500"
          }
        >
          {tab.label}
        </Link>
      ))}
    </nav>
  );
}

useSelectedLayoutSegment() returns the active segment one level below the layout that renders the component. Inside the settings layout, that's "profile", "billing", or "notifications". The tabs component doesn't need to know the full URL, so you could move the settings section elsewhere without touching it.

Keep these client components small. The layout itself stays a Server Component, and only the navigation piece ships JavaScript.

Fetching Data in Layouts

Layouts are Server Components by default, so they can be async and fetch data:

// app/dashboard/layout.tsx
import { getCurrentUser } from "@/lib/auth";
import { SidebarNav } from "./sidebar-nav";

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

  return (
    <div className="flex">
      <aside className="w-60 shrink-0 border-r bg-white p-4">
        <p className="mb-4 text-sm text-slate-500">
          Signed in as {user?.name ?? "guest"}
        </p>
        <SidebarNav />
      </aside>
      <main className="flex-1 p-8">{children}</main>
    </div>
  );
}

There are two rules to keep in mind.

Layouts Can't Pass Data to Their Children

children is already-rendered output, not a component you call with props. If the billing page also needs the current user, it fetches the user itself. That sounds wasteful, but it isn't, as long as the function is deduplicated. fetch calls with the same URL and options are deduplicated within a request automatically. For anything else (an ORM, an SDK), wrap the function in React's cache:

// lib/auth.ts
import { cache } from "react";
import { cookies } from "next/headers";
import { db } from "@/lib/db";

export const getCurrentUser = cache(async () => {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get("session")?.value;
  if (!sessionId) return null;

  const session = await db.session.findUnique({
    where: { id: sessionId },
    include: { user: true },
  });
  return session?.user ?? null;
});

Now the layout and any page under it can call getCurrentUser() and the database is only queried once per request. Each component declares the data it needs, which keeps them independent.

Slow Layout Data Blocks Everything Below It

A layout wraps its children, including their loading.tsx. If the dashboard layout awaits a slow query, nothing under it can show, not even the loading skeleton of the page, because loading.tsx sits below the layout in the component tree.

Without Cache Components, navigation blocks until the layout finishes. With Cache Components enabled, Next.js requires uncached data access in a layout to be wrapped in its own Suspense boundary and tells you at build time if it isn't.

The fix is the same either way: push slow work into a component and wrap it in Suspense, so the layout's shell renders immediately.

// app/dashboard/layout.tsx
import { Suspense } from "react";
import { SidebarNav } from "./sidebar-nav";
import { UserBadge } from "./user-badge";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <aside className="w-60 shrink-0 border-r bg-white p-4">
        <Suspense
          fallback={
            <div className="mb-4 h-5 w-32 animate-pulse rounded bg-slate-200" />
          }
        >
          <UserBadge />
        </Suspense>
        <SidebarNav />
      </aside>
      <main className="flex-1 p-8">{children}</main>
    </div>
  );
}
// app/dashboard/user-badge.tsx
import { getCurrentUser } from "@/lib/auth";

export async function UserBadge() {
  const user = await getCurrentUser();
  return (
    <p className="mb-4 text-sm text-slate-500">
      Signed in as {user?.name ?? "guest"}
    </p>
  );
}

The layout is no longer async. The sidebar, navigation, and page loading state all appear right away, and the user's name streams in when it's ready.

Layouts with Dynamic Segments

A layout inside a dynamic segment receives params for that segment and the ones above it:

// app/teams/[team]/layout.tsx
import { notFound } from "next/navigation";
import { getTeam } from "@/lib/teams";

export default async function TeamLayout({
  children,
  params,
}: LayoutProps<"/teams/[team]">) {
  const { team: slug } = await params;
  const team = await getTeam(slug);
  if (!team) notFound();

  return (
    <div>
      <header className="border-b px-8 py-4">
        <h1 className="text-xl font-semibold">{team.name}</h1>
      </header>
      {children}
    </div>
  );
}

LayoutProps<"/teams/[team]"> is a generated global type that knows this layout's params and any parallel route slots. Navigating between /teams/acme/members and /teams/acme/projects keeps the team layout mounted; switching to /teams/globex/members renders it again with the new team.

If you use Cache Components and the team param isn't covered by generateStaticParams, awaiting params at the top of the layout makes the whole layout depend on request data. To keep a static shell, render the static parts directly and move the param-dependent part into a child component inside a Suspense boundary.

Common Mistakes

Putting interactive state in a page when it belongs in the layout. If a filter panel or sidebar should survive navigation between sibling pages, render it from the shared layout, not from each page.

Making the whole layout a Client Component. Adding "use client" to layout.tsx to use usePathname turns everything it imports into client code. Extract the part that needs the hook instead.

Trying to read searchParams in a layout. It isn't passed to layouts. Read it in the page, or with useSearchParams in a Client Component.

Expecting a layout to re-run its effects on every navigation. It won't, because it doesn't remount. If you need per-navigation effects (a page view event, an animation), put that logic in a Client Component that watches usePathname, or use a template.

Duplicating layout markup in pages. If three sibling pages repeat the same header, that header belongs in a layout. Use route groups when only some siblings should share it.

Conclusion

Nested layouts let the folder structure describe your shared UI. Each layout.tsx wraps everything below it, layouts compose from the root down, and on navigation Next.js keeps shared layouts mounted and renders only the segments that changed. That gives you faster navigations, smaller payloads, and UI state that survives clicks.

The constraints follow from that design: layouts don't receive the pathname or search params, they can't pass data to children, and slow data at the top of a layout holds up everything below it. Read URL state in small Client Components, deduplicate shared data with cache, and wrap slow layout data in Suspense, and nested layouts will do most of the work of building a fast, stable app shell for you.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading