Marcos Cámara

Stop Calling Next.js Slow: Master These Optimization Techniques

April 21, 2025 (3m ago)0 views

In recent months, there has been growing discussion about Next.js and its reputation for being slow, especially during initial page loads or when using server components. This perception is often the result of not fully understanding or utilizing Next.js’s optimization features and rendering strategies, rather than a true limitation of the framework.

Next.js actually offers powerful tools for performance, such as hybrid rendering (SSG, SSR, ISR), advanced caching, parallel data fetching, Suspense for smoother UX, and automatic image optimization. When used correctly, these features enable Next.js apps to be fast and highly responsive. In this article, I’ll explain why the “Next.js is slow” narrative exists and share practical ways to unlock the real speed of your Next.js applications.

  1. Hybrid Rendering in Next.js: Mixing SSG, SSR, and ISR for Ultimate Flexibility
  2. unstable_cache in Action: Smarter, Faster Data Fetching
  3. Instant Feedback, Smoother UX: The Power of Suspense in Next.js 15
  4. Advanced Image Optimization Techniques
  5. Optimizing Data Fetching in Next.js with SWRConfig and Suspense
  6. Parallel Data Fetching: Maximizing Efficiency in Next.js Applications

1. Hybrid Rendering in Next.js: Mixing SSG, SSR, and ISR for Ultimate Flexibility

Next.js makes it easy to combine different rendering strategies in the same project. This flexibility helps you get the best performance and user experience for each type of content in your app.[2]

Static Site Generation (SSG)

Best for: Pages with content that rarely changes (e.g., About, Contact).

// app/about/page.tsx
export default function About() {
  return (
    <main>
      <h1>About Us</h1>
      <p>We are a company focused on innovation and customer service.</p>
    </main>
  );
}

How it works: The HTML for this page is generated at build time and served quickly from a CDN. Great for SEO and speed.

Incremental Static Regeneration (ISR)

Best for: Pages that update every so often (e.g., blog posts, product pages).

// app/blog/[slug]/page.tsx
export const revalidate = 3600; // Regenerate every hour

async function getPost(slug) {
  const res = await fetch(`https://api.example.com/posts/${slug}`);
  return res.json();
}

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);

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

How it works: The page is generated at build time, but Next.js will automatically update it in the background every hour. Users always see a fast page, and the content stays fresh.

Server-Side Rendering (SSR)

Best for: Pages with personalized or real-time data (e.g., user dashboards).

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

async function getUserData() {
  const res = await fetch('https://api.example.com/user/dashboard', {
    cache: 'no-store' // Always get fresh data
  });
  return res.json();
}

export default async function Dashboard() {
  const user = await getUserData();

  return (
    <main>
      <h1>Welcome, {user.name}!</h1>
      <p>Your last login: {new Date(user.lastLogin).toLocaleString()}</p>
    </main>
  );
}

How it works: The page is generated on the server every time a user visits, so the data is always up to date.

You can use different strategies for different routes:

2. unstable_cache in Action: Smarter, Faster Data Fetching

unstable_cache allows you to cache the result of expensive operations (like database queries) and reuse them across requests, boosting performance.[2]

unstable_cache receives three parameters:

const cachedFn = unstable_cache(fetchData, keyParts, options)

Example:

import { unstable_cache } from 'next/cache';

const getProduct = unstable_cache(
  async (id: string) => db.products.findUnique({ where: { id } }),
  ['product-details'],
  { tags: ['products'], revalidate: 3600 }
);

Revalidation Strategies

Time-based (TTL):

{ revalidate: 60 } // Revalidates every minute

Manual invalidation with revalidateTag:

import { revalidateTag } from 'next/cache';
revalidateTag('products'); // Invalidates all caches with the 'products' tag

Path-based invalidation:

revalidatePath('/products/[id]');

Caching with Dynamic Variables

const getUserData = unstable_cache(
  async (userId) => {
    const session = await auth();
    return db.users.find({ userId, org: session.org });
  },
  [userId, 'user-profile'], // Include variables in the cache key
  { tags: [`user-${userId}`] }
);

Usage in Server Components

export default async function Profile({ params }) {
  const user = await getCachedUser(params.id);
  return <div>{user.name}</div>;
}

Full Example with Invalidation

// app/api/products/route.ts
import { unstable_cache, revalidateTag } from 'next/cache';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const productId = searchParams.get('id');

  const product = await unstable_cache(
    () => db.products.findUnique({ where: { id: productId } }),
    ['product', productId],
    { tags: [`product-${productId}`] }
  )();

  return Response.json(product);
}

export async function POST(request: Request) {
  const data = await request.json();
  await db.products.update({ where: { id: data.id }, data });
  revalidateTag(`product-${data.id}`);
  return Response.json({ success: true });
}

3. Instant Feedback, Smoother UX: The Power of Suspense in Next.js 15

What is Suspense in Next.js 15?

