cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Add a loading state to a routeWrite a loading.tsx and say what the visitor sees first, on a page load and after a click.
  • 02
    Explain it as a Suspense boundaryTell what loading.tsx wraps, what it doesn't, and when a Suspense in the page fits better.
  • 03
    Design 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.

The orders page with a loading stateNext.js
app/_data/orders/
app/orders/loading.tsx
export default function Loading() {  return <p>Loading your orders…</p>;}
Recorded from Next.js 16.4.3

GET /orders200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-stale-time 300
x-nextjs-prerender 1
x-nextjs-postponed 1
X-Powered-By Next.js
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
Content-Type text/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.

Two browser windows for slash orders. The left one, at first, shows the links Home and Orders and the card Loading your orders. A curved arrow under them, labeled later in the same response, goes to the right one, which shows the same links and the orders 1042 Green tea and 1043 Mint tea in the place of the loading card.at firstHome OrdersLoading your orders…later, in the same responsethenHome OrdersOrder 1042: Green teaOrder 1043: Mint tea
One response, two parts. The first part has the root layout's links and the fallback from 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.

Real-life analogyA table at a restaurant

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
The main idea

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:

What Next.js builds from the orders folder, written by handJSX
<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.

Three crates nested inside each other. The outer one is RootLayout. Inside it is a crate labeled Suspense, which holds two cards: the fallback, Loading, and the page, OrdersPage. A label says the fallback shows until the page is ready.fallback: LoadingOrdersPagethe fallback shows until the page is readySuspenseRootLayout
The tree Next.js builds for /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:

Playground: what shows while the orders load?
The tea shop's orders page, in this versionNext.js
app/_data/orders/
app/orders/page.tsx
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>  );}
next build failed · Next.js 16.4.3
$ 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: null
Try it yourself
No boundary

The page waits for the orders, and nothing says what to show meanwhile. The build stops: Next.js encountered uncached or runtime data during prerendering.

Each version was built with next build from Next.js 16.4.3, and the two that built were run with next start and asked for /orders. Open the Network tab to read the HTML as it arrived, or the Terminal tab for the build. Reset goes back to the first version.

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:

Three versions of the orders page
What the folder hasWhat waits behind the fallbackWhat shows at once
app/orders/loading.tsxThe whole page, and the pages in folders below it.The layouts above it: here, the root layout's links.
A <Suspense> in the pageOnly what is inside it: here, the list of orders.The layouts, and the rest of the page: here, the heading too.
NeitherNothing 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.

Clicking Orders in ChromeNext.js
app/_data/orders/
app/orders/loading.tsx
export default function Loading() {  return <p>Loading your orders…</p>;}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/ordersTea shop
Home Orders

Loading your orders…

Focus
the link “Orders”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. 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 and Loading your orders…. The page made one request, for the rest of /orders.
  • Step 3, wait for the orders: the page shows Your orders and both orders, and made no new request. They arrived as the rest of step 2's response.
Three browser windows, one for each recorded step. Step 1, the home page, prefetches the shell of slash orders. Step 2, right after the click on Orders, shows the links and Loading your orders. Step 3 shows the links and the heading of the orders page. Arrows go from each step to the next.step 1Tea shopOrders linkstep 2Home OrdersLoading your orders…step 3Home OrdersYour ordersprefetch the shellclick: the fallbackthe orders arrive
The recorded click. Step 1 prefetches /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:

A skeleton and the orders, in ReactPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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:

The orders page with a skeletonNext.js
app/_data/orders/
app/orders/loading.tsx
import 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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/ordersTea shop
Home Orders
Your orders
  • 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

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

Keep it small

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:

The special files in one folder, from the outside in
FileWhat it wraps
layout.tsxEverything below. It sits outside the boundary, so it must not wait for request-time data on its own.
template.tsxLike a layout, inside it. A later lesson teaches it.
error.tsxWhat's inside it, to show an error screen. The next lesson teaches it.
loading.tsxThe rest: not-found.tsx, the page, and the folders below, in a <Suspense>.
not-found.tsxThe page, to show when something isn't found. A later lesson teaches it.
page.tsxNothing. 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:

