Loading UI
Add loading.tsx to show an instant loading state while a page waits for its data, see it streamed as a Suspense fallback, and build skeletons that fit the page.
- 01Add a loading state to a routeWrite a loading.tsx and say what the visitor sees first, on a page load and after a click.
- 02Explain it as a Suspense boundaryTell what loading.tsx wraps, what it doesn't, and when a Suspense in the page fits better.
- 03Design a skeleton that fitsBuild a loading state with the page's own shape, so nothing jumps when the content arrives.
A loading state from one file
Some pages have to wait. The tea shop's orders page reads each visitor's orders when they ask for it, and a database takes a moment to answer. Until then, the page has nothing to show. A loading state is what the visitor sees meanwhile: a short message, or the page's outline without its data.
In Next.js, a loading state is one file. Put a loading.tsx in a route's folder, and Next.js shows what it returns while the page in that folder waits. Here is the shop with its orders page and a loading.tsx beside it. Like every example in this lesson, it's a real Next.js 16.4.3 project, built with next build and run with next start, and what it sent back was recorded.
app/orders/loading.tsxexport default function Loading() { return <p>Loading your orders…</p>;}GET /orders200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 16 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<nav><a href="/">Home</a> <a href="/orders">Orders</a></nav>
<!--&-->
<!--&-->
<!--$?-->
<template id="B:0"></template>
<p>Loading your orders…</p>
<!--/$-->
<!--/&-->
<!--/&-->
<script>…</script>
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<div hidden="" id="S:0">
<template id="P:1"></template>
<!--$-->
<!--/$-->
</div>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
<div hidden="" id="S:1">
<main>
<h1>Your orders</h1>
<ul>
<li>Order <!-- -->1042<!-- -->: <!-- -->Green tea</li>
<li>Order <!-- -->1043<!-- -->: <!-- -->Mint tea</li>
</ul>
</main>
</div>
<script>…</script>
<script>…</script>
</body>
</html>app/orders/loading.tsx, the file shown first, is a normal component: line 2 returns Loading your orders…. It takes no props. Its name and place are what make it special. Now click app/orders/page.tsx: line 4 waits for getOrders(), and lines 9 to 13 list the orders. In app/_data/orders.ts, line 5 calls connection(), which tells Next.js to read the orders when a request comes in, never during the build. Line 7 waits a moment on purpose, the way a real database would, so the recordings can catch the loading state.
The Network tab is open on /orders. It shows the HTML in the order it arrived. Near the top, after the links, is <p>Loading your orders…</p>. Further down, in a hidden <div>, come the heading and the two orders. So the server didn't wait for the orders before it answered. It sent the links and the loading state at once, then the orders later in the same response, and a small script that React adds swapped them into the loading state's place. Sending a page in parts like this is called streaming. The Browser tab shows the page after the swap: the links, Your orders and both orders.
loading.tsx. The orders come later in the same response, and React puts them where the fallback was.Open the Terminal tab. The build lists ◐ /orders, and its legend says ◐ means "prerendered as static HTML with dynamic server-streamed content". The part that doesn't depend on the request, the links and the loading state, was made during the build. That part is called the static shell. The orders are made for each request and streamed in after it.
At a good restaurant, you sit down at a table that's already set, and a card on your plate says your meal is being cooked. You know where you are and that something is coming. When the meal is ready, the waiter takes the card away and puts the plate exactly where it was.
- In real life: The table, set before you arrive: plates, cutlery, a candle
- In Next.js: The static shell: the layout, made during the build
- In real life: A card on your plate: "Your meal is being cooked"
- In Next.js: The fallback from
loading.tsx - In real life: The kitchen, cooking your order
- In Next.js: The server, waiting for the orders
- In real life: The waiter taking the card away and putting the meal there
- In Next.js: React swapping the page into the fallback's place
loading.tsx is the loading state of the route in its folder. While the page waits for its data, the visitor sees the layout and what loading.tsx returns. The page streams in later, in the same response, and takes the loading state's place.
loading.tsx is a Suspense boundary
None of this is new to React. Suspense is the React component that shows a fallback, like a loading message, while the components inside it wait. The place where a Suspense sits is called a Suspense boundary: everything inside it can wait, and everything outside it shows at once. loading.tsx is a shortcut for one. Written by hand, the tree Next.js builds for /orders is roughly this:
<RootLayout>
<Suspense fallback={<Loading />}>
<OrdersPage />
</Suspense>
</RootLayout>Line 1 is the root layout, from app/layout.tsx. Line 2 is a Suspense whose fallback is the component from loading.tsx, here called Loading. Line 3 is the page from app/orders/page.tsx. This is a sketch to read, not code you write: Next.js builds the real tree from your files.
/orders. loading.tsx becomes the fallback of a Suspense around the page. The layout is outside it, so the links show at once.So loading.tsx is one way to place a boundary. You can also write a Suspense yourself, inside the page. And with Cache Components on, as in every project in this course, a page that waits for request-time data needs a boundary, or the build stops. Pick each version of the orders page and compare:
app/orders/page.tsximport { getOrders } from "../_data/orders"; export default async function OrdersPage() { const orders = await getOrders(); return ( <main> <h1>Your orders</h1> <ul> {orders.map((order) => ( <li key={order.id}> Order {order.id}: {order.tea} </li> ))} </ul> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Error: Route "/orders": Next.js encountered uncached or runtime data during prerendering. `fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. Ways to fix this: - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`) - [block] Set `export const instant = false` to allow a blocking route Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic at body (<anonymous>) at html (<anonymous>)To get a more detailed stack trace and pinpoint the issue, try one of the following: - Start the app in development mode by running `next dev`, then open "/orders" in your browser to investigate the error. - Rerun the production build with `next build --debug-prerender` to generate better stack traces.Error occurred prerendering page "/orders". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /orders/page: /orders, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullThe page waits for the orders, and nothing says what to show meanwhile. The build stops: Next.js encountered uncached or runtime data during prerendering.
Without a boundary, the build stops with Next.js encountered uncached or runtime data during prerendering. Read it in the Terminal tab of the first version. "Uncached or runtime data" is data read for each request, like the orders after connection(). Next.js wants to send something at once, and nothing says what. The message lists three fixes. The first, [stream], is the one this lesson teaches: a Suspense, or a loading.tsx. The other two, caching the data and letting the page block, belong to the Rendering and caching stage.
With loading.tsx, the whole page waits, heading included. With a Suspense around the list in the page, the first HTML already holds the heading Your orders, and only the list waits. Each row is one version:
| What the folder has | What waits behind the fallback | What shows at once |
|---|---|---|
app/orders/loading.tsx | The whole page, and the pages in folders below it. | The layouts above it: here, the root layout's links. |
A <Suspense> in the page | Only what is inside it: here, the list of orders. | The layouts, and the rest of the page: here, the heading too. |
| Neither | Nothing can wait, so the build stops with an error. | Nothing: there's no page to show. |
Use loading.tsx for one loading state for a whole route, and a Suspense in the page when part of the page can show at once. Both stream the same way. Streaming with Suspense goes further.
Instant loading states
So far, the visitor typed the address. Most visitors arrive by clicking a link instead. Then Next.js swaps in the new page without loading a whole new document, which is called a client-side navigation. The Next.js docs call a loading state that shows right after the click an instant loading state. This example was clicked through in real Chrome: open /, click Orders, then wait for the orders. The Steps tab opens on the click.
app/orders/loading.tsxexport default function Loading() { return <p>Loading your orders…</p>;}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Loading your orders…
- Focus
- the link “Orders”
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
GET/orders200text/x-component- rsc
- 1
- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22orders%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2C%22refetch%22%2C49314%5D%7D%2Cnull%2Cnull%2C49254%5D%7D%2Cnull%2Cnull%2C49242%5D
- next-url
- /
- Step 1, open
/: the home page, a whole page, then prefetches: requests the page's JavaScript makes early, before any click, for the pages its links lead to. Two of them are for/orders. With partial prefetching, which this project has on, a prefetch loads the linked route's static shell: the links and the loading state, not the orders. - Step 2, click Orders: the address is
/orders, and the page shows the links andLoading your orders…. The page made one request, for the rest of/orders. - Step 3, wait for the orders: the page shows
Your ordersand both orders, and made no new request. They arrived as the rest of step 2's response.
/orders's static shell. Step 2, right after the click, shows the links and the fallback while the orders are on their way. Step 3 shows the orders, which came in the same response.The docs explain why step 2 can show the loading state right away: the fallback is prefetched, so the navigation is immediate unless the prefetch hasn't finished. The layout, here the links, stays on the screen and keeps working while the new page loads. The docs also say a navigation can be interrupted: a visitor who clicks another link meanwhile doesn't wait for the orders. The Linking and navigating lesson covers prefetching in full.
Skeletons that match the page
Loading your orders… works, but when the orders arrive, everything under the links moves: a short line becomes a heading and a list. A skeleton is a loading state with the page's own shape: the parts you already know, like the heading, and grey bars where the data will go. When the data arrives, it lands where the bars were, and nothing jumps.
Here is the idea live, in plain React. It's the same Suspense that loading.tsx makes, with a skeleton as its fallback. Press Run again on the result to watch the switch:
import { Suspense, use } from "react";import { getOrders } from "./orders.js";import "./styles.css"; const ordersPromise = getOrders(); function OrdersSkeleton() { return ( <main aria-busy="true"> <h1>Your orders</h1> <ul> <li className="row">Loading order…</li> <li className="row">Loading order…</li> </ul> </main> );} function Orders() { const orders = use(ordersPromise); return ( <main> <h1>Your orders</h1> <ul> {orders.map((order) => ( <li key={order.id}> Order {order.id}: {order.tea} </li> ))} </ul> </main> );} export default function App() { return ( <Suspense fallback={<OrdersSkeleton />}> <Orders /> </Suspense> );}OrdersSkeleton, on lines 7 to 17, has the same <main>, the same heading and the same list as Orders, on lines 19 to 33. Its two rows on lines 12 and 13 use the class row, which styles.css paints as grey bars with see-through text. In the orders.js tab, line 8 answers after 1500 ms. Until then, the Suspense on line 37 shows the skeleton. Then the orders take the bars' place, under a heading that never moved. Line 9 adds aria-busy="true", which tells screen readers this part is still loading.
In the tea shop, the skeleton is the orders folder's loading.tsx:
app/orders/loading.tsximport styles from "./loading.module.css"; export default function Loading() { return ( <main aria-busy="true"> <h1>Your orders</h1> <ul> <li className={styles.row}>Loading order…</li> <li className={styles.row}>Loading order…</li> </ul> </main> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Loading order…
- Loading order…
- Focus
- the link “Orders”
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
GET/orders200text/x-component- rsc
- 1
- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22orders%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2C%22refetch%22%2C49314%5D%7D%2Cnull%2Cnull%2C49254%5D%7D%2Cnull%2Cnull%2C49242%5D
- next-url
- /
Line 6 of app/orders/loading.tsx is the page's heading, and lines 8 and 9 are the two placeholder rows. Their class comes from loading.module.css on line 1: a CSS Module, a CSS file whose class names belong to the file that imports it (CSS in Next.js teaches them). The Steps tab, recorded in Chrome, opens right after the click: the heading Your orders and two Loading order… rows. The recording draws pages with plain styles, so the rows show their text instead of grey bars. Step 3 has the orders under the same heading.
A loading state should show at once, so keep it light: no data of its own, and nothing it has to wait for. Repeat what you already know, like headings and the outline of lists and cards, and leave out the rest.
Where loading.tsx sits in the tree
Special files in one folder nest inside each other in a fixed order, as Project structure showed. Each row is one of them, from the outside in, and what it wraps:
| File | What it wraps |
|---|---|
layout.tsx | Everything below. It sits outside the boundary, so it must not wait for request-time data on its own. |
template.tsx | Like a layout, inside it. A later lesson teaches it. |
error.tsx | What's inside it, to show an error screen. The next lesson teaches it. |
loading.tsx | The rest: not-found.tsx, the page, and the folders below, in a <Suspense>. |
not-found.tsx | The page, to show when something isn't found. A later lesson teaches it. |
page.tsx | Nothing. It's the innermost part. |
Two rules follow. First, a loading.tsx also covers the folders below it. Here, one file covers the whole account area:
app/
account/
orders/
page.tsx
settings/
page.tsx
layout.tsx
loading.tsxapp/account/loading.tsxexport default function Loading() { return <p>Loading your account…</p>;}GET /account/orders200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 16 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<nav><a href="/">Home</a> <a href="/orders">Orders</a></nav>
<!--&-->
<section>
<p>Your account: <a href="/account/orders">Orders</a> <a href="/account/settings">Settings</a></p>
<!--&-->
<!--$?-->
<template id="B:0"></template>
<p>Loading your account…</p>
<!--/$-->
<!--/&-->
</section>
<!--/&-->
<script>…</script>
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<div hidden="" id="S:0">
<!--&-->
<template id="P:1"></template>
<!--$-->
<!--/$-->
<!--/&-->
</div>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
<div hidden="" id="S:1">
<main>
<h1>Your orders</h1>
<ul>
<li>Order <!-- -->1042<!-- -->: <!-- -->Green tea</li>
<li>Order <!-- -->1043<!-- -->: <!-- -->Mint tea</li>
</ul>
</main>
</div>
<script>…</script>
<script>…</script>
</body>
</html>The Network tab shows /account/orders. Its first part already holds the account layout's links, Your account: Orders Settings, then Loading your account… from app/account/loading.tsx. The orders page is one folder down, and the fallback still covered it. Now pick /account/settings: the settings page waits for nothing, so the build made it whole (○ /account/settings in the Terminal tab), and its HTML never holds the loading state. A fallback only shows when something inside its boundary waits.
app/account/loading.tsx wraps both account pages, and the account layout wraps it. The layout's links are outside the boundary, so they show at once.Second, a layout sits outside the loading.tsx in its own folder. If that layout waits for request-time data, like a cookie, no fallback covers the wait. Pick both versions:
app/orders/layout.tsximport { cookies } from "next/headers"; export default async function OrdersLayout({ children }: LayoutProps<"/orders">) { const name = (await cookies()).get("name")?.value ?? "guest"; return ( <> <p>Signed in as {name}</p> {children} </> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Error: Route "/orders": Next.js encountered uncached or runtime data during prerendering. `fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. Ways to fix this: - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`) - [block] Set `export const instant = false` to allow a blocking route Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic at body (<anonymous>) at html (<anonymous>)To get a more detailed stack trace and pinpoint the issue, try one of the following: - Start the app in development mode by running `next dev`, then open "/orders" in your browser to investigate the error. - Rerun the production build with `next build --debug-prerender` to generate better stack traces.Error occurred prerendering page "/orders". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /orders/page: /orders, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullThe layout awaits cookies(), and loading.tsx sits below it, so nothing catches the wait. The build stops with the same error as a page with no boundary.
In the first version, app/orders/layout.tsx reads a cookie on line 4, and the build stops with the same error as a page with no boundary, although loading.tsx sits right beside it. The fix is a Suspense in the layout itself: in the second version, lines 4 to 7 move the cookie read into SignedInAs, and lines 12 to 14 wrap it with the fallback Checking who you are…. Now the first HTML holds two fallbacks, and both are replaced: Signed in as Asha and the orders. The other way out is to move the wait into the page, where loading.tsx covers it.
Loading states across the tea shop
Planning loading states comes down to two questions for each route: what has to wait, and what can show at once? Put the boundary right above what waits. Here is the whole orders page you'd ship, with the skeleton from above:
import { getOrders } from "../_data/orders";
export default async function OrdersPage() {
const orders = await getOrders();
return (
<main>
<h1>Your orders</h1>
<ul>
{orders.map((order) => (
<li key={order.id}>
Order {order.id}: {order.tea}
</li>
))}
</ul>
</main>
);
}
app/
_data/
orders.ts
orders/
loading.module.css
loading.tsx
page.tsx
layout.tsx
page.tsxLine 4 of the page waits for the orders, so the page sits behind the folder's boundary, and loading.tsx gives it a skeleton. The root layout waits for nothing, so its links show on every page at once. Now sort the files from this lesson: which ones sit inside a loading.tsx boundary?
app/orders/page.tsx, besideapp/orders/loading.tsxapp/account/orders/page.tsx, underapp/account/loading.tsxapp/account/layout.tsx, besideapp/account/loading.tsxapp/orders/layout.tsxreading a cookie, besideapp/orders/loading.tsxapp/layout.tsx, the root layout
Sort each file: is it inside the boundary of a loading.tsx from this lesson, or outside it?
Common mix-ups
- "The server waits for the whole page, then sends it." No. The recorded HTML holds the loading state first and the orders later, in one response.
- "loading.tsx covers the layout in its folder." No. It sits inside that layout. A layout that reads a cookie stopped the build.
- "Every page shows its loading state." No. Only a page that waits does.
/account/settingsnever showed it. - "A page can wait for request-time data without any boundary." Not by default with Cache Components on. The build stops, and the first fix it lists is a
Suspense. - "The loading state only works when you type the address." No. The recorded click showed it right away.
- "loading.tsx is a different thing from Suspense." No. It is a
Suspensearound the page, with the file's component as the fallback.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
This is app/orders/loading.tsx, beside the orders page. A visitor opens /orders. Besides the layout's links, what does the first part of the HTML show?
export default function Loading() {
return <p>Loading your orders…</p>;
}
Loading your orders…, after the root layout's links. The recorded first part of /orders shows Home Orders and Loading your orders…; the orders come later.
The orders page waits for request-time data, and has a loading.tsx beside it. Which symbol does next build print next to /orders?
import { getOrders } from "../_data/orders";
export default async function OrdersPage() {
const orders = await getOrders();
return (
<main>
<h1>Your orders</h1>
<ul>
{orders.map((order) => (
<li key={order.id}>
Order {order.id}: {order.tea}
</li>
))}
</ul>
</main>
);
}
◐: prerendered as static HTML with dynamic server-streamed content. The shell with the loading state is made during the build, and the orders are streamed for each request.
This is app/orders/page.tsx. Give the whole page a loading state that says Loading your orders…. Which file do you add?
import { getOrders } from "../_data/orders";
export default async function OrdersPage() {
const orders = await getOrders();
return (
<main>
<h1>Your orders</h1>
<ul>
{orders.map((order) => (
<li key={order.id}>
Order {order.id}: {order.tea}
</li>
))}
</ul>
</main>
);
}
export default function Loading() {
return <p>Loading your orders…</p>;
}
app/orders/loading.tsx, which exports a component by default. With it, the recorded build lists ◐ /orders, and the first HTML holds Loading your orders….
With no loading.tsx, this page stops the build: Next.js encountered uncached or runtime data during prerendering. What can you add to the page itself to fix it, so that the heading shows at once?
import { getOrders } from "../_data/orders";
export default async function OrdersPage() {
const orders = await getOrders();
return (
<main>
<h1>Your orders</h1>
<ul>
{orders.map((order) => (
<li key={order.id}>
Order {order.id}: {order.tea}
</li>
))}
</ul>
</main>
);
}
import { Suspense } from "react";
import { getOrders } from "../_data/orders";
async function OrderList() {
const orders = await getOrders();
return (
<ul>
{orders.map((order) => (
<li key={order.id}>
Order {order.id}: {order.tea}
</li>
))}
</ul>
);
}
export default function OrdersPage() {
return (
<main>
<h1>Your orders</h1>
<Suspense fallback={<p>Loading your orders…</p>}>
<OrderList />
</Suspense>
</main>
);
}
A Suspense around the part that waits. Here the list moves into OrderList, inside <Suspense fallback={<p>Loading your orders…</p>}>. The recorded build passes, and the heading shows at once.
app/account/loading.tsx covers both account pages. The settings page only shows a heading and a sentence. Does a visitor to /account/settings ever see Loading your account… in its HTML?
app/
account/
orders/
page.tsx
settings/
page.tsx
layout.tsx
loading.tsxNo. The settings page waits for nothing, so the build made it whole (○ /account/settings), and its recorded HTML has no Loading your account….
This app/orders/layout.tsx stops the build, though app/orders/loading.tsx sits beside it. Move line 4 into a component called SignedInAs. What goes around it?
import { cookies } from "next/headers";
export default async function OrdersLayout({ children }: LayoutProps<"/orders">) {
const name = (await cookies()).get("name")?.value ?? "guest";
return (
<>
<p>Signed in as {name}</p>
{children}
</>
);
}
import { Suspense } from "react";
import { cookies } from "next/headers";
async function SignedInAs() {
const name = (await cookies()).get("name")?.value ?? "guest";
return <p>Signed in as {name}</p>;
}
export default function OrdersLayout({ children }: LayoutProps<"/orders">) {
return (
<>
<Suspense fallback={<p>Checking who you are…</p>}>
<SignedInAs />
</Suspense>
{children}
</>
);
}
A Suspense around SignedInAs, the component that reads the cookie. The layout itself no longer waits. The recorded build passes, and /orders shows Checking who you are… first, then Signed in as Asha.
Check your understanding
Question 1 of 7What does Next.js wrap a page in, when its folder has a
loading.tsx?Choose an answer to see the explanation.
Question 2 of 7In the recorded HTML for
/orders, which comes first?Choose an answer to see the explanation.
Question 3 of 7Which symbol does the recorded build print for
/orders, with itsloading.tsx?Choose an answer to see the explanation.
Question 4 of 7The orders page awaits request-time data and has no
loading.tsxand noSuspense. What happens?Choose an answer to see the explanation.
Question 5 of 7
app/orders/layout.tsxreads a cookie, andapp/orders/loading.tsxsits beside it. Does the loading state cover the layout?Choose an answer to see the explanation.
Question 6 of 7Why does the skeleton in
loading.tsxrepeat the headingYour orders?Choose an answer to see the explanation.
Question 7 of 7A visitor on
/clicks Orders. When does the recorded loading state show?Choose an answer to see the explanation.
Key takeaways
- A
loading.tsxin a folder is the loading state of the route there. It takes no props and exports a component by default. - It becomes the fallback of a
Suspensearound the page, inside the folder's layout. The layout shows at once, and the page streams in later in the same response. - With Cache Components on, a page that waits for request-time data needs a boundary: a
loading.tsxor aSuspense. Without one, the build stops and lists the fixes. - After a click, the loading state shows right away: the docs say it comes with the route's prefetched static shell.
- A skeleton repeats the page's shape, so nothing jumps when the content arrives.
loading.tsxcovers the folders below it, but not the layout beside it. A layout that waits needs its ownSuspense.
Remember the one-liner.loading.tsx is a Suspense around the page: the layout shows now, and the page streams in when it's ready.
Coming next: Error handling. It shows error.tsx, the file that catches what breaks in a route and lets the visitor try again.