Stop Calling Next.js Slow: Master These Optimization Techniques
@marcoscamara01|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.
- Hybrid Rendering in Next.js: Mixing SSG, SSR, and ISR for Ultimate Flexibility
- unstable_cache in Action: Smarter, Faster Data Fetching
- Instant Feedback, Smoother UX: The Power of Suspense in Next.js 15
- Advanced Image Optimization Techniques
- Optimizing Data Fetching in Next.js with SWRConfig and Suspense
- 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:
- Use SSG for static pages (About, Contact)
- Use ISR for pages that update regularly (Blog, Products)
- Use SSR for pages that need real-time or user-specific data (Dashboard)
#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)
- fetchData: An async function returning the data to cache.
- keyParts: An array that uniquely identifies the cache entry (especially useful when using external variables).
- options: Advanced config: tags: for selective invalidation, revalidate: time in seconds for automatic revalidation.
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:
- You wrap a component that needs to load data with
<Suspense>
. - While the data is loading, users see a fallback UI (like a skeleton).
- When the data is ready, the real component appears.
Why use Suspense in Next.js 15?
- Cleaner code: No need for manual loading state variables.
- Better user experience: Users see content as soon as it's ready, not after everything loads.
- Performance: Next.js 15 streams HTML and hydrates only the interactive parts, making pages faster.
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?
- ShopPage serves as the main page and uses
<Suspense>
to wrap the product list. While the product data is loading, users see "Loading products...". - ProductList fetches product data on the server and renders product cards. Each card displays product info and an interactive "Add to Cart" button.
- 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
- 🚀 Most of the page is server-rendered (great for SEO and performance)
- 📦 Only the interactive button is hydrated on the client (smaller JS bundle)
- ⚡ Users see product info immediately, with buttons becoming interactive shortly after
- 🔄 Automatic loading states without manual conditionals
#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:
- Responsive Sizing: Use the sizes property to control how images scale across devices. Next.js generates multiple resolutions and serves the best one for each device.
- Priority & Lazy Loading: The priority property ensures critical images load immediately for better Largest Contentful Paint (LCP), while others are lazy-loaded by default.
- Blurred Placeholders: With
placeholder="blur"
andblurDataURL
, users see a smooth, low-res preview while the full image loads. - Automatic Format Conversion: Next.js serves modern formats (WebP/AVIF) if supported, falling back to JPEG/PNG, and allows custom quality settings.
- Remote Image Support: Images from external domains can be optimized by configuring allowed domains in next.config.js.
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?
- Responsive images that adapt to any device and resolution.
- Priority loading for the main image, improving perceived speed and LCP.
- Blurred placeholders for a smooth visual experience while images load.
- Automatic conversion to modern formats like WebP or AVIF when possible.
- Custom quality settings based on image importance.
#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:
- Provides a fallback data source for /api/dashboard
- Enables Suspense mode for all SWR hooks
- 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:
- Instant Initial Load: Users see content immediately from the fallback data
- Graceful Loading States: Components using the data can leverage Suspense for loading states
- Background Revalidation: SWR refreshes data in the background without disrupting the user experience
- Error Handling: SWR provides built-in error handling
- Type Safety: TypeScript interfaces ensure data consistency
- 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.