Skip to main content

Command Palette

Search for a command to run...

Static vs Dynamic Rendering in Next.js

Published
β€’17 min read
Static vs Dynamic Rendering in Next.js

Have you ever wondered why some SSR pages built using Next.JS load instantly while others seem to take a moment to generate their content? πŸ€”

The secret lies in how the server prepares and delivers your pages.

In Next.js, this preparation happens through two fundamental approaches:

i. Static Rendering
ii. Dynamic Rendering.

Think of Static Rendering like having a collection of pre-written letters ready to mail out instantly πŸ’¨. While Dynamic Rendering is like writing a personalised letter ✍🏻 for each recipient as they request it.

This fundamental difference in approach affects everything from server resource usage to user experience, as we'll explore throughout this guide.

In this comprehensive guide, we'll explore how Next.js makes these decisions, what triggers each rendering mode, and how you can control this behavior to optimise your application's performance.

Setting Up Our Learning Environment

Before diving into the concepts, let's create a Next.js project with several example pages that will help us understand the different rendering behaviours.

First, initialise a new Next.js project:

npx create-next-app@latest my-rendering-demo
cd my-rendering-demo

Now, let's create our example pages that will demonstrate various rendering scenarios.

Example 1: The Simple Homepage

Our most basic page serves as the perfect baseline for understanding static rendering:

// app/page.tsx
export default function HomePage() {
  return <h1>Welcome to Our Site</h1>
}

This page contains no dynamic elements, no data fetching, and no user-specific content. It's the perfect candidate for static rendering because the content never changes regardless of who visits or when they visit.

Example 2: Dynamic Route Parameters

Next, let's create a page that handles dynamic parameters in the URL:

// app/todos/[todoId]/page.tsx
type Todo = {
    userId: number;
    id: number;
    title: string;
    completed: boolean;
};

async function fetchTodoData(todoId: string): Promise<Todo | null> {
    const data = await fetch(
        `https://jsonplaceholder.typicode.com/todos/${todoId}`, {
            method: "GET",
            headers: {
                "Content-Type": "application/json",
                "Accept": "application/json",
            },
        }
    );

    if (!data.ok)
        return null;

    const todo = await data.json();  
    if (!todo || !todo.id) {
        return null;
    }

    return todo;
}

export default async function TodoPage({
    params,
}: {
    params: Promise<{ id: string }>;
}) {

    // We need to await params in the new App Router
    const resolvedParams = await params;
    const { id } = resolvedParams;
    const todoData = await fetchTodoData(id);
    if (!todoData) {
        return <div>Todo not found</div>;
    }

    return (
        <div>
            <h1>Todo Details</h1>
            <p>Viewing todo with ID: {id}</p>
            <p>Title: {todoData.title}</p>
            <p>Completed: {todoData.completed ? "Yes" : "No"}</p>
        </div>
    );
}

This page must handle different content based on the todoId parameter. When someone visits /todos/123 versus /todos/456, the page needs to display different information, making it inherently dynamic.

Example 3: Time-Based Revalidation

Let's create a page that demonstrates how revalidation affects rendering decisions:

// app/revalidate-5/page.tsx
export const revalidate = 5; // Revalidate every 5 seconds

async function getServerTime() {
  // Simulate fetching time-sensitive data
  return new Date().toISOString();
}

export default async function RevalidatePage() {
  const timestamp = await getServerTime();

  return (
    <div>
      <h1>Time-Sensitive Content</h1>
      <p>Last updated: {timestamp}</p>
    </div>
  );
}

This page tells Next.js to rebuild the static version every 5 seconds. Between rebuilds, it serves the cached static version, but it refreshes regularly to ensure the content doesn't become too stale.

Example 4: Always Fresh Content

For comparison, let's create a page that always regenerates:

// app/revalidate-0/page.tsx
export const revalidate = 0; // Never cache, always regenerate

async function getServerTime() {
  return new Date().toISOString();
}

export default async function AlwaysFreshPage() {
  const timestamp = await getServerTime();

  return (
    <div>
      <h1>Always Fresh Content</h1>
      <p>Generated at: {timestamp}</p>
    </div>
  );
}

By setting revalidate to 0, we're telling Next.js to never cache this page and to generate fresh content for every request.

Example 5: Explicitly Dynamic

Finally, let's create a page that we explicitly mark as dynamic:

// app/force-dynamic/page.tsx
export const dynamic = 'force-dynamic';

