| |

React 93 ⚛️ Next.js Layouts, Pages, and Loading UI

⏱️ Estimated read: 15 min | |

The App Router’s file conventions separate three concerns that the Pages Router merged into a single component: the persistent shell (layout.tsx), the route-specific content (page.tsx), and the pending state while that content resolves (loading.tsx). The separation is what makes navigation fast. When a user moves from /blog to /blog/hello, the layout does not re-render because it has no reason to—it is the same shell. Only the page segment changes, and the loading UI appears in the gap between the click and the data arriving . Understanding when each file mounts, unmounts, and re-renders is the difference between a navigation that feels instant and one that flickers.

The nesting rule is mechanical: every segment can contribute a layout, a page, a loading state, and an error boundary. A layout wraps its segment and all descendants; a page is the leaf that ends the route. A route can have a layout without a page (the segment is not routable but its children are), and a page without a layout (it inherits the nearest ancestor layout). The root layout is mandatory and wraps everything. The loading.tsx file is sugar for a <Suspense> boundary around the page and its children, which means the loading UI appears during navigation and during initial load if the page suspends .

This chapter covers the layout and page nesting model, the persistence behavior that distinguishes layouts from templates, the loading.tsx and <Suspense> relationship, the route group mechanism for sharing layouts across segments, and the streaming behavior that makes partial rendering possible. It assumes familiarity with the App Router structure from the preceding chapter.

Key point: A layout persists across navigation between its children; a page is replaced on every navigation; a loading file is a Suspense fallback that shows while the page’s data resolves. The nesting order is layout → loading → page, and the loading boundary wraps the page and its children, not the layout.


Why layouts, pages, and loading UI are separate files

The Pages Router re-rendered the whole page tree on navigation. A shared header defined in a custom _app.tsx stayed mounted, but any component in the page tree re-rendered when the route changed. The App Router’s layout is a stronger guarantee: it is a separate React tree node that does not re-render when its children change. The layout can hold state, fetch data once, and keep a scroll position, and none of that is discarded when the user navigates to a sibling route .

The page is the leaf that ends the route. A folder without a page.tsx is not routable. The page is what the URL resolves to, and it is replaced on every navigation. A page can be a Server Component that fetches data, or a Client Component that renders interactive UI, but it is always the terminal node in the route tree .

The loading file is the pending state for the route segment. Without a loading.tsx, a navigation to a slow route shows nothing until the server responds. With it, Next.js immediately renders the loading UI as the fallback for an implicit <Suspense> boundary, and replaces it with the page when the data resolves . The boundary is placed around the page, which means the layout stays visible during the wait—the user sees the shell and the loading indicator, not a blank screen .

The trade-off. The separation requires understanding which file is responsible for which behavior. A layout that needs to react to route changes must be a Client Component that reads usePathname, because the layout does not re-render on navigation by default. A page that needs a loading state must either add its own <Suspense> boundary or rely on the segment’s loading.tsx. The conventions are rigid, but the rigidity is what makes the behavior predictable.


a. The nesting model: layout wraps loading wraps page

The App Router composes the files in a fixed order. A layout renders its children, which is the loading boundary, which renders the page. The layout is outside the Suspense boundary, so it remains visible while the page’s data resolves. The loading UI is inside the layout, replacing the page during the wait.

// app/blog/layout.tsx — persistent shell for /blog/*.
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <section>
      <nav>Blog Navigation</nav>
      {children}
    </section>
  );
}

// app/blog/page.tsx — the /blog route content.
export default function BlogPage() {
  return <h1>All Posts</h1>;
}

// app/blog/loading.tsx — shown while the page suspends.
export default function Loading() {
  return <p>Loading posts...</p>;
}

When the user navigates to /blog, the layout renders once, the loading UI appears, and the page replaces it when the data is ready. When the user navigates from /blog to /blog/hello, the layout does not re-render because it is the same tree node. The page is replaced, and the loading UI appears again if the new page suspends .

The loading boundary wraps the page and its children, not the layout. This is why the layout stays visible during the wait. If the layout itself fetches slow data, it must have its own <Suspense> boundary inside the layout, or the entire route blocks until the layout resolves.


b. Layouts vs templates: persistence and re-mounting

A layout.tsx persists across navigation between its children. It does not re-render, and its state is preserved. A template.tsx is the alternative that re-mounts on every navigation. The template renders a new instance of its children on each route change, which is useful for enter/exit animations, useEffect re-runs on navigation, or resetting a form’s state when the segment changes .

