Layouts, Loading States and Error Boundaries
IntermediateA layout persists across navigation while its children change. loading.tsx and error.tsx are Suspense and error boundaries you get by naming a file.
Overview
Layouts are the reason App Router navigation feels like an app rather than a website: the header, the sidebar and their state survive a route change because only the inner segment re-renders. Alongside them, two reserved filenames give you infrastructure that would otherwise be hand-wired — `loading.tsx` becomes a Suspense fallback for the segment and `error.tsx` becomes an error boundary around it. The pay-off is that a slow or broken route degrades locally instead of blanking the application.
Nested Layouts
They persist, they nest, and they do not re-render on navigation.
// app/layout.tsx — the root. Required, and must render html and body.
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Header />
{children}
<Footer />
</body>
</html>
)
}
// app/(app)/dashboard/layout.tsx — nests inside the root
export default function DashboardLayout({ children }) {
return <div className="grid grid-cols-[240px_1fr]"><Sidebar />{children}</div>
}
// Navigating /dashboard -> /dashboard/billing re-renders only the
// page. The sidebar is NOT re-rendered, so its scroll position, its
// open sections and any client state inside it survive.
// The corollary that surprises people: a layout does not re-run on
// navigation between its children, so data fetched in a layout is
// NOT refreshed by moving between them. Fetch per-page data in the
// page.
// template.tsx is the escape hatch — same shape, but a fresh instance
// on every navigation. Use it when you want an enter animation or
// state reset per route.loading.tsx
A Suspense boundary named by convention, and where to put it.
// app/problems/loading.tsx
export default function Loading() {
return <ProblemsSkeleton /> // matches the real layout
}
// Next wraps the segment in <Suspense fallback={<Loading/>}>, so the
// shell — header, sidebar, anything above — renders immediately and
// this segment streams in. The user sees structure at once.
// Granularity is the decision. One loading.tsx at the top means the
// whole page waits for the slowest query. Suspense in the page gives
// each slow part its own boundary:
export default async function Page() {
return (
<>
<ProblemHeader /> {/* fast */}
<Suspense fallback={<StatsSkeleton />}>
<SlowStats /> {/* streams */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* streams */}
</Suspense>
</>
)
}
// A skeleton must match the final layout, or the content lands and
// shifts the page — trading a wait for a layout shift.error.tsx and not-found.tsx
Failures contained to a segment, with a way back.
// app/problems/error.tsx — MUST be a client component
'use client'
export default function Error({ error, reset }) {
useEffect(() => { reportError(error) }, [error])
return (
<div role="alert">
<p>We could not load the problems.</p>
<button onClick={reset}>Try again</button> {/* re-render */}
<Link href="/">Go home</Link>
</div>
)
}
// It catches errors from this segment and below. The layout above it
// stays rendered, so the user keeps the header and the navigation.
// An error in the ROOT layout is not caught here — that needs
// global-error.tsx, which must render its own html and body.
// In production the error object is redacted to a message and a
// digest; the stack is in your server logs, matched by that digest.
// not-found.tsx and the notFound() helper
import { notFound } from 'next/navigation'
const problem = await getProblem(slug)
if (!problem) notFound() // renders the nearest not-found.tsx
// and sends a real 404 status
// redirect() works the same way and also throws, so neither needs a
// return after it — but both must be called OUTSIDE a try/catch, or
// your catch swallows the control-flow exception.Key Points to Remember
- 1Layouts persist across navigation between their children, so their client state and scroll position survive
- 2Because a layout does not re-run on those navigations, per-page data belongs in the page, not the layout
- 3loading.tsx is a Suspense fallback for the segment; finer Suspense boundaries inside a page stream each slow part separately
- 4error.tsx must be a client component, contains failures to its segment, and receives a reset function
- 5notFound() and redirect() work by throwing, so calling them inside a try/catch swallows them
Interview Questions
Sign in to ask AriaWhat is the difference between layout.tsx and template.tsx?
Why does data fetched in a layout not refresh when navigating between its child pages?
Why must error.tsx be a client component?
Ask Aria about Layouts, Loading States and Error Boundaries
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.