React 93 ⚛️ Next.js Layouts, Pages, and Loading UI
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
| File | Purpose | Persistence |
|---|---|---|
layout.tsx | Shared UI for segment and children | Persists across navigation |
template.tsx | Re-mounts on navigation | Re-mounts every navigation |
page.tsx | Route content | Replaced on navigation |
loading.tsx | Suspense fallback for the segment | Shown while page resolves |
error.tsx | Error boundary (Client Component) | Replaces segment on error |
not-found.tsx | 404 UI for the segment | Shown on notFound() |
Nesting Order
| Layer | Boundary |
|---|---|
| Layout | Outside Suspense; persists |
| Loading | Implicit Suspense around page |
| Page | Inside loading boundary |
Layout vs Template
| Aspect | layout.tsx | template.tsx |
|---|---|---|
| Re-renders on navigation | No | Yes |
| State preserved | Yes | No |
useEffect re-runs | No | Yes |
| Use case | Persistent shell | Animations, resets |
Route Groups
| Pattern | URL | Purpose |
|---|---|---|
(marketing)/about/page.tsx | /about | Share layout, omit group from URL |
(shop)/cart/page.tsx | /cart | Share 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Layout re-renders on navigation | Used template.tsx | Use layout.tsx for persistence |
| Loading UI does not show | Page is statically rendered | Loading only shows on suspension |
| Whole page blocks on slow data | Only loading.tsx, no inline Suspense | Add <Suspense> around slow section |
| Animation does not replay | Animation in layout.tsx | Move to template.tsx |
| Route group appears in URL | Used 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
| Item | Value |
|---|---|
| Layout persistence | layout.tsx does not re-render on navigation |
| Template re-mount | template.tsx re-mounts every navigation |
| Page | page.tsx is replaced on every navigation |
| Loading UI | loading.tsx is an implicit Suspense fallback |
| Nesting order | Layout → loading → page |
| Loading boundary scope | Wraps page and children, not layout |
| Route group | (name) shares layout, omitted from URL |
| Error boundary | error.tsx, must be Client Component |
| Not found | not-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.tsxvstemplate.tsxis 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.tsxis 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 . paramsis a Promise in Next.js 15+. It must be awaited in Server Components. Client Components useuse()oruseParams().
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.
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!