// app/blog/template.tsx — re-mounts on every navigation.
export default function BlogTemplate({
  children,
}: {
  children: React.ReactNode;
}) {
  return <div className="animate-fade-in">{children}</div>;
}

The template sits inside the layout and wraps the children. If a segment has both a layout and a template, the layout renders once, and the template re-mounts on each navigation within the segment. The template’s useEffect runs on every mount, which is what an animation library needs to replay the entrance animation .

The choice between layout and template is a choice about persistence. A layout is the right choice for a sidebar, a navigation bar, or a data provider that should not re-fetch on navigation. A template is the right choice for a page transition animation, a scroll reset, or a segment that should reset its state on every visit. The default should be a layout; templates are the exception for behavior that specifically requires re-mounting.


c. Loading UI, Suspense, and streaming

The loading.tsx file is an implicit <Suspense> boundary around the page. It is the simplest way to provide a pending state for a route, but it has a limitation: the entire page is either loading or rendered. If the page has a fast section and a slow section, the loading file shows the fallback for the whole page until the slowest data resolves, and then the whole page appears at once.

The alternative is an explicit <Suspense> boundary inside the page. This is what enables streaming: the fast section renders immediately, and the slow section streams in when its data is ready. The user sees the page progressively, not as a single delayed render .

// app/products/page.tsx — explicit Suspense for streaming.
import { Suspense } from 'react';

export default function ProductsPage() {
  return (
    <section>
      <h1>Products</h1>
      {/* Fast section renders immediately. */}
      <ProductFilters />

      {/* Slow section streams in when data resolves. */}
      <Suspense fallback={<p>Loading products...</p>}>
        <ProductList />
      </Suspense>
    </section>
  );
}

The loading.tsx file and the explicit <Suspense> boundary are not mutually exclusive. The loading file provides a route-level fallback for the initial navigation, and the inline Suspense boundaries provide finer-grained streaming within the page. The loading file is the coarse-grained fallback; the inline boundaries are the fine-grained ones.

A subtlety with the loading file: it does not show for the initial page load if the page is statically rendered and does not suspend. It only shows when the page suspends during navigation or when the page is dynamically rendered and the data has not resolved. For statically rendered pages, the HTML is ready before the browser parses it, so there is no pending state to show .


Complete Example Session

// ============================================
// PART 1: ROOT LAYOUT
// ============================================
// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <header>App Header</header>
        <main>{children}</main>
      </body>
    </html>
  );
}

// ============================================
// PART 2: NESTED LAYOUT
// ============================================
// app/blog/layout.tsx
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <section>
      <nav>Blog Navigation</nav>
      {children}
    </section>
  );
}

// ============================================
// PART 3: PAGE WITHIN THE LAYOUT
// ============================================
// app/blog/page.tsx
export default function BlogPage() {
  return <h1>All Posts</h1>;
}

// ============================================
// PART 4: LOADING UI
// ============================================
// app/blog/loading.tsx
export default function Loading() {
  return <p>Loading posts...</p>;
}

// ============================================
// PART 5: DYNAMIC PAGE WITH SUSPENSE
// ============================================
// app/blog/[slug]/page.tsx
import { Suspense } from 'react';

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

  return (
    <article>
      <h1>{slug}</h1>
      <Suspense fallback={<p>Loading comments...</p>}>
        <Comments slug={slug} />
      </Suspense>
    </article>
  );
}

async function Comments({ slug }: { slug: string }) {
  const res = await fetch(`https://api.example.com/posts/${slug}/comments`);
  const comments = await res.json();
  return (
    <ul>
      {comments.map((c: { id: string; text: string }) => (
        <li key={c.id}>{c.text}</li>
      ))}
    </ul>
  );
}

// ============================================
// PART 6: TEMPLATE FOR RE-MOUNTING
// ============================================
// app/blog/template.tsx
export default function BlogTemplate({
  children,
}: {
  children: React.ReactNode;
}) {
  return <div className="page-enter">{children}</div>;
}

// ============================================
// PART 7: ROUTE GROUPS FOR SHARED LAYOUTS
// ============================================
// app/(shop)/layout.tsx
export default function ShopLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <section>
      <aside>Shop Sidebar</aside>
      {children}
    </section>
  );
}