export default function ForceDynamicPage() {
  const randomNumber = Math.random();

  return (
    <div>
      <h1>Forced Dynamic Page</h1>
      <p>Random number: {randomNumber}</p>
    </div>
  );
}

The dynamic = 'force-dynamic' export tells Next.js to always render this page dynamically, even if it could theoretically be static.

The Build Analysis: Reading Next.js's Mind

The most reliable way to understand how Next.js categorizes your pages is through the build output. When you run the build command, Next.js analyses each page and tells you exactly how it plans to handle them.

npm run build

After the build completes, you'll see output similar to this:

Route (app)                                 Size  First Load JS  Revalidate  Expire
β”Œ β—‹ /                                      158 B         102 kB
β”œ β—‹ /_not-found                            158 B         102 kB
β”œ Ζ’ /[id]                                  158 B         102 kB
β”œ Ζ’ /force-dyanmic                         158 B         102 kB
β”œ Ζ’ /revalidate-0                          158 B         102 kB
β”œ β—‹ /revalidate-5                          158 B         102 kB          5s      1y
β”” Ζ’ /todos/[id]                            158 B         102 kB

Understanding these symbols is crucial for optimising your application:

The β—‹ (circle) symbol indicates static rendering. These pages are pre-built during the build process and served from cache. Static pages offer the best performance because the server doesn't need to do any work when a request comes in, it simply sends the pre-generated HTML.

The f symbol indicates dynamic rendering. These pages are generated fresh for each request, allowing them to include user-specific content, real-time data, or other dynamic elements that can't be determined at build time.

Let's analyse our example pages and understand why Next.js made each decision:

Our homepage shows β—‹ because it contains only static content. There's nothing that changes between requests, so Next.js can safely pre-generate this page once and serve it to all visitors.

The force-dynamic page shows Ζ’ because we explicitly told Next.js to treat it as dynamic. Even though the content could theoretically be static, our explicit directive overrides Next.js's default optimisation.

The revalidate-0 page shows Ζ’ because setting revalidation to 0 effectively means "never use cached content," which forces dynamic rendering for every request.

Interestingly, the revalidate-5 page shows β—‹ despite having time-sensitive content. This happens because Next.js considers it static with periodic regeneration. Between the 5-second intervals, the page serves identical content, so it can be statically cached and only rebuilt when the revalidation timer expires.

The todos page with dynamic parameters shows Ζ’ because the [todoId] parameter means the content varies based on the URL, requiring fresh rendering for different parameter values.

Production Runtime: What Happens When You Start Your App

After building your application, you can start it in production mode using:

npm run start

This command launches your Next.js application using the optimised, built assets we just analysed. Understanding what happens at this stage helps clarify the practical differences between static and dynamic rendering.

When you start your production server and begin making requests, you'll observe the rendering behaviours we predicted from the build analysis. Static pages marked with β—‹ will respond almost instantaneously because Next.js simply serves the pre-generated HTML files from memory or disk. There's no computation, no database queries, and no API calls happening at request time.

Request Response Timing of a Static Page in Action

Dynamic pages marked with Ζ’ will take longer to respond because the server needs to execute your page components, run any data fetching functions, and generate the HTML for each request. You might notice this slight delay, especially if your dynamic pages perform complex operations or external API calls.

Request Response Timing of a Dynamic Page in Action

The revalidate-5 page presents an interesting case study in production behavior. When you first visit this page, it serves the statically generated version created during build time. However, after the 5-second revalidation period expires, the next visitor will trigger a background regeneration. Importantly, this visitor still receives the existing cached version immediately, but Next.js begins rebuilding a fresh version in the background. Subsequent visitors will then receive the newly generated version.

Seeing things in action, making an initial request to revalidate-5:

After 5 seconds on making another request, we’ll get the same cached page but the cache version will be replaced by a new page this time such that whenever a new request is made, the new page with updated date can be sent to the requester.

This background regeneration strategy, known as Incremental Static Regeneration (ISR), provides an excellent balance between performance and freshness. Users never wait for content to be generated, but the content stays reasonably up-to-date based on your revalidation schedule.

Understanding Development Mode Behavior

It's crucial to understand that development mode behaves quite differently from production. During development, Next.js priorities developer experience over performance optimisation, which can sometimes lead to confusion about how your pages will actually behave in production.

When you run npm run dev, most pages are rendered dynamically regardless of whether they could be static. This ensures that changes you make to your code are immediately visible without needing to rebuild. For example, your revalidate-5 page will show a new timestamp on every refresh during development, even though it serves cached content for 5-second intervals in production.

This difference exists because development mode assumes you're actively changing code and want to see those changes immediately. The development server essentially treats most pages as if they have dynamic = 'force-dynamic' set, ensuring that your latest code changes are always reflected in the browser.

Understanding this distinction helps prevent confusion when testing your application. Always verify your rendering behavior using the production build and start commands rather than relying solely on development mode observations. The build analysis symbols (β—‹ and Ζ’) provide the authoritative answer about how your pages will behave in production.

The Core Philosophy: Static by Default

Next.js operates on a fundamental principle: optimise for performance by defaulting to static rendering whenever possible. This approach stems from the understanding that static pages are significantly faster to serve and scale better under high traffic loads.

When Next.js encounters a page, it essentially asks itself: "Can I pre-generate this page at build time and serve the same content to all users?" If the answer is yes, it chooses static rendering. Only when the answer is no does it fall back to dynamic rendering.

This decision-making process follows a logical flow. Next.js first checks whether your page uses any dynamic functions – these are functions that inherently require request-time information, such as reading cookies, accessing request headers, or examining URL search parameters. If your page uses any of these functions, it must be rendered dynamically because the output depends on information that's only available when a request arrives.

If your page doesn't use dynamic functions, Next.js then examines your data fetching. If all the data your page needs is cached (or can be cached), and none of your data sources are marked as requiring fresh data on every request, then the page can be statically rendered.

Understanding the Random Number Paradox

Let's explore a fascinating example that often confuses developers new to Next.js static rendering. Consider this simple page:

// app/random/page.tsx
export default function RandomPage() {
  return <h1>Random number: {Math.random()}</h1>;
}

When you run this in development mode using npm run dev, you'll see a new random number every time you refresh the page. This behavior might lead you to believe the page is dynamic.

However, when you build for production and run npm run build && npm run start, you'll discover something surprising: the same random number appears on every visit! The build output will show this page with the β—‹ symbol, indicating static rendering.

This happens because during the build process, Next.js executes Math.random() once, captures the result, and bakes it into the static HTML. From that point forward, every visitor receives the same pre-generated page with the same random number.

This example perfectly illustrates the difference between development and production behavior in Next.js. Development mode prioritises developer experience and often renders pages dynamically to make debugging easier and changes more immediately visible. Production mode prioritises performance and applies aggressive optimisations, including static rendering wherever possible.

To make our random number page truly dynamic in production, we need to explicitly tell Next.js that this page requires fresh rendering:

// app/random/page.tsx
export const dynamic = 'force-dynamic';

export default function RandomPage() {
  return <h1>Random number: {Math.random()}</h1>;
}

With this change, the build output will show Ζ’ for this page, and each visitor will receive a freshly generated random number.

Managing Dynamic Components with Connection API

Sometimes you'll have components that need to be dynamic, but they're used within otherwise static pages. This scenario presents an interesting challenge: how do you signal to Next.js that a page containing a dynamic component should be rendered dynamically?

Consider this dynamic component:

// components/CurrentTime.tsx
export function CurrentTime() {
  return <p>Current time: {new Date().toISOString()}</p>;
}

If you use this component in an otherwise static page:

// app/page.tsx
import { CurrentTime } from '@/components/CurrentTime';

export default function HomePage() {
  return (
    <div>
      <h1>Welcome!</h1>
      <CurrentTime />
    </div>
  );
}

The page will likely still be rendered statically, and the time will be frozen at the build time. You could add export const dynamic = 'force-dynamic' to the page, but this becomes cumbersome when you have many pages using the same dynamic component.

Next.js provides a solution through the connection API (which replaces the deprecated unstable_noStore). This function allows components to signal that they require dynamic rendering:

// components/CurrentTime.tsx
import { connection } from 'next/server';

export async function CurrentTime() {
  // This tells Next.js that everything after this point is dynamic
  await connection();

  return <p>Current time: {new Date().toISOString()}</p>;
}

The connection function must be awaited, which means your component needs to be async. When Next.js encounters this function call during the rendering process, it automatically marks the entire page as dynamic, regardless of what other static content it might contain.

This approach is particularly powerful because it allows components to encapsulate their own rendering requirements. A component that needs dynamic behavior can declare it internally, and any page using that component will automatically adapt accordingly.

Data Fetching and Caching Implications

The relationship between data fetching and rendering mode is intricate and crucial to understand. Next.js makes rendering decisions based not just on your component code, but also on how you fetch and cache data.

Consider this data fetching scenario:

// app/posts/page.tsx
async function getPosts() {
  const response = await fetch('https://api.example.com/posts');
  return response.json();
}

export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <div>
      <h1>Latest Posts</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}

Important Note for Next.js 15: In Next.js 15, the default caching behavior for fetch() requests has changed significantly. Unlike previous versions that cached by default, Next.js 15 now defaults to cache: 'no-store' for all fetch requests. This means the page above will be dynamic by default because the data is not cached.

This change represents a philosophical shift toward more predictable behavior - what you see in development is now much closer to what happens in production. However, it also means you need to be more intentional about caching when you want static rendering.

To make this page static in Next.js 15, you would need to explicitly enable caching:

async function getPosts() {
  const response = await fetch('https://api.example.com/posts', {
    cache: 'force-cache' // Explicitly enable caching for static rendering
  });
  return response.json();
}

Alternatively, you can use time-based caching to achieve static rendering with periodic updates:

You can also use time-based revalidation:

async function getPosts() {
  const response = await fetch('https://api.example.com/posts', {
    next: { revalidate: 300 } // Revalidate every 5 minutes
  });
  return response.json();
}

This approach allows the page to remain static but ensures the data is refreshed periodically. The page will be rebuilt every 5 minutes with fresh data, combining the performance benefits of static rendering with reasonably up-to-date content.

Practical Decision Making: When to Choose What

Understanding the technical mechanisms is important, but knowing when to apply each approach is crucial for building effective applications.

Static rendering excels for content that doesn't change frequently or doesn't need to be personalised. Marketing pages, blog posts, documentation, and product catalogs are excellent candidates for static rendering. These pages benefit enormously from the performance advantages of pre-generation, and the content typically doesn't need to be fresh for every visitor.

Dynamic rendering becomes necessary when you need personalisation, real-time data, or user-specific content. User dashboards, shopping carts, social media feeds, and any page that displays data based on authentication state should be dynamically rendered.

The revalidation approach works well for content that changes periodically but doesn't need to be real-time. News sites, product listings, and content management systems often benefit from this middle ground, where content is mostly static but refreshes on a reasonable schedule.

Advanced Considerations and Performance Impact

The choice between static and dynamic rendering has profound implications for your application's performance, scalability, and user experience.

Static pages can be served from CDNs worldwide, bringing content physically closer to your users and reducing latency. They also require minimal server resources since no computation happens at request time. This makes static rendering ideal for high-traffic applications where server resources are a concern.

Dynamic rendering provides flexibility but comes with computational costs. Each request requires server processing, database queries, API calls, and HTML generation. While modern servers handle this efficiently, the accumulated cost becomes significant under high load.

The revalidation strategy offers a compromise, providing most of the performance benefits of static rendering while ensuring content freshness. However, it's important to understand that revalidation happens asynchronously – the first visitor after a revalidation period might experience slightly slower response times as the page regenerates.

Debugging and Monitoring Rendering Modes

When building complex applications, it's crucial to monitor and verify that your pages are rendering in the modes you expect. The build output provides the initial indication, but you should also verify behavior in production.

Next.js provides response headers that indicate how pages were rendered. Look for headers like x-nextjs-cache which can show values like HIT for static pages served from cache, MISS for dynamically rendered pages, or STALE for pages that were served from cache but are being regenerated in the background.

You can also add logging to your pages to understand their rendering behavior:

export default async function MyPage() {
  console.log('Page rendering at:', new Date().toISOString());

  return <div>My content</div>;
}

In production, this log will appear once during build for static pages, but for every request for dynamic pages.

Conclusion: Mastering Next.js Rendering Strategy

Understanding static versus dynamic rendering in Next.js is fundamental to building performant, scalable web applications. The framework's intelligent defaults optimise for performance while providing the flexibility to handle dynamic requirements when necessary.

Remember that Next.js starts with the assumption that everything should be static and fast. It only moves to dynamic rendering when you explicitly use dynamic features or when your data requirements demand fresh content for each request.

The key to mastering this system is understanding the decision-making process: dynamic functions force dynamic rendering, uncached data forces dynamic rendering, and explicit directives override the defaults. Everything else can potentially be static.

As you build your applications, regularly review your build output, monitor your pages' rendering behavior, and make conscious decisions about when to prioritise performance versus freshness. With this understanding, you'll be able to build Next.js applications that are both fast and functional, providing excellent user experiences while making efficient use of server resources.

The rendering strategy you choose today will impact your application's performance, scalability, and maintenance requirements for years to come. Take the time to understand these concepts deeply, and your future self will thank you for the performant, well-architected applications you build.