
Next.js Building public/static pages
A landing page, a product listing, a blog index — the common thread across all of them is that every visitor sees the same content. Nobody's landing page is personalized; nobody's product catalog differs by who's looking at it. That shared-content property is exactly what makes these pages cheap to serve well: since the output doesn't vary per user, Next.js can compute it once, ahead of time, and hand out the same prerendered result to everyone — faster page loads, and a fraction of the server cost of computing the same thing on every single request.
This article walks through building exactly that kind of page — starting fully static, adding real external data, and finally adding one genuinely personalized element without giving up the static benefits for everything else. It's a hands-on companion to the more conceptual caching articles elsewhere in this series, worked through as one concrete example: a product listing page.
Step 1: A page with nothing dynamic in it
// app/products/page.tsx
function Header() {
return <h1>Shop</h1>;
}
export default async function Page() {
return (
<>
<Header />
</>
);
}
<Header /> doesn't depend on anything that changes between requests — no external data, no request headers, no route params, no current time, nothing random. Because its output can be fully determined ahead of time, it's what counts as a static component, and Next.js prerenders it at build time with zero configuration required to make that happen.
You can confirm this directly by running the build and reading its own output:
Route (app) Revalidate Expire
┌ ○ /products 15m 1y
└ ○ /_not-found
○ (Static) prerendered as static content
That ○ (Static) marker next to /products is worth internalizing as a thing to actually check, not just trust — it's the build telling you explicitly that this route qualified for prerendering, with no export const dynamic or similar config needed to earn that classification.
One aside worth flagging here, since it's a common early mistake with "static" pages specifically: if this header ever needs to show a locale-aware date or time, don't reach for a naive toLocaleDateString() call — that's a different problem with its own dedicated fix, covered in the "Preventing flash before hydration" article in this series.
Step 2: Adding real data — and hitting the first wall
// app/products/page.tsx
import db from "@/db";
import { List } from "@/app/products/ui";
function Header() {}
async function ProductList() {
const products = await db.product.findMany();
return <List items={products} />;
}
export default async function Page() {
return (
<>
<Header />
<ProductList />
</>
);
}
Unlike the header, <ProductList /> depends on external data that genuinely can change over time — new products get added, prices change. That single fact makes it a dynamic component, and without any further instruction, Next.js's default assumption is exactly what you'd want from ordinary web behavior: fetch fresh data on every request.
The problem is what that default assumption costs you here. If this component renders at request time, its data fetch blocks the entire route from responding — reload the page and you'd actually see this happen: the header, despite being computable instantly, can't reach the browser until the product list's database query has fully resolved, because they're part of one response.
Next.js doesn't let this happen silently. The first time you await genuinely uncached data outside a <Suspense> boundary, it surfaces an explicit warning: accessing uncached data this way is exactly what prevents the route from being prerendered at all. That warning is the framework handing you a decision point, not an error to silence — you have to choose one of two ways to unblock the response: cache the component so it becomes stable and prerenderable, or stream it so it becomes non-blocking without needing to be cacheable at all.
For a product catalog shared identically across every visitor, caching is unambiguously the right call.
Cache components: making dynamic data static-shaped
The 'use cache' directive is what marks a function as cacheable:
// app/products/page.tsx
import db from "@/db";
import { List } from "@/app/products/ui";
function Header() {}
async function ProductList() {
"use cache";
const products = await db.product.findMany();
return <List items={products} />;
}
export default async function Page() {
return (
<>
<Header />
<ProductList />
</>
);
}
This turns ProductList into what Cache Components calls a cache component: the first execution computes and caches whatever it returns, and every subsequent call reuses that cached result rather than re-running the database query. The crucial property that makes this compatible with static prerendering specifically: if a cache component's inputs are fully known before a request even arrives — which is true here, since this function takes no per-request arguments at all — Next.js can prerender it exactly like a genuinely static component.
Reload the page after this change, and it loads instantly — the cache component no longer blocks the response at all. Run next build again, and the route is still marked static:
Route (app) Revalidate Expire
┌ ○ /products 15m 1y
└ ○ /_not-found
○ (Static) prerendered as static content
Same static classification as the plain header-only version from Step 1 — except now the page actually renders a real, live-updating (on its own revalidation schedule) product catalog, not just placeholder markup. That's the entire trick: caching turns genuinely dynamic data into something that behaves, from the rendering pipeline's perspective, exactly like static content.
But real pages rarely stay this simple forever — sooner or later, something on the page genuinely can't be shared across every visitor.
Step 3: A promotion banner that can't be cached away
// app/products/page.tsx
import db from "@/db";
import { List, Promotion } from "@/app/products/ui";
import { getPromotion } from "@/app/products/data";
function Header() {}
async function ProductList() {}
async function PromotionContent() {
const promotion = await getPromotion();
return <Promotion data={promotion} />;
}
export default async function Page() {
return (
<>
<PromotionContent />
<Header />
<ProductList />
</>
);
}
This starts out dynamic too, and triggers the same blocking-behavior warning as before — but this time, 'use cache' genuinely isn't the answer. The promotion depends on request-specific signals like the visitor's location and which A/B test bucket they're in; caching it would mean serving one visitor's promotion (and test bucket) to everyone, which defeats the entire point of the feature.
Partial prerendering: unblocking without caching
The realization worth internalizing here is that "add dynamic content" and "go back to fully blocking rendering" aren't actually the same decision — you can unblock the response with streaming instead of caching, for exactly the content that genuinely can't be cached:
// app/products/page.tsx
import { Suspense } from "react";
import db from "@/db";
import { List, Promotion, PromotionSkeleton } from "@/app/products/ui";
import { getPromotion } from "@/app/products/data";
function Header() {}
async function ProductList() {}
async function PromotionContent() {
const promotion = await getPromotion();
return <Promotion data={promotion} />;
}
export default async function Page() {
return (
<>
<Suspense fallback={<PromotionSkeleton />}>
<PromotionContent />
</Suspense>
<Header />
<ProductList />
</>
);
}
The <Suspense> boundary here is the actual mechanism, and it's worth being precise about what it does: it tells Next.js exactly where to slice the streamed response into separate chunks, and what fallback UI to show in that slot while the real content is still loading. The fallback itself gets prerendered right alongside your static and cached content — only the actual <PromotionContent> streams in afterward, once its data fetch resolves.
Because of this split, the build output changes its classification, and it's worth knowing this new symbol specifically:
Route (app) Revalidate Expire
┌ ◐ /products 15m 1y
└ ◐ /_not-found
◐ (Partial Prerender) Prerendered as static HTML with dynamic server-streamed content
That half-filled ◐ circle, replacing the fully-filled ○, is the build telling you this route is now partially prerendered — most of the page (header, product list, and the promotion's fallback skeleton) gets rendered, cached, and pushed to a CDN at build time; the genuinely per-visitor promotion content renders on the server at request time and streams in afterward, swapped into the fallback slot once it's ready.
The end-user experience of this is exactly what you'd hope for: reload the page, and most of it appears instantly (served from a CDN edge node near the visitor), while the personalized part fills in a beat later as it becomes available — rather than either (a) the whole page waiting on the slowest piece, or (b) giving up entirely and making the whole route dynamic just because one small part of it genuinely needs to be.
The general shape of this pattern
Stepping back from the specific product-page example, the actual decision framework generalizes cleanly to basically any page mixing shared and personalized content:
- Start static. If a piece of UI has no dependency on data that varies by request, it prerenders automatically — you don't have to ask for this.
- When you add data that can change, Next.js will tell you, via the blocking-prerender warning, the moment that data threatens to make the whole route wait on it.
- Decide, per piece of dynamic content, whether it's actually shared (cache it with
'use cache') or actually personal (stream it behind a<Suspense>boundary). - Never assume "this page has some dynamic content" forces "the whole page must be dynamic." Partial prerendering exists specifically so that assumption doesn't have to hold.
Key Takeaways
| Situation | What to reach for | Build output marker |
|---|---|---|
| No data dependency at all | Nothing — it's static automatically | ○ Static |
| Data that's the same for every visitor | 'use cache' | Still ○ Static |
| Data that's genuinely per-visitor | <Suspense> boundary, streamed | ◐ Partial Prerender |
| Mixing both on one page | Cache the shared parts, stream the personal parts | ◐ Partial Prerender |
The single habit worth taking from this article is checking next build's route table after making a rendering-related change, rather than assuming you know what happened — the ○/◐ markers are a direct, immediate answer to "did that change actually keep this route as fast as I intended," and it costs nothing to look.


