DEV Community

Cover image for Next.js Performance: What the Defaults Don’t Solve for You
Dany Paredes
Dany Paredes

Posted on • Originally published at danywalls.com

Next.js Performance: What the Defaults Don’t Solve for You

Next.js gives you strong performance defaults. But fast by default doesn’t mean fast always.

Over the last few days, I’ve been working with Next.js and React Server Components, assuming the framework would handle most performance concerns for me. And to be fair—it does a great job out of the box.

However, I started noticing familiar patterns: duplicated server work, unnecessary JavaScript shipped to the client, and Web Vitals like First Contentful Paint (FCP) slowly getting worse.

I want to share, things that I learned while addressing those issues. Not by fighting Next.js, but by understanding how it works—and how small, intentional decisions can significantly improve performance.

Scenario

We're going to play with a typical scenario that looks like this: you have an application that fetches data from an API. That same API call is reused across multiple components—some render lists, others compute statistics, and others power visualizations.

As the app evolves, third-party libraries are added to improve the UI. Charts and dashboards work great, but they often end up included in the initial JavaScript bundle, even when they’re not immediately visible.

The result is an application that is correct, stable, and feature-complete—but performance metrics start to degrade. In particular, FCP becomes slower than expected, affecting both user experience and Web Vitals.

The important part: the application works, but needs to improve the performance.

The goal of this article is to take this existing scenario and apply a series of small, focused improvements to make it faster, lighter, and more predictable.

I recommend that if you want to follow along, download the source code:

git clone https://github.com/danywalls/improve-nextjs.git
Enter fullscreen mode Exit fullscreen mode

How Data Is Fetched and Shared Across Components

At the center of this application is a single data source: a server function responsible for fetching products from an external API.

export async function getProducts(): Promise<Product[]> {
  try {
    const response = await fetch("https://dummyjson.com/products");
    const { products }: ProductsResponse = await response.json();
    return products;
  } catch (error) {
    throw new Error("Failed to fetch products");
  }
}
Enter fullscreen mode Exit fullscreen mode

Multiple components reuse this function, and one component renders the list of products:

import { getProducts, Product } from "@/lib/products/products";
import ProductCard from "./product-card";

