Layouts, loading and error states
Three special files handle the parts of a page that are not the page: shared chrome, what shows while waiting, and what shows when something breaks.
Layouts
// src/app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-white text-gray-900">
<Header />
<main className="mx-auto max-w-7xl px-4">{children}</main>
<Footer />
</body>
</html>
);
}
A layout wraps every route in its folder and below. children is the page, or a
nested layout.
They nest:
app/layout.tsx header and footer, everywhere
app/(shop)/layout.tsx category nav, shop pages only
app/(shop)/products/page.tsx
The page renders inside the shop layout, which renders inside the root layout.
The key property: a layout does not re-render when you navigate between its
children. Move from /products to /cart and the header stays mounted — its
state, scroll position and any open menu survive. That is why a Next.js site
feels like an application rather than a series of page loads.
The consequence: a layout cannot read params or searchParams. It is not
re-rendered per navigation, so it cannot depend on values that change per route.
Trying is a common early mistake; the data belongs in the page, or in a component
the page renders.
Templates (template.tsx) are the opposite — they do remount on navigation.
Occasionally useful for entry animations, rarely otherwise.
loading.tsx
// src/app/products/loading.tsx
export default function Loading() {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-64 animate-pulse rounded-xl bg-gray-100" />
))}
</div>
);
}
Next.js shows this automatically while the route's server components are
awaiting. It is a Suspense boundary created for you.
Make it look like the content it replaces. A skeleton grid matching the product grid feels fast; a centred spinner feels like waiting, and a layout that jumps when content arrives feels broken.
This is one of the few places where index keys are fine — the list is fixed and has no state.
error.tsx
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="py-16 text-center">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="mt-2 text-gray-600">
We could not load this page. Please try again.
</p>
<button onClick={reset} className="mt-4 text-emerald-600">
Try again
</button>
</div>
);
}
Catches errors thrown by the route below it — including the throw from the
data-fetching lesson when a fetch fails.
Three things that are not optional:
"use client" is required. It needs onClick for the retry.
Do not show error.message to users. It may contain a database error, a
file path, or an internal URL. Log the real message; show something human. The
digest is a hash you can match against your server logs.
reset() re-renders the segment, which retries the fetch. For a transient
network failure that is often all it takes.
An error boundary does not catch errors in the layout above it. For that,
global-error.tsx at the root — which must render its own <html> and <body>
because the root layout has failed.
not-found.tsx
// src/app/products/[slug]/not-found.tsx
import Link from "next/link";
export default function NotFound() {
return (
<div className="py-16 text-center">
<h2 className="text-xl font-semibold">Product not found</h2>
<p className="mt-2 text-gray-600">
It may have been removed or the link may be wrong.
</p>
<Link href="/products" className="mt-4 inline-block text-emerald-600">
Browse all products
</Link>
</div>
);
}
Triggered by calling notFound():
import { notFound } from "next/navigation";
export default async function ProductPage({ params }: Props) {
const product = await getProduct((await params).slug);
if (!product) notFound();
return <ProductDetail product={product} />;
}
Use notFound() rather than rendering a "not found" message yourself. It
sends an actual 404 status, which matters: a 200 response saying "not found"
tells Google the page exists and should be indexed. On a shop with discontinued
products, that is a real SEO problem.
notFound() never returns, so TypeScript narrows product to non-null after
it — no ! needed.
Putting them together
app/
layout.tsx
error.tsx
not-found.tsx
products/
layout.tsx
loading.tsx
page.tsx
[slug]/
loading.tsx
not-found.tsx
page.tsx
Each applies to its folder and below, with the nearest one winning. A product
page that throws uses the root error.tsx unless products/ has its own.
Put an error.tsx at the root at minimum. Without one, an unhandled error
shows Next.js's default error page, which is fine in development and unhelpful
in production.
Check your work
Why a layout does not re-render on navigation: it wraps its children, so moving between them keeps it mounted — which is why header state survives.
Why a layout cannot read params: it is not re-rendered per route, so it
cannot depend on values that change per route.
What loading.tsx is: a Suspense boundary Next.js creates for the route.
Why a skeleton should match the content: otherwise the layout jumps when content arrives, which is what the skeleton was meant to prevent.
Why error.tsx needs "use client": it has an onClick for the retry.
Why not to render error.message: it can contain a database error, a file
path or an internal URL.
What reset() does: re-renders the segment, which retries the fetch.
Why notFound() rather than your own message: it sends a real 404 status.
A 200 saying "not found" tells Google the page exists and should be indexed.
Practice
- Add a root layout with a header and footer. Navigate between pages and confirm they stay.
- Put state in the header — an open menu — and confirm it survives navigation.
- Try to read
paramsin a layout. Read the error and explain why. - Add a
(shop)route group with its own layout and confirm the nesting. - Add
loading.tsxwith a skeleton matching your grid. Slow your fetch with a deliberate delay to see it. - Replace the skeleton with a centred spinner. Compare how each feels.
- Add
error.tsx. Throw in a page and confirm it catches. Press "Try again". - Render
error.messagein it, then write down what could leak. - Call
notFound()for a missing product and check the status code is 404 in the Network tab.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship