New May 30, 2025

Next.js Rendering Strategies and how they affect core web vitals

Multi Author Blogs All from This Dot Labs RSS feed View Next.js Rendering Strategies and how they affect core web vitals on thisdot.co

When it comes to building fast and scalable web apps with Next.js, it’s important to understand how rendering works, especially with the App Router. Next.js organizes rendering around two main environments: the server and the client. On the server side, you’ll encounter three key strategies: Static Rendering, Dynamic Rendering, and Streaming. Each one comes with its own set of trade-offs and performance benefits, so knowing when to use which is crucial for delivering a great user experience.

In this post, we'll break down each strategy, what it's good for, and how it impacts your site's performance, especially Core Web Vitals. We'll also explore hybrid approaches and provide practical guidance on choosing the right strategy for your use case.

What Are Core Web Vitals?

Core Web Vitals are a set of metrics defined by Google that measure real-world user experience on websites. These metrics play a major role in search engine rankings and directly affect how users perceive the speed and smoothness of your site.

If you want to dive deeper into Core Web Vitals and understand more about their impact on your website's performance, I recommend reading this detailed guide on New Core Web Vitals and How They Work.

Next.js Rendering Strategies and Core Web Vitals

Let's explore each rendering strategy in detail:

1. Static Rendering (Server Rendering Strategy)

Static Rendering is the default for Server Components in Next.js. With this approach, components are rendered at build time (or during revalidation), and the resulting HTML is reused for each request. This pre-rendering happens on the server, not in the user's browser. Static rendering is ideal for routes where the data is not personalized to the user, and this makes it suitable for:

How Static Rendering Affects Core Web Vitals

Code Examples:

  1. Basic static rendering:
// app/page.tsx (Server Component - Static Rendering by default)
export default async function Page() {
  const res = await fetch('https://api.example.com/static-data');
  const data = await res.json();
  return (
    <div>
      <h1>Static Content</h1>
      <p>{data.content}</p>
    </div>
  );
}
  1. Static rendering with revalidation (ISR):
// app/dashboard/page.tsx
export default async function Dashboard() {
  // Static data that revalidates every day
  const siteStats = await fetch('https://api.example.com/site-stats', {
    next: { revalidate: 86400 } // 24 hours
  }).then(r => r.json());

// Data that revalidates every hour const popularProducts = await fetch('https://api.example.com/popular-products', { next: { revalidate: 3600 } // 1 hour }).then(r => r.json());

// Data with a cache tag for on-demand revalidation const featuredContent = await fetch('https://api.example.com/featured-content', { next: { tags: ['featured'] } }).then(r => r.json());

return ( <div className="dashboard"> <section className="stats"> <h2>Site Statistics</h2> <p>Total Users: {siteStats.totalUsers}</p> <p>Total Orders: {siteStats.totalOrders}</p> </section>

<section className="popular"> <h2>Popular Products</h2> <ul> {popularProducts.map(product => ( <li key={product.id}>{product.name} - {product.sales} sold</li> ))} </ul> </section>

<section className="featured"> <h2>Featured Content</h2> <div>{featuredContent.html}</div> </section> </div> ); }

  1. Static path generation:
// app/products/[id]/page.tsx
export async function generateStaticParams() {
  const products = await fetch('https://api.example.com/products').then(r => r.json());

return products.map((product) => ({ id: product.id.toString(), })); }

export default async function Product({ params }) { const product = await fetch(https://api.example.com/products/${params.id}).then(r => r.json());

return ( <div> <h1>{product.name}</h1> <p>${product.price.toFixed(2)}</p> <p>{product.description}</p> </div> ); }

2. Dynamic Rendering (Server Rendering Strategy)

Dynamic Rendering generates HTML on the server for each request at request time. Unlike static rendering, the content is not pre-rendered or cached but freshly generated for each user. This kind of rendering works best for:

How Dynamic Rendering Affects Core Web Vitals

Code Examples:

  1. Explicit dynamic rendering:
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // Force this route to be dynamically rendered

export default async function Dashboard() { // This will run on every request const data = await fetch('https://api.example.com/dashboard-data').then(r => r.json());

return ( <div> <h1>Dashboard</h1> <p>Last updated: {new Date().toLocaleString()}</p> {/* Dashboard content */} </div> ); }

  1. Simplicit dynamic rendering with cookies:
// app/profile/page.tsx
import { cookies } from 'next/headers';

export default async function Profile() { // Using cookies() automatically opts into dynamic rendering const userId = cookies().get('userId')?.value;

const user = await fetch(https://api.example.com/users/${userId}).then(r => r.json());

return ( <div> <h1>Welcome, {user.name}</h1> <p>Email: {user.email}</p> {/* Profile content */} </div> ); }

  1. Dynamic routes:
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
  // It will run at request time for any slug not explicitly pre-rendered
  const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json());

return ( <article> <h1>{post.title}</h1> <div>{post.content}</div> </article> ); }

3. Streaming (Server Rendering Strategy)

Streaming allows you to progressively render UI from the server. Instead of waiting for all the data to be ready before sending any HTML, the server sends chunks of HTML as they become available. This is implemented using React's Suspense boundary.

React Suspense works by creating boundaries in your component tree that can "suspend" rendering while waiting for asynchronous operations. When a component inside a Suspense boundary throws a promise (which happens automatically with data fetching in React Server Components), React pauses rendering of that component and its children, renders the fallback UI specified in the Suspense component, continues rendering other parts of the page outside this boundary, and eventually resumes and replaces the fallback with the actual component once the promise resolves.

When streaming, this mechanism allows the server to send the initial HTML with fallbacks for suspended components while continuing to process suspended components in the background. The server then streams additional HTML chunks as each suspended component resolves, including instructions for the browser to seamlessly replace fallbacks with final content. It works well for:

How Streaming Affects Core Web Vitals

Code Examples:

  1. Basic Streaming with Suspense:
// app/dashboard/page.tsx
import { Suspense } from 'react';
import UserProfile from './components/UserProfile';
import RecentActivity from './components/RecentActivity';
import PopularPosts from './components/PopularPosts';

export default function Dashboard() { return ( <div className="dashboard"> {/* This loads quickly */} <h1>Dashboard</h1>

{/* User profile loads first */} <Suspense fallback={<div className="skeleton-profile">Loading profile...</div>}> <UserProfile /> </Suspense>

{/* Recent activity might take longer */} <Suspense fallback={<div className="skeleton-activity">Loading activity...</div>}> <RecentActivity /> </Suspense>

{/* Popular posts might be the slowest */} <Suspense fallback={<div className="skeleton-posts">Loading popular posts...</div>}> <PopularPosts /> </Suspense> </div> ); }

  1. Nested Suspense boundaries for more granular control:
// app/complex-page/page.tsx
import { Suspense } from 'react';

export default function ComplexPage() { return ( <Suspense fallback={<PageSkeleton />}> <Header />

<div className="content-grid"> <div className="main-content"> <Suspense fallback={<MainContentSkeleton />}> <MainContent /> </Suspense> </div>

<div className="sidebar"> <Suspense fallback={<SidebarTopSkeleton />}> <SidebarTopSection /> </Suspense>

<Suspense fallback={<SidebarBottomSkeleton />}> <SidebarBottomSection /> </Suspense> </div> </div>

<Footer /> </Suspense> ); }

  1. Using Next.js loading.js convention:
// app/products/loading.tsx - This will automatically be used as a Suspense fallback
export default function Loading() {
  return (
    <div className="products-loading-skeleton">
      <div className="header-skeleton" />
      <div className="filters-skeleton" />
      <div className="products-grid-skeleton">
        {Array.from({ length: 12 }).map((_, i) => (
          <div key={i} className="product-card-skeleton" />
        ))}
      </div>
    </div>
  );
}

// app/products/page.tsx export default async function ProductsPage() { // This component can take time to load // Next.js will automatically wrap it in Suspense // and use the loading.js as the fallback const products = await fetchProducts();

return <ProductsList products={products} />; }

4. Client Components and Client-Side Rendering

Client Components are defined using the React 'use client' directive. They are pre-rendered on the server but then hydrated on the client, enabling interactivity. This is different from pure client-side rendering (CSR), where rendering happens entirely in the browser. In the traditional sense of CSR (where the initial HTML is minimal, and all rendering happens in the browser), Next.js has moved away from this as a default approach but it can still be achievable by using dynamic imports and setting ssr: false.

// app/csr-example/page.tsx
'use client';

import { useState, useEffect } from 'react'; import dynamic from 'next/dynamic';

// Lazily load a component with no SSR const ClientOnlyComponent = dynamic( () => import('../components/heavy-component'), { ssr: false, loading: () => <p>Loading...</p> } );

export default function CSRPage() { const [isClient, setIsClient] = useState(false);

useEffect(() => { setIsClient(true); }, []);

return ( <div> <h1>Client-Side Rendered Page</h1> {isClient ? ( <ClientOnlyComponent /> ) : ( <p>Loading client component...</p> )} </div> ); }

Despite the shift toward server rendering, there are valid use cases for CSR:

  1. Private dashboards: Where SEO doesn't matter, and you want to reduce server load
  2. Heavy interactive applications: Like data visualization tools or complex editors
  3. Browser-only APIs: When you need access to browser-specific features like localStorage or WebGL
  4. Third-party integrations: Some third-party widgets or libraries that only work in the browser

While these are valid use cases, using Client Components is generally preferable to pure CSR in Next.js. Client Components give you the best of both worlds: server-rendered HTML for the initial load (improving SEO and LCP) with client-side interactivity after hydration. Pure CSR should be reserved for specific scenarios where server rendering is impossible or counterproductive.

Client components are good for:

How Client Components Affect Core Web Vitals

Code Examples:

  1. Basic Client Component:
// app/components/Counter.tsx
'use client';

import { useState } from 'react';

export default function Counter() { const [count, setCount] = useState(0);

return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); }

  1. Client Component with server data:
// app/products/page.tsx - Server Component
import ProductFilter from '../components/ProductFilter';

export default async function ProductsPage() { // Fetch data on the server const products = await fetch('https://api.example.com/products').then(r => r.json());

// Pass server data to Client Component as props return <ProductFilter initialProducts={products} />; }

Hybrid Approaches and Composition Patterns

In real-world applications, you'll often use a combination of rendering strategies to achieve the best performance. Next.js makes it easy to compose Server and Client Components together.

Server Components with Islands of Interactivity

One of the most effective patterns is to use Server Components for the majority of your UI and add Client Components only where interactivity is needed. This approach:

  1. Minimizes JavaScript sent to the client
  2. Provides excellent initial load performance
  3. Maintains good interactivity where needed
// app/products/[id]/page.tsx - Server Component
import AddToCartButton from '../../components/AddToCartButton';
import ProductReviews from '../../components/ProductReviews';
import RelatedProducts from '../../components/RelatedProducts';

export default async function ProductPage({ params }: { params: { id: string; } }) { // Fetch product data on the server const product = await fetch(https://api.example.com/products/${params.id}).then(r => r.json());

return ( <div className="product-page"> <div className="product-main"> <h1>{product.name}</h1> <p className="price">${product.price.toFixed(2)}</p> <div className="description">{product.description}</div>

{/* Client Component for interactivity */} <AddToCartButton product={product} /> </div>

{/* Server Component for product reviews */} <ProductReviews productId={params.id} />

{/* Server Component for related products */} <RelatedProducts categoryId={product.categoryId} /> </div> ); }

Partial Prerendering (Next.js 15)

Next.js 15 introduced Partial Prerendering, a new hybrid rendering strategy that combines static and dynamic content in a single route. This allows you to:

  1. Statically generate a shell of the page
  2. Stream in dynamic, personalized content
  3. Get the best of both static and dynamic rendering

Note: At the time of this writing, Partial Prerendering is experimental and is not ready for production use. Read more

// app/dashboard/page.tsx
import { unstable_noStore as noStore } from 'next/cache';
import StaticContent from './components/StaticContent';
import DynamicContent from './components/DynamicContent';

export default function Dashboard() { return ( <div className="dashboard"> {/* This part is statically generated */} <StaticContent />

{/* This part is dynamically rendered */} <DynamicPart /> </div> ); }

// This component and its children will be dynamically rendered function DynamicPart() { // Opt out of caching for this part noStore();

return <DynamicContent />; }

Measuring Core Web Vitals in Next.js

Understanding the impact of your rendering strategy choices requires measuring Core Web Vitals in real-world conditions. Here are some approaches:

1. Vercel Analytics

If you deploy on Vercel, you can use Vercel Analytics to automatically track Core Web Vitals for your production site:

// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';

export default function RootLayout({ children }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> {children} <Analytics /> </body> </html> ); }

2. Web Vitals API

You can manually track Core Web Vitals using the web-vitals library:

// app/components/WebVitalsReporter.tsx
'use client';

import { useEffect } from 'react'; import { onCLS, onINP, onLCP } from 'web-vitals';

export function WebVitalsReporter() { useEffect(() => { // Report Core Web Vitals onCLS(metric => console.log('CLS:', metric.value)); onINP(metric => console.log('INP:', metric.value)); onLCP(metric => console.log('LCP:', metric.value));

// In a real app, you would send these to your analytics service }, []);

return null; // This component doesn't render anything }

3. Lighthouse and PageSpeed Insights

For development and testing, use:

Making Practical Decisions: Which Rendering Strategy to Choose?

Choosing the right rendering strategy depends on your specific requirements. Here's a decision framework:

Choose Static Rendering when

Choose Dynamic Rendering when

Choose Streaming when

Choose Client Components when

Conclusion

Next.js provides a powerful set of rendering strategies that allow you to optimize for both performance and user experience. By understanding how each strategy affects Core Web Vitals, you can make informed decisions about how to build your application.

Remember that the best approach is often a hybrid one, combining different rendering strategies based on the specific requirements of each part of your application. Start with Server Components as your default, use Static Rendering where possible, and add Client Components only where interactivity is needed.

By following these principles and measuring your Core Web Vitals, you can create Next.js applications that are fast, responsive, and provide an excellent user experience.

Scroll to top