Suspense is a React feature that lets you show a loading indicator (a "fallback") while waiting for data or code to load. In Next.js 15, Suspense is deeply integrated with the framework's server components, making it much easier to handle loading states and improve user experience.[2]

In simple terms:

Why use Suspense in Next.js 15?

Example: ShopPage and ProductList with Suspense

Here's a practical example showing how Suspense works in a Next.js 15 shop page:

// app/shop/page.js (Server Component)
import { Suspense } from 'react';
import ProductList from './ProductList';

export default function ShopPage() {
  return (
    <div className="shop">
      <h1>Our Products</h1>

      <Suspense fallback={<div className="loading">Loading products...</div>}>
        <ProductList />
      </Suspense>
    </div>
  );
}
// app/shop/ProductList.js (Server Component)
async function ProductList() {
  // Server-side data fetching
  const products = await fetch('https://api.example.com/products')
    .then(res => res.json());

  return (
    <div className="product-grid">
      {products.map(product => (
        <Product key={product.id} product={product} />
      ))}
    </div>
  );
}

function Product({ product }) {
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <p>${product.price}</p>

      {/* Client-side interactivity wrapped in Suspense */}
      <Suspense fallback={<button disabled>...</button>}>
        <AddToCartButton productId={product.id} />
      </Suspense>
    </div>
  );
}
// app/shop/AddToCartButton.js (Client Component)
'use client';

import { useMutation } from '@tanstack/react-query';

async function addToCart(productId) {
  const res = await fetch('/api/cart', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ productId }),
  });
  if (!res.ok) throw new Error('Failed to add to cart');
  return res.json();
}