// app/(shop)/products/page.tsx → /products
// app/(shop)/cart/page.tsx → /cart
// Both share the ShopLayout.

// ============================================
// PART 8: ERROR BOUNDARY
// ============================================
// app/blog/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

// ============================================
// PART 9: NOT FOUND
// ============================================
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return <p>Post not found.</p>;
}

// ============================================
// PART 10: THE COMPLETE FLOW
// ============================================
// 1. Root layout wraps everything.
// 2. Nested layout wraps the segment.
// 3. Loading UI shows during navigation.
// 4. Page renders inside the loading boundary.
// 5. Inline Suspense streams sections.
// 6. Template re-mounts on each navigation.
// 7. Route groups share layouts without URL segments.

Quick Reference

File Conventions

FilePurposePersistence
layout.tsxShared UI for segment and childrenPersists across navigation
template.tsxRe-mounts on navigationRe-mounts every navigation
page.tsxRoute contentReplaced on navigation
loading.tsxSuspense fallback for the segmentShown while page resolves
error.tsxError boundary (Client Component)Replaces segment on error
not-found.tsx404 UI for the segmentShown on notFound()

Nesting Order

LayerBoundary
LayoutOutside Suspense; persists
LoadingImplicit Suspense around page
PageInside loading boundary

Layout vs Template

Aspectlayout.tsxtemplate.tsx
Re-renders on navigationNoYes
State preservedYesNo
useEffect re-runsNoYes
Use casePersistent shellAnimations, resets

Route Groups

PatternURLPurpose
(marketing)/about/page.tsx/aboutShare layout, omit group from URL
(shop)/cart/page.tsx/cartShare shop layout

Best Practices

✅ Do This:

// Use layout.tsx for persistent shells.                      // ✅
// Navigation bar, sidebar, providers.

// Use template.tsx for animations and resets.                // ✅
// Page transitions, scroll reset.

// Use loading.tsx for route-level fallback.                  // ✅
// app/blog/loading.tsx

// Use inline Suspense for fine-grained streaming.            // ✅
<Suspense fallback={<Skeleton />}><Comments /></Suspense>

// Use route groups to share layouts.                         // ✅
// app/(shop)/layout.tsx wraps /products and /cart.

❌ Don’t Do This:

// Put an animation in layout.tsx.                            // ❌
// Layout persists; animation only plays once.

// Put persistent state in template.tsx.                      // ❌
// Template re-mounts; state is lost.

// Use loading.tsx for a fast page section.                   // ❌
// The whole page waits; use inline Suspense.

// Assume loading.tsx shows on statically rendered pages.     // ❌
// HTML is ready before the browser parses it.

Common Pitfalls

PitfallWhy It HappensFix
Layout re-renders on navigationUsed template.tsxUse layout.tsx for persistence
Loading UI does not showPage is statically renderedLoading only shows on suspension
Whole page blocks on slow dataOnly loading.tsx, no inline SuspenseAdd <Suspense> around slow section
Animation does not replayAnimation in layout.tsxMove to template.tsx
Route group appears in URLUsed a folder, not (group)Wrap in parentheses

Real-World Examples

1. Root Layout

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

2. Nested Layout

// app/blog/layout.tsx
export default function BlogLayout({ children }: { children: React.ReactNode }) {
  return <section><nav>Blog</nav>{children}</section>;
}

3. Loading UI

// app/blog/loading.tsx
export default function Loading() { return <p>Loading...</p>; }

4. Template for Animation

// app/blog/template.tsx
export default function Template({ children }: { children: React.ReactNode }) {
  return <div className="fade-in">{children}</div>;
}

5. Inline Suspense

<Suspense fallback={<p>Loading...</p>}><SlowList /></Suspense>

6. Route Group

// app/(shop)/layout.tsx
// app/(shop)/products/page.tsx → /products

7. Error Boundary

// app/blog/error.tsx
'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return <button onClick={reset}>{error.message}</button>;
}

8. Not Found

// app/blog/[slug]/not-found.tsx
export default function NotFound() { return <p>Not found.</p>; }

9. Dynamic Page with Suspense

<Suspense fallback={<CommentsSkeleton />}><Comments slug={slug} /></Suspense>

10. Persistent Provider in Layout

// app/layout.tsx
<ThemeProvider>{children}</ThemeProvider>

Visual: Nesting Model

