Next.js Pre-Rendering: Choosing Between Static and Server-Side

Next.js offers two distinct pre-rendering strategies. Server-side Rendering (SSR) generates HTML on the server for every incoming request, which increases time to first byte (TTFB) but guarantees current data. Static Generation (SSG) pre-renders HTML ahead of time—often at build time—so pages can be cached globally and delivered nearly instantly. SSG is the performance winner, but its data can go stale between builds.

Two complementary features let you lean on SSG without accepting stale data or abandoning SSR entirely:

  • Incremental Static Generation — add or refresh statically generated pages after the initial build.
  • Client-side fetching — statically render the shell of a page, then populate dynamic data in the browser.

Choosing a Strategy Per Page

A typical storefront illustrates how these approaches mix. Consider four page types with different data requirements:

  • About Us — company info hardcoded in source, no external data needed.
  • All Products — a product list pulled from a database, identical for every visitor.
  • Individual Product — similar, but data belongs to a single product route.
  • Shopping Cart — products vary by user, so data must be fetched at render time.

Next.js lets you configure the data-fetching strategy on a per-page basis. For the storefront, that means:

  • About Us — SSG with no data.
  • All Products / Individual Product — SSG with Incremental Static Generation.
  • Shopping Cart — SSG (no data) plus client-side fetching.

No-Data Pages: Automatic Static Generation

Pages that don't fetch external data are pre-rendered at build time automatically—this is Next.js's default behavior. An pages/about.js that exports only a component gets this treatment with no further effort.

// This page can can be pre-rendered without

// external data: It will be pre-rendered

// into a HTML file at build time.

export default function About() {

return (

<div>

<h1>About Us</h1>

{/* ... */}

</div>

)

}

Database-Powered Pages: getStaticProps

To connect product catalogs at build time, export getStaticProps:

// This function runs at build time on the build server

export async function getStaticProps() {

return {

props: {

products: await getProductsFromDatabase()

}

}

}

// The page component receives products prop

// from getStaticProps at build time

export default function Products({ products }) {

return (

<>

<h1>Products</h1> 

<ul>

{products.map((product) => (

<li key={product.id}>{product.name}</li>

))}

</ul>

</>

)

}

getStaticProps runs in the Node.js build environment, so database access code never reaches the client bundle. This also means you can query a database directly inside the function.

Dynamic Product Routes with getStaticPaths

Individual product pages live at routes like /products/[id]. To pre-render all of them at build time, create products/[id].js and export getStaticPaths, which specifies every valid id. Then use getStaticProps to fetch the product data, receiving the id as a parameter during the build:

// In getStaticPaths(), you need to return the list of

// ids of product pages (/products/[id]) that you’d

// like to pre-render at build time. To do so,

// you can fetch all products from a database.

export async function getStaticPaths() {

const products = await getProductsFromDatabase()

const paths = products.map((product) => ({

params: { id: product.id }

}))

// fallback: false means pages that don’t have the

// correct id will 404.

return { paths, fallback: false }

}

// params will contain the id for each generated page.

export async function getStaticProps({ params }) {

return {

props: {

product: await getProductFromDatabase(params.id)

}

}

}

export default function Product({ product }) {

// Render product

}

Incremental Static Generation for Large Catalogs

As the store grows from 100 products to 100,000, two problems appear: building tens of thousands of pages at once is slow, and a single product change shouldn't force a full rebuild. Incremental Static Generation solves both.

Lazy Pre-Rendering with Fallback

Instead of building all product pages upfront, set fallback: true in getStaticPaths. When a user requests a page you haven't pre-rendered yet:

  1. Next.js immediately serves a fallback view (such as a loading spinner), not a 404.
  2. In the background, Next.js renders the actual page.
  3. The fallback is swapped for the fully rendered page once ready.
  4. Subsequent requests get the cached static page instantly.

Use the router.isFallback flag in the page component to decide what to show while this happens.

export async function getStaticProps({ params }) {

// ...

}

export async function getStaticPaths() {

// ...

// fallback: true means that the missing pages

// will not 404, and instead can render a fallback.

return { paths, fallback: true }

}

export default function Product({ product }) {

const router = useRouter()

if (router.isFallback) {

return <div>Loading...</div>

}

// Render product...

}

Page Refreshing with Incremental Static Regeneration

When a pre-rendered product changes, you can have Next.js re-render just that page—not the whole app. Set revalidate: 60 in getStaticProps to define a freshness window:

  1. Users keep seeing the cached page until the interval elapses.
  2. The next request after the interval triggers a background re-render.
  3. Once complete, the updated HTML is served.

This mirrors the stale-while-revalidate pattern: traffic is served statically at all times, and updates only propagate after generating successfully. A minority of requests may briefly see older content, but the majority receive up-to-date data on a fast, static response.

export async function getStaticProps({ params }) {

return {

props: {

product: await getProductFromDatabase(params.id)

},

revalidate: 60

}

}

Both page addition and refresh work with standard next start and the Vercel Edge Network without extra configuration.

Partially Static Pages: Client-Side Data Fetching

Pages such as the shopping cart are user-specific and can't be fully pre-rendered for everyone. Rather than jumping to SSR, combine static generation with client-side fetching:

  1. Pre-render the page with no data and show a loading state.
  2. Fetch the personalized data in the browser and swap it in.

The SWR library handles this pattern well, providing caching, revalidation, and focus tracking out of the box.

import useSWR from 'swr'

function ShoppingCart() {

// fetchAPI is the function to do data fetching

const { data, error } = useSWR('/api/cart', fetchAPI)

if (error) return <div>failed to load</div>

if (!data) return <div>loading...</div>

return <div>Items in Cart: {data.products.length}</div>

}

Why Choose Static Where Possible

  • Speed is predictable. Pre-rendered HTML can be cached on a global CDN.
  • Pages stay online. Even if the database or backend fails, cached pages remain accessible.
  • Backend load drops sharply. With no per-request rendering, hit traffic to APIs and databases decreases.

The SSR Route (and When to Skip It)

Server-side rendering remains available in Next.js—export getServerSideProps from a page to re-render HTML on every request. This works fine on Vercel, but abandons the static benefits listed above. Before reaching for SSR, check whether Incremental Static Generation or client-side fetching covers the use case.

Writes and Mutations: API Routes

Fetching data isn't the only operation a storefront performs. For writes—like adding items to a cart—Next.js provides API Routes. Files under pages/api become endpoints. A pages/api/cart.js file, for instance, can accept a productId query parameter and add the item:

export default async (req, res) => {

const response = await fetch(`https://.../cart`, {

body: JSON.stringify({

productId: req.query.productId

}),

headers: {

Authorization: `Token ${process.env.YOUR_API_KEY}`,

'Content-Type': 'application/json'

},

method: 'POST'

})

const { products } = await response.json()

return res.status(200).json({ products })

};

API routes export a request handler that receives the request and returns a JSON response. They allow secure writes to external data sources: sensitive credentials from environment variables never leak client-side. By default, API routes deploy as serverless functions on Vercel.