The account folderText
app/
  account/
    orders/
      page.tsx
    settings/
      page.tsx
    layout.tsx
    loading.tsx
One loading state for the account pagesNext.js
app/_data/account/orders/settings/
app/account/loading.tsx
export default function Loading() {  return <p>Loading your account…</p>;}
Recorded from Next.js 16.4.3

GET /account/orders200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-stale-time 300
x-nextjs-prerender 1
x-nextjs-postponed 1
X-Powered-By Next.js
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
Content-Type text/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.

Crates nested inside each other. The outer one is AccountLayout, with its links card above a crate labeled Suspense. The Suspense crate holds the fallback card, Loading your account, and the two pages, orders and settings.Your account: Orders Settingsfallback: Loading your account…orders pagesettings pageSuspenseAccountLayout
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:

Playground: a layout that waits
The orders folder, with this layoutNext.js
app/_data/orders/
app/orders/layout.tsx
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}    </>  );}
next build failed · Next.js 16.4.3
$ 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: null
Try it yourself
Cookie read in the layout

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

Both versions were built with next build from Next.js 16.4.3. The second was run with next start and asked for /orders with the cookie name=Asha. Both have app/orders/loading.tsx; only the layout changes. Reset goes back to the first version.

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:

app/orders/page.tsx: the page that waitsTSX
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>
  );
}
The orders folder, ready to shipText
app/
  _data/
    orders.ts
  orders/
    loading.module.css
    loading.tsx
    page.tsx
  layout.tsx
  page.tsx

Line 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?

Inside the boundary or outside it?
  • app/orders/page.tsx, beside app/orders/loading.tsx
  • app/account/orders/page.tsx, under app/account/loading.tsx
  • app/account/layout.tsx, beside app/account/loading.tsx
  • app/orders/layout.tsx reading a cookie, beside app/orders/loading.tsx
  • app/layout.tsx, the root layout
Try it yourself
0 of 5 correct

Sort each file: is it inside the boundary of a loading.tsx from this lesson, or outside it?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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/settings never 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 Suspense around 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.

Exercise 1 · Warm-upWhat arrives first

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?

Starter codeTSX
export default function Loading() {
  return <p>Loading your orders…</p>;
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upRead the route table

    The orders page waits for request-time data, and has a loading.tsx beside it. Which symbol does next build print next to /orders?

    Starter codeTSX
    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>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeAdd a loading state

      This is app/orders/page.tsx. Give the whole page a loading state that says Loading your orders…. Which file do you add?

      Starter codeTSX
      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>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFix the build without loading.tsx

        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?

        Starter codeTSX
        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>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeA page with nothing to wait for

          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?

          Starter codeText
          app/
            account/
              orders/
                page.tsx
              settings/
                page.tsx
              layout.tsx
              loading.tsx

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeA layout that waits

            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?

            Starter codeTSX
            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}
                </>
              );
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              The loading UI quiz · 7 questionsScore: first tries count
              1. 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.

              2. Question 2 of 7In the recorded HTML for /orders, which comes first?

                Choose an answer to see the explanation.

              3. Question 3 of 7Which symbol does the recorded build print for /orders, with its loading.tsx?

                Choose an answer to see the explanation.

              4. Question 4 of 7The orders page awaits request-time data and has no loading.tsx and no Suspense. What happens?

                Choose an answer to see the explanation.

              5. Question 5 of 7app/orders/layout.tsx reads a cookie, and app/orders/loading.tsx sits beside it. Does the loading state cover the layout?

                Choose an answer to see the explanation.

              6. Question 6 of 7Why does the skeleton in loading.tsx repeat the heading Your orders?

                Choose an answer to see the explanation.

              7. 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.tsx in 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 Suspense around 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.tsx or a Suspense. 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.tsx covers the folders below it, but not the layout beside it. A layout that waits needs its own Suspense.

              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.

              CompleteFrontend Clear concepts. Working examples.