Type something to search...
Next.js Building public/static pages

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:

  1. 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.
  2. 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.
  3. 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).
  4. 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

SituationWhat to reach forBuild output marker
No data dependency at allNothing — 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 pageCache 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.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper