Optimizing the static shell
Cache Components can prerender a static shell while request-time content streams in. The shell can keep the route's layout, navigation, headings, and focused loading states visible instead of replacing the page with one broad fallback.
Apps written before Cache Components often await request data near the top of a layout or page. With Cache Components enabled, those routes may already have a small static shell, sometimes made up of one broad loading state.
In this guide, you'll learn how to expand the shell by tracing each data read to the UI that needs it, caching reusable work, and moving request-time work behind focused <Suspense> boundaries. These patterns keep important content available early and reserve space as the rest of the page streams in.
Good to know
This guide focuses on Cache Components, which enable the static shell and instant navigation validation described below. Many of the underlying patterns, such as moving data access closer to the UI that needs it, streaming with focused <Suspense> boundaries, avoiding waterfalls, and designing stable loading states, are also useful in other App Router applications. The cache directives and validation workflow require Cache Components.
Use the optimizer skill (recommended)
The next-cache-components-optimizer skill applies this guide with a coding agent. It uses the instant() verification loop below to capture the intended static shell, prove the current behavior, and keep the passing test as regression coverage.
The skill can also target a client navigation whose destination UI does not commit immediately. It applies the same patterns to the destination segments that change, then verifies the named navigation through its real link. If the destination already has instant UI and you want to include additional URL-specific content before the click, use Optimizing prefetching instead.
Install the skill:
Then give the agent the route and the UI that should be in its static shell:
Prerequisites
Enable cacheComponents and resolve the route's blocking validation errors first. For the migration workflow, see Migrating to Cache Components.
For client navigations, consider enabling Partial Prefetching as well. With Partial Prefetching enabled, <Link prefetch={true}> can include cached URL-specific content before the click, making more UI available immediately on navigation. See Optimizing prefetching to choose which content to prefetch.
Patterns
The following patterns can make more useful UI available in the static shell:
| Pattern | Use it when | Result |
|---|---|---|
| Keep static UI in the shell | UI does not need data | The UI can render in the static shell |
| Stream request-time work | A region needs data that is only known at request time | Its nearest <Suspense> fallback can render first |
| Cache reusable work | A result can be reused across requests | The completed result can render in the static shell |
Keep static UI in the shell
A <Suspense> boundary controls which part of the UI its fallback replaces when a child suspends. Static UI can sit inside a boundary without a problem. However, if the same boundary also contains slow or request-time work, that fallback temporarily replaces the static UI too.
To keep headings, navigation, cached content, and other stable UI visible, take them out of any loading state they do not need to be part of. Then move the boundary down to the smaller region that can suspend:
When the primary heading or other Largest Contentful Paint candidate depends on reusable data, cache that data so the completed content can render in the shell. If the data must wait for the request, keep its loading state focused so it does not hold back the rest of the page.
Stream request-time work
Some data cannot be known until a request arrives, must be recomputed for every request, or has a cache lifetime too short for prerendering. If a layout or page awaits that work near the top, it can block the whole page from rendering. Split out the region that needs the result and let only that region suspend so the rest of the page can render first.
Push data access down
Resolve request-time data in the logical UI region that uses it. This can be a Server Component that awaits the data or a Client Component that unwraps a promise passed from the server.
Move data access into a Server Component. When the page resolves request-time data itself, its heading and other independent UI cannot render until that work finishes:
Move params, searchParams, or another request-time data read into the Server Component that represents the relevant UI region. Place that component behind <Suspense> so its siblings can render while the work resolves:
The heading and product fallback can render before the request-time product query finishes. Request APIs such as cookies() and headers() can also be moved into a focused component:
The layout can return DashboardShell before UserMenu reads the incoming request.
Resolve a promise inline. For a small region, you do not need to create another component only to await a promise. Resolve params, searchParams, or another promise inline with .then() inside the boundary:
Pass a promise to a Client Component. An interactive Client Component may need data fetched on the server. Awaiting that data in its parent would also hold back the parent's stable UI:
Start the request without awaiting it, then pass the promise to the Client Component behind <Suspense>:
The Client Component unwraps the promise with React's use():
For URL data, a Client Component can often call useParams() or useSearchParams() instead of receiving a promise. Both hooks resolve synchronously during a client navigation because the router already has the URL.
See Push dynamic access down for more examples of resolving request-time work closer to the UI that uses it.
Design loading states
Before this refactor, a route might rely on one loading.tsx file for the whole segment. That is often enough while the page loads as one unit. As you push data access down to reveal more completed UI, use focused <Suspense> boundaries and choose their loading states more deliberately. The loading.tsx file can remain as an outer fallback, or be removed when the route no longer needs it.
Reveal independent regions separately. Use sibling boundaries when each completed region has predictable dimensions and can appear on its own. Each fallback should reserve the space its completed region needs:
Reveal related regions in stages. Nest boundaries when the group should first appear together and then reveal more detail, or when revealing a variable-height region by itself could shift the content below it:
React can still start independently reachable work during the same render. Nesting changes the reveal order. It does not make independent requests sequential.
For variable-length content, another option is to render a predictable collapsed state with a Show more or Load more control so content below can appear without shifting later.
When a route uses parallel slots, each slot can stream with its own loading state. See Loading and Error UI with Parallel Routes.
Use the same layout for loading and completed UI. Fallbacks can drift away from the completed UI as components and responsive layouts change. Keep the container that owns the layout outside the boundary. In this example, the skeleton cards and completed product cards both use the same responsive grid:
Good to know
As you refactor or update the UI, a skeleton might no longer match the completed component's size or responsive layout. Keeping the skeleton next to the component, or exporting it from the same file, makes it easier to update and reuse whenever that component needs to be wrapped in <Suspense>.
Use an empty fallback only when the resolved component also has no visual footprint, such as an authorization gate that returns null. Otherwise, provide a fallback that reserves space for the completed UI.
Related data-fetching patterns. After splitting work into streamed regions, these patterns can prevent the requests themselves from starting later than necessary:
- When multiple independent requests must stay in one region, use parallel data fetching to start them together.
- When React reaches a data dependency later in the render, use preloading to start it sooner.
Cache reusable work
After isolating work that would block the page, consider whether the result actually needs to be recomputed for every request. When it can be reused, cache the function or component so its completed UI can render in the static shell. Give the result a lifetime based on how fresh the data needs to be:
If the application can change a cached result, connect the cached read to its writes with on-demand revalidation.
Related caching patterns. These patterns cover values derived from a request, reuse within one request, and dynamic routes:
- To cache a result using a value from
cookies(),headers(), or another runtime API, read the value outside the cached scope and pass it as an argument. - To deduplicate matching calls during one server request without persisting the result across requests, use React
cache(). - To prerender known dynamic routes and generate others on demand, see ISR with Cache Components.
Learn more about cache scopes and lifetimes in Caching.
Follow validation as you refactor
With Cache Components enabled, next dev validates whether each page load or client navigation has instant UI. A route may already have instant UI for one entry point but show an insight for another. For example, a fallback in a shared layout may cover a page load without covering a client navigation within that layout.
If an insight appears while you refactor, that specific page load or client navigation needs an immediate result or fallback. It is a useful target for the patterns above or the optimizer skill, even when another entry point to the same route is already instant. The suggested fixes map to the patterns above:
Resolving the insight makes that entry point instant. It does not decide which UI should be available immediately. Use the Navigation Inspector to inspect what appears and apply the patterns above to include more useful UI, or give the route and the intended instant UI to the optimizer skill.
Example
Consider a team settings route that awaits request data near the top of both its layout and page. This structure may feel natural in an App Router application written before Cache Components. It can also carry over from the Pages Router, where a route-level function such as getServerSideProps loads data before rendering the page. In the App Router, the same structure leaves none of the settings UI in the static shell.
The example starts with one layout and one page:
The layout resolves authentication before rendering the settings frame:
The page then waits for its URL data, team settings, and reusable plan descriptions before rendering the heading:
The following steps keep the completed page and its authorization behavior while making more of it available in the static shell.
Step 1: Move URL-dependent work behind Suspense
The current page resolves both URL promises before rendering any UI:
The settings tabs are interactive, so they can read the URL directly with useSearchParams(). The server-rendered form receives the params and searchParams promises and resolves them where their values are needed.
During prerendering, both regions depend on the URL, so an outer boundary provides one fallback for the group. Once the URL is available, the tabs can render. The form also waits for the settings query, so a nested boundary keeps its own loading state visible until the server work finishes:
The tabs read the active section from the URL:
The form awaits both URL promises before starting the query that depends on their values:
The prerendered shell shows SettingsContentSkeleton. Once the URL is available, the tabs replace that fallback while the nested form boundary stays in its loading state until the settings query finishes.
The plan query does not depend on either URL value. Move it into a sibling component so React can start it while the URL-dependent branch is pending. It still runs for each request at this point, so keep a temporary boundary:
The heading renders directly. PlanOptions and the URL-dependent settings branch can begin independently.
Step 2: Move authentication behind Suspense
The current layout resolves the request APIs before rendering the settings frame:
Instead, move the cookies() and headers() reads into an authentication gate behind <Suspense>. The layout can then render the settings frame without waiting for authentication:
AuthGate is the only component that needs the request values, so it owns those reads. Its boundary lets the settings frame and page stay in the shell. An empty fallback fits this boundary because an authorized gate also returns null.
Good to know
If an authorization interrupt such as unauthorized() or forbidden() runs after streaming starts, it can render its UI, but the response status remains 200. If the HTTP 401 or 403 status matters, check access before streaming begins. See Status codes.
A layout redirect does not protect a database query or Server Action by itself. Keep authorization checks in the data access functions used by the page, and include only safe UI in the shell before authentication completes. See Authentication with Cache Components for the complete pattern.
Step 3: Cache the reusable plan data
The team settings must stay current and authorized for each request, so they continue as request-time work behind <Suspense>. Plan descriptions are shared across teams and change on a known schedule, so PlanOptions can cache them:
PlanOptions has no request-time dependency, so Next.js can resolve it while generating the static shell. Its existing <Suspense> boundary does not prevent that. The fallback is not needed for this route, so the boundary may be removed. Keep a boundary when a cached component is first reached after request-time data so it can cover a cold cache.
If a write changes the plan descriptions, use on-demand revalidation from the corresponding mutation.
With the refactor complete, the route reveals in three stages:
-
In the static shell. The heading and cached plan options render while the URL-dependent region shows its fallback.
-
Once the URL is available. The tabs replace the content skeleton while the settings query continues behind its nested fallback.
-
After the settings load. The completed form replaces its skeleton.
The page now renders useful UI immediately while request-specific content streams into focused fallbacks. The next step is to lock this contract in.
Step 4: Verify the instant UI
Use instant() to hold back request-time server content and assert which parts of the page are available immediately. Playwright makes these assertions after hydration, so Client Components that can resolve without server content may already be visible inside the callback.
Inside the callback, the heading and cached plan options stay visible. Hydration has already rendered the tabs from the browser URL, while instant() keeps the request-time form behind SettingsFormSkeleton. The final assertions confirm that the completed form replaces its loading state after the pause is released.
The testing API is available automatically in next dev. When running the test against a production build with next start, enable experimental.exposeTestingApiInProductionBuild for the test build.
The completed route should have the same settings data, redirects, error states, and interactions as the original route.
Step 5: Include URL-specific content in the instant UI
The direct visit starts with the shared static shell and SettingsContentSkeleton because the route does not know searchParams during prerendering. Once the URL is available, the tabs replace that fallback.
During a client navigation, SettingsTabs can read the URL synchronously. Other server content that depends on the destination's params or searchParams is not part of the shared shell. If that work is cacheable, a per-link prefetch can resolve it before the click instead of streaming it afterward.
Use Optimizing prefetching when URL-specific content should be ready before a selected link is clicked. The guide also covers how to choose a prefetch policy based on the cost of the work.
Learn more
Learn more about the APIs used in this guide.
use cache
use cache: remote
cacheLife
loading.js
Last updated