export default async function Products() {
  const products = await getProducts();
  return (
    <section>
      <h2 className="text-2xl font-bold">Products</h2>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {products.map((product: Product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Another component uses the same data to calculate statistics:

import { getProducts, Product } from "@/lib/products/products";

export default async function ProductsStats() {
  const products = await getProducts();
  const totalProducts = products.length;
  const avgPrice =
    products.reduce((acc: number, product: Product) => acc + product.price, 0) /
    totalProducts;
  return (
    <section className="rounded-xl bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 shadow-lg border border-gray-200 dark:border-gray-800 p-6 mb-8 flex flex-col md:flex-row items-center md:justify-between gap-8">
      <h2 className="text-2xl font-bold">Products Stats</h2>
      <p>Total Products: {totalProducts}</p>
      <p>Average Price: ${avgPrice.toFixed(2)}</p>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

At first glance, this feels perfectly reasonable. But there’s an important detail hidden here. Each component calls the same server function independently.

Let’s see why that matters.

Avoiding Duplicated Server Work with React cache

Fetching the same data multiple times during a single request is unnecessary work. It increases server load and adds latency without providing any benefit.

React provides a simple way to deduplicate this work using cache.

import { cache } from "react";

export const getProducts = cache(async (): Promise<Product[]> => {
  try {
    const response = await fetch("https://dummyjson.com/products");
    const { products }: ProductsResponse = await response.json();
    return products;
  } catch (error) {
    throw new Error("Failed to fetch products");
  }
});
Enter fullscreen mode Exit fullscreen mode

With this change, the fetch runs once per request, and the result is reused across Server Components. This works because both Products and ProductsStats are async Server Components, and React automatically shares the cached result during the same render pass.

Server efficiency is now under control—but that’s only half of the story. Let’s look at what happens on the client.

Third-party Libraries

Modern dashboards often rely on third-party libraries for charts and visualizations. In this example, the dashboard uses recharts.

"use client";

import { useEffect, useState } from "react";
import { LineChart, Line, XAxis, YAxis, ResponsiveContainer } from "recharts";
import { getProducts, Product } from "@/lib/products/products";

export default function Dashboard() {
  const [data, setData] = useState<Product[]>([]);

  useEffect(() => {
    const fetchData = async () => {
      const products = await getProducts();
      setData(products);
    };
    fetchData();
  }, []);

  return (
    <ResponsiveContainer width="100%" height={400}>
      <LineChart data={data}>
        <XAxis dataKey="title" />
        <YAxis />
        <Line dataKey="price" />
      </LineChart>
    </ResponsiveContainer>
  );
}
Enter fullscreen mode Exit fullscreen mode

This works—but it comes at a cost.

Charting libraries are heavy, and when they’re included in the initial bundle, they slow down hydration and negatively impact FCP.

The fix is not to remove the library, but to load it only when it’s needed.

Reducing Bundle Size with Dynamic Imports

First, we extract the chart into a dedicated client component:

"use client";

import { LineChart, Line, XAxis, YAxis, ResponsiveContainer } from "recharts";
import { Product } from "@/lib/products/products";

interface ChartWrapperProps {
  data: Product[];
}

export default function ChartWrapper({ data }: ChartWrapperProps) {
  return (
    <ResponsiveContainer width="100%" height={400}>
      <LineChart data={data}>
        <XAxis dataKey="title" />
        <YAxis />
        <Line dataKey="price" />
      </LineChart>
    </ResponsiveContainer>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next, in the dashboard component, use dynamic to import the component. We set ssr: false because recharts is a client-side library that uses browser APIs and cannot be rendered on the server.

"use client";

import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { getProducts, Product } from "@/lib/products/products";

const ChartWrapper = dynamic(() => import("./chartwrapper"), {
  ssr: false,
  loading: () => (
    <div className="flex items-center justify-center h-[400px] bg-gray-50 dark:bg-gray-800 rounded-lg">
      <div className="text-gray-500 dark:text-gray-400">Loading chart...</div>
    </div>
  ),
});

export default function Dashboard() {
  const [data, setData] = useState<Product[]>([]);

  useEffect(() => {
    const fetchData = async () => {
      const products = await getProducts();
      setData(products);
    };
    fetchData();
  }, []);

  return <ChartWrapper data={data} />;
}
Enter fullscreen mode Exit fullscreen mode

We get a smaller initial JS bundle - recharts is excluded from the main bundle with faster hydration - less JavaScript to parse and execute initially and chart loads only when needed - the library is downloaded on-demand when the component renders

Now that server work is deduplicated and the client bundle is lighter, we still have one remaining issue: loading coordination.

Improving Perceived Performance : Suspense

Even with optimized data fetching and smaller bundles, loading behavior matters. without coordination, parts of the UI can block each other, leading to blank spaces or delayed content.

Suspense allows us to make loading explicit and stream content progressively.

Finally, let's add Suspense to control loading behavior. With Suspense:

  • Page renders immediately - the page shell appears right away

  • Loading state is explicit - users see skeleton loaders instead of blank spaces

  • Streaming support - each Server Component can render independently as data becomes available

  • Better perceived performance - content appears progressively rather than all at once

Suspense works seamlessly with async Server Components, when a Server Component is wrapped in Suspense, React can stream its content to the client as soon as it's ready, without blocking the rest of the page.

First, create two skeleton components:

function ProductsStatsSkeleton() {
  return (
    <section className="rounded-xl bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 shadow-lg border border-gray-200 dark:border-gray-800 p-6 mb-8 flex flex-col md:flex-row items-center md:justify-between gap-8 animate-pulse">
      <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
      <div className="flex gap-4">
        <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
        <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
      </div>
    </section>
  );
}

function ProductsSkeleton() {
  return (
    <section>
      <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-6 animate-pulse"></div>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {[...Array(6)].map((_, i) => (
          <div
            key={i}
            className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden border border-gray-200 dark:border-gray-700 animate-pulse"
          >
            <div className="aspect-square bg-gray-200 dark:bg-gray-700"></div>
            <div className="p-4 space-y-3">
              <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
              <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
              <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
              <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
            </div>
          </div>
        ))}
      </div>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next, import Suspense from React and update page.tsx to wrap the async Server Components:

import { Suspense } from "react";
import Products from "./_products/products";
import Dashboard from "./_dashboard/dashboard";
import ProductsStats from "./_products/products-stats";

function ProductsStatsSkeleton() {
  return (
    <section className="rounded-xl bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 shadow-lg border border-gray-200 dark:border-gray-800 p-6 mb-8 flex flex-col md:flex-row items-center md:justify-between gap-8 animate-pulse">
      <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
      <div className="flex gap-4">
        <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
        <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
      </div>
    </section>
  );
}

function ProductsSkeleton() {
  return (
    <section>
      <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-6 animate-pulse"></div>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {[...Array(6)].map((_, i) => (
          <div
            key={i}
            className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden border border-gray-200 dark:border-gray-700 animate-pulse"
          >
            <div className="aspect-square bg-gray-200 dark:bg-gray-700"></div>
            <div className="p-4 space-y-3">
              <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
              <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
              <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
              <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
            </div>
          </div>
        ))}
      </div>
    </section>
  );
}

export default function Home() {
  return (
    <div className="space-y-12">
      <div className="text-center py-8">
        <h1 className="text-5xl font-bold text-gray-900 dark:text-white mb-4 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
          Welcome to Dashboard
        </h1>
        <p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
          Manage and analyze your products with real-time insights and beautiful
          visualizations
        </p>
      </div>

      <Suspense fallback={<ProductsStatsSkeleton />}>
        <ProductsStats />
      </Suspense>

      <Suspense fallback={<ProductsSkeleton />}>
        <Products />
      </Suspense>

      <Dashboard />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Ok, but what do we have here?

Well, with Suspense, the page shell renders immediately, each section loads independently, and users see meaningful feedback rather than blank space.

Recap

None of these changes is dramatic on its own. But together, they turn a working application into a more predictable and scalable one:

  • React cache avoids duplicated server work

  • Dynamic imports reduce the initial JavaScript bundle

  • Suspense improves perceived performance and FCP

All optimizations are applied to one connected example, making the performance impact easy to understand, Next.js provides excellent defaults—but performance is still a design decision.

The framework gives you the tools, but knowing when and how to use them is the key for you app.

Top comments (0)