Shipping Less JavaScript: A Guide to Bundle Splitting

In the last part, we mastered server rendering to deliver HTML to the user in record time. But the job isn’t done. Even a perfectly server-rendered page can feel sluggish if it’s followed by a massive JavaScript bundle that blocks the main thread and delays interactivity. The final boss of frontend performance is often the payload size itself.

The Silent Performance Killer: Bundle Bloat

Every npm install is a potential performance liability. While the rich JavaScript ecosystem gives us powerful tools, it also makes it easy to accumulate bloat. Common culprits include:

  • Heavy SDKs: Analytics tools, feature flagging libraries, and payment processors.
  • Complex UI Libraries: Charting libraries (like D3), rich text editors, or extensive date formatters (like Moment.js).
  • The GraphQL Client Itself: A full-featured client like Apollo Client, with its caching and state management capabilities, adds significant weight.

To diagnose this, you can use tools like @next/bundle-analyzer to visualize what’s inside your bundles. The first step to solving the problem is seeing it.

Strategy 1: Route-Based Splitting (The Next.js Superpower)

This is the most fundamental and impactful form of code splitting, and Next.js gives it to you for free. Every file you create in the pages directory is automatically treated as a separate entry point. Its code is only loaded when a user navigates to that route.

This means the code for your /dashboard isn’t slowing down your /pricing page. By simply structuring your application with Next.js, you’ve already implemented a powerful code-splitting strategy.

Strategy 2: Component-Level Splitting with dynamic()

What about heavy components used within a page? A complex modal, a data visualization, or a rich text editor doesn’t need to be loaded until the user actually needs it.

Next.js provides the dynamic() function, a powerful wrapper around React.lazy and Suspense, for this exact purpose.

Before: The Eager Load

// This entire component and its dependencies are in the initial page bundle.
import RichTextEditor from '../components/RichTextEditor';

function MyPage() {
  // ...
  return <RichTextEditor />;
}

After: The Smart, Dynamic Load

import dynamic from 'next/dynamic';

// The editor is now in its own JS chunk, loaded only when MyPage renders.
const RichTextEditor = dynamic(() => import('../components/RichTextEditor'));

function MyPage() {
  // ...
  return <RichTextEditor />;
}

You can even disable server-side rendering for components that rely on browser-only APIs like the window object:

const MyClientOnlyComponent = dynamic(
  () => import('../components/MyClientOnlyComponent'),
  { ssr: false }
);

Strategy 3: Selective SDK and Client Loading

This is a more advanced pattern for ultra-lean pages. Ask yourself: Does every single page need the full, interactive GraphQL client?

Consider a purely static marketing page generated with getStaticProps. It has no interactive queries and doesn’t need a complex caching layer. By default, if your ApolloProvider is in _app.js, you’re still shipping the entire client library to that page.

Instead, you can apply the provider selectively. Create a higher-order component or a layout that wraps only the parts of your application that are truly interactive.

// pages/static-marketing-page.js
function StaticPage({ content }) {
  return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
// This page gets no ApolloProvider.

// pages/dashboard.js
function DashboardPage({ data }) {
  return <Dashboard data={data} />;
}

// Wrap the page component in a layout that provides the client.
DashboardPage.getLayout = function getLayout(page) {
  return <AuthenticatedLayout>{page}</AuthenticatedLayout>;
};

This ensures your leanest pages are just that—lean.

Conclusion: The “Just-in-Time” Philosophy

The goal is to adopt a “just-in-time” philosophy for your frontend assets. Ship only the critical code needed for the initial view, and strategically load the rest as the user interacts with the page. With modern frameworks like Next.js, these powerful optimizations are accessible and don’t require sacrificing developer experience.

Now that our application is fast and lean, how do we make it resilient? In Part 6, we’ll tackle Error Handling and Resilience, ensuring our UI remains graceful even when the API or network misbehaves.