export function AddToCartButton({ productId }) {
  const mutation = useMutation({
    mutationFn: () => addToCart(productId),
  });

  return (
    <button
      onClick={() => mutation.mutate()}
      disabled={mutation.isPending}
      className="add-to-cart"
    >
      {mutation.isPending ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

How does this work?

  1. ShopPage serves as the main page and uses <Suspense> to wrap the product list. While the product data is loading, users see "Loading products...".
  2. ProductList fetches product data on the server and renders product cards. Each card displays product info and an interactive "Add to Cart" button.
  3. AddToCartButton is a client component (marked with 'use client') that handles the interactive logic. It's wrapped in its own <Suspense>, so each button hydrates independently.

Benefits

4. Advanced Image Optimization Techniques

Next.js 15's Image component offers advanced features for optimizing images automatically, improving user experience, and ensuring responsive, high-performance visuals in modern web apps.[2]

Key Features:

Example: Advanced Responsive Image Gallery

import Image from 'next/image';

export default function ProductGallery({ images }) {
  return (
    <div className="gallery">
      {images.map((image) => (
        <Image
          key={image.id}
          src={image.url}
          width={600}
          height={400}
          placeholder="blur"
          blurDataURL={image.blurDataUrl}
          sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
          priority={image.isPrimary}
          quality={image.isPrimary ? 85 : 75}
          alt={image.alt}
        />
      ))}
    </div>
  );
}

What does this implementation achieve?

5. Optimizing Data Fetching in Next.js with SWRConfig and Suspense

SWR (Stale-While-Revalidate) is a powerful React hook for data fetching that significantly improves user experience by serving cached data first while revalidating in the background. When combined with React Suspense, it creates an elegant solution for handling loading states in modern web applications.[2]

Implementation Example with SWRConfig

Let's look at a detailed implementation of SWRConfig in a Next.js 15 application:

// app/layout.tsx
import type React from "react";
import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { SWRConfig } from "swr";
import { getDashboardData } from "./data";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "SaaS Dashboard",
  description: "SaaS Dashboard UI",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <SWRConfig
          value={{
            fallback: {
              // We do NOT await this function here
              // Only components that read this data will suspend
              "/api/dashboard": getDashboardData(),
            },
            suspense: true,
          }}
        >
          {children}
        </SWRConfig>
      </body>
    </html>
  );
}

This example sets up a global SWRConfig wrapper that:

  1. Provides a fallback data source for /api/dashboard
  2. Enables Suspense mode for all SWR hooks
  3. Delegates the actual data fetching to a function called getDashboardData

The Data Fetching Function

// app/data.ts
export async function getDashboardData() {
  // Fetch data from your API endpoint
  const response = await fetch('https://api.example.com/dashboard', {
    // In Next.js 15, this prevents caching at the fetch level
    cache: 'no-store',
  });

  if (!response.ok) {
    throw new Error(`Failed to fetch dashboard data: ${response.status}`);
  }

  return response.json();
}

Dashboard Page with Suspense

// app/dashboard/page.tsx
import { Suspense } from 'react';
import DashboardContent from './dashboard-content';
import DashboardSkeleton from './dashboard-skeleton';

export default function DashboardPage() {
  return (
    <div className="dashboard-container">
      <h1>Dashboard</h1>
      <Suspense fallback={<DashboardSkeleton />}>
        <DashboardContent />
      </Suspense>
    </div>
  );
}

Client Component to Consume the Data

// app/dashboard/dashboard-content.tsx
'use client';

import useSWR from 'swr';

interface DashboardData {
  stats: {
    activeUsers: number;
    revenue: number;
    conversion: number;
  };
  recentActivity: Array<{
    id: string;
    action: string;
    timestamp: string;
  }>;
}

export default function DashboardContent() {
  // The hook will use the fallback data from SWRConfig initially
  // and will revalidate in the background
  const { data } = useSWR<DashboardData>('/api/dashboard');

  return (
    <div className="dashboard-grid">
      <div className="stats-panel">
        <div className="stat-card">
          <h3>Active Users</h3>
          <p className="stat-value">{data.stats.activeUsers}</p>
        </div>
        <div className="stat-card">
          <h3>Revenue</h3>
          <p className="stat-value">${data.stats.revenue.toLocaleString()}</p>
        </div>
        <div className="stat-card">
          <h3>Conversion Rate</h3>
          <p className="stat-value">{data.stats.conversion}%</p>
        </div>
      </div>

      <div className="recent-activity">
        <h3>Recent Activity</h3>
        <ul>
          {data.recentActivity.map((activity) => (
            <li key={activity.id} className="activity-item">
              <span>{activity.action}</span>
              <time>{new Date(activity.timestamp).toLocaleString()}</time>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

Key Benefits of This Implementation

This implementation offers several advantages:

  1. Instant Initial Load: Users see content immediately from the fallback data
  2. Graceful Loading States: Components using the data can leverage Suspense for loading states
  3. Background Revalidation: SWR refreshes data in the background without disrupting the user experience
  4. Error Handling: SWR provides built-in error handling
  5. Type Safety: TypeScript interfaces ensure data consistency
  6. Clear Separation of Concerns: Each component has a specific responsibility

Handling Data Mutations

For user interactions that update data, you can use SWR's mutate function:

'use client';

import useSWR from 'swr';

export default function ActionButton() {
  const { mutate } = useSWR('/api/dashboard');

  const handleRefresh = async () => {
    await mutate(); // This will trigger a revalidation
  };

  return (
    <button
      onClick={handleRefresh}
      className="px-4 py-2 bg-blue-500 text-white rounded"
    >
      Refresh Data
    </button>
  );
}

6. Parallel Data Fetching: Maximizing Efficiency in Next.js Applications

While Next.js doesn't provide a dedicated API for parallel data fetching, its integration with native JavaScript patterns unlocks significant performance gains. This approach combines standard language features with Next.js-specific caching controls to optimize data loading.[2]

The Power of Promise.all + Next.js Caching

This implementation pattern leverages JavaScript's Promise.all alongside Next.js's enhanced fetch options:

// Product page with parallel data fetching and caching strategies
export default async function ProductPage({ params }) {
  const [product, recommendations, reviews] = await Promise.all([
    // Core product data - frequent revalidation
    fetch(`https://api.store.com/products/${params.id}`, {
      next: { revalidate: 60 } // Refresh every minute
    }).then(r => r.json()),

    // Recommendations - stale-while-revalidate
    fetch(`https://api.store.com/recommendations?for=${params.id}`, {
      next: { revalidate: 3600 } // Refresh hourly
    }).then(r => r.json()),

    // Reviews - always fresh
    fetch(`https://api.store.com/reviews?product=${params.id}`, {
      cache: 'no-store' // Bypass caching
    }).then(r => r.json())
  ]);

  return (
    <div>
      <ProductDetails product={product} />
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews reviews={reviews} />
      </Suspense>
      <Suspense fallback={<RecommendationsSkeleton />}>
        <ProductRecommendations items={recommendations} />
      </Suspense>
    </div>
  );
}

How It Works

All three fetch requests: product details, recommendations, and reviews are launched at the same time using Promise.all. This means the total page load time is determined only by the slowest request, instead of adding up the time for each one. By starting all requests together, you avoid the "waterfall" effect where each fetch waits for the previous one to finish, which can significantly slow down the page.

Performance Impact

By fetching all data in parallel, the page avoids long cumulative load times. For example, if each API call takes about 200ms, loading them one after another would take around 600ms. With parallel fetching, the whole process only takes as long as the slowest call, about 200ms in this case. Caching recommendations for longer also reduces the number of API calls by up to 70–80%, saving server resources and improving repeat visit speed. Meanwhile, reviews always show the most up-to-date content, which is great for user trust and engagement.

Why This Pattern Matters

This pattern uses standard JavaScript features like Promise.all, so it's compatible with any framework. However, Next.js enhances the experience by providing fine-grained cache control and features like automatic request deduplication and incremental static regeneration, which keep cached data fresh in the background. In real-world scenarios, this approach has led to lower bounce rates and noticeably faster load times for e-commerce and content-heavy sites.