┌──────────────────────────────────────────────┐
│  ROUTE COMPOSITION                           │
│                                              │
│  app/layout.tsx (root, persists)             │
│    │                                         │
│    ├── app/blog/layout.tsx (persists)        │
│    │     │                                   │
│    │     ├── app/blog/loading.tsx            │
│    │     │     │ (Suspense fallback)         │
│    │     │     ▼                             │
│    │     └── app/blog/page.tsx               │
│    │           (replaced on navigation)      │
│    │                                         │
│    └── app/about/page.tsx                    │
│                                              │
│  Layout renders once.                        │
│  Loading shows while page resolves.          │
│  Page is replaced on every navigation.       │
│                                              │
└──────────────────────────────────────────────┘

Layout vs Template Persistence

┌──────────────────────────────────────────────┐
│  NAVIGATION: /blog/a → /blog/b               │
│                                              │
│  layout.tsx:                                 │
│    Renders once. Not re-rendered.            │
│    State preserved.                          │
│                                              │
│  template.tsx:                               │
│    Re-mounts. New instance.                  │
│    State lost. useEffect re-runs.            │
│                                              │
│  page.tsx:                                   │
│    Replaced. New content.                    │
│                                              │
└──────────────────────────────────────────────┘

Loading vs Inline Suspense

┌──────────────────────────────────────────────┐
│  loading.tsx (COARSE)                        │
│    Whole page blocks.                        │
│    Fast and slow sections wait together.     │
│                                              │
│  inline <Suspense> (FINE)                    │
│    Fast section renders immediately.         │
│    Slow section streams in.                  │
│    User sees progressive content.            │
│                                              │
│  Use loading.tsx for route-level fallback.   │
│  Use inline Suspense for section streaming.  │
│                                              │
└──────────────────────────────────────────────┘

Route Groups

┌──────────────────────────────────────────────┐
│  app/(shop)/layout.tsx                       │
│    │                                         │
│    ├── app/(shop)/products/page.tsx → /products│
│    └── app/(shop)/cart/page.tsx     → /cart  │
│                                              │
│  Both routes share the shop layout.          │
│  The (shop) group does not appear in the URL.│
│                                              │
│  app/(marketing)/about/page.tsx → /about     │
│  Different group, different layout.          │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Layout persistencelayout.tsx does not re-render on navigation
Template re-mounttemplate.tsx re-mounts every navigation
Pagepage.tsx is replaced on every navigation
Loading UIloading.tsx is an implicit Suspense fallback
Nesting orderLayout → loading → page
Loading boundary scopeWraps page and children, not layout
Route group(name) shares layout, omitted from URL
Error boundaryerror.tsx, must be Client Component
Not foundnot-found.tsx, shown on notFound()

Key takeaways:

  • Layouts persist; pages are replaced; loading UI bridges the gap. The layout renders once and does not re-render on navigation. The page is replaced with new content. The loading UI shows while the page’s data resolves, and is replaced when the data arrives .
  • The loading boundary wraps the page, not the layout. The layout stays visible during the wait. This is why the user sees the shell and the loading indicator, not a blank screen .
  • layout.tsx vs template.tsx is a persistence decision. Layouts preserve state and do not re-run effects. Templates re-mount on every navigation, which is needed for animations, scroll resets, and state that should be discarded when the segment changes .
  • loading.tsx is coarse-grained; inline <Suspense> is fine-grained. The loading file shows a fallback for the whole page until all data resolves. Inline Suspense boundaries stream sections independently, so a fast section renders while a slow section loads .
  • The loading file does not show for statically rendered pages. If the HTML is prerendered and the page does not suspend, there is no pending state to display. Loading is for navigation to dynamic pages and for pages that suspend on data .
  • Route groups share layouts without affecting URLs. A folder named (shop) groups routes that share a layout, and the group name is omitted from the URL. This is the mechanism for different layouts in different sections of the app .
  • params is a Promise in Next.js 15+. It must be awaited in Server Components. Client Components use use() or useParams() .

Remember: The App Router’s three file conventions map to three moments in the navigation lifecycle. The layout is what stays; the page is what changes; the loading UI is what shows in between. The layout persists because it is the same tree node across navigations. The page is replaced because it is the route leaf. The loading UI appears because the page suspended and the Suspense boundary caught it. Use layout.tsx for the shell, template.tsx only when re-mounting is required, loading.tsx for the route-level fallback, and inline <Suspense> for the sections that should stream independently. The nesting order is fixed, and understanding it is what makes the navigation feel instant.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!