Home/Learn/Next.js/Layouts, Loading States and Error Boundaries

Layouts, Loading States and Error Boundaries

Intermediate
Foundations

A 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.

Layouts persist; templates remount
// 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.

Per-segment fallback, or per-boundary in the page
// 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.

error.tsx is client-side; notFound() sends a real 404
// 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 Aria
1

What is the difference between layout.tsx and template.tsx?

Medium
2

Why does data fetched in a layout not refresh when navigating between its child pages?

Hard
3

Why must error.tsx be a client component?

Medium

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.

Loading discussion…