Code splitting is the practice of breaking a JavaScript bundle into smaller chunks that the browser fetches on demand rather than all at once. In the Next.js App Router, the framework splits along route segment boundaries automatically — every page.tsx becomes its own entry point — but automatic splitting stops at the route level. Anything imported statically inside a route lands in that route’s chunk, whether or not every visitor needs it. Closing that gap is the work this post covers.

The Application and Its Starting Point

The target was a Next.js 14 project using the App Router: a marketing site with a blog, a pricing page, and an account dashboard. Two third-party libraries sat at the top of the dependency tree — a rich text renderer used only by blog post pages, and a charting library used only by the dashboard’s analytics tab. Both were imported statically at the top of their respective route files, in the ordinary way:

// app/(marketing)/blog/[slug]/page.tsx
import { RichTextRenderer } from '@/components/rich-text-renderer';
import { getPostBySlug } from '@/lib/posts';

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
  return <RichTextRenderer content={post.body} />;
}
// app/(dashboard)/analytics/page.tsx
'use client';
import { AnalyticsChart } from '@/components/analytics-chart';
import { useAnalyticsData } from '@/lib/hooks';

export default function AnalyticsPage() {
  const data = useAnalyticsData();
  return <AnalyticsChart data={data} />;
}

Running next build with ANALYZE=true (the @next/bundle-analyzer package) produced the baseline numbers. First Load JS for the blog post route was 312 kB, of which 148 kB was the rich text renderer. First Load JS for the analytics route was 287 kB, of which 164 kB was the charting library plus its dependencies. Both libraries are needed, but not by every route, and not on every load of those routes — a blog reader scrolling a page may never trigger a chart, and a dashboard visitor lands on the overview tab, not analytics, most of the time.

Splitting the Route-Level Import with next/dynamic

The first attempt used the App Router’s next/dynamic helper, which wraps React.lazy with a few Next.js-specific options. Because the chart component depends on browser APIs and a 'use client' boundary is already in place at the page level, this was the simplest possible case:

// app/(dashboard)/analytics/page.tsx
'use client';
import dynamic from 'next/dynamic';
import { useAnalyticsData } from '@/lib/hooks';

const AnalyticsChart = dynamic(() => import('@/components/analytics-chart'), {
  loading: () => <div className="chart-skeleton" />,
  ssr: false,
});

export default function AnalyticsPage() {
  const data = useAnalyticsData();
  return <AnalyticsChart data={data} />;
}

Two options are doing real work here. ssr: false skips server-rendering the chart, which is correct for a component that reads window dimensions and would throw during prerender. loading supplies a fallback so the layout does not collapse while the chunk downloads — without it, the page shifts when the chart appears, and Cumulative Layout Shift climbs.

In practice, that approach to rebuilding can confirm the change: the analytics route’s First Load JS can fall from the high hundreds of kB to well over a hundred kB, with the charting library moved to a separate chunk fetched only after hydration. The trade-off is a visible delay before the chart renders — on a throttled connection the skeleton was visible for roughly 400 ms in testing. For a secondary dashboard tab, that delay is acceptable. For the primary view a user lands on, it would not be.

The Server Component Case

Applying the same pattern to the blog post page failed, and the failure is instructive. The RichTextRenderer sits in a Server Component tree — no 'use client' directive, no browser APIs — and next/dynamic with ssr: false is not permitted inside a Server Component. Attempting it produces a build error, because the App Router’s server renderer has no hydration step for later chunks to attach to.

The correct pattern in this case is React.lazy combined with Suspense, which the App Router supports natively on the server. React.lazy is called directly, not through next/dynamic:

// app/(marketing)/blog/[slug]/page.tsx
import { Suspense, lazy } from 'react';
import { getPostBySlug } from '@/lib/posts';

const RichTextRenderer = lazy(() => import('@/components/rich-text-renderer'));

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
  return (
    <Suspense fallback={<div className="prose-skeleton" />}>
      <RichTextRenderer content={post.body} />
    </Suspense>
  );
}

This is where the App Router diverges from the old Pages Router mental model. In Pages Router, next/dynamic was the standard tool for everything and ssr: false was common because the router itself was a Client Component by default. In the App Router, the boundary is explicit: next/dynamic is for client components; React.lazy plus Suspense is the way to defer work inside Server Components. Mixing them up is the single most common cause of “my dynamic import doesn’t do anything” reports.

Rebuilt, the blog post route’s First Load JS can drop from several hundred kB to under two hundred kB, with the renderer in a separate chunk. A reader whose connection stalls between page load and scroll gets the shell immediately and the renderer when it arrives — no longer blocking LCP on a library most of the page’s HTML does not need.

What Next.js Already Splits, and What It Doesn’t

The measurements above can create the impression that manual splitting is the dominant lever. In practice, the framework is already doing most of the segmentation, and it is worth knowing where the automatic boundaries sit before adding manual ones.

  • Route segments. Every page.tsx, layout.tsx, and route.ts under app/ produces its own chunk. Navigating from /blog to /blog/some-post fetches only the new segments, not a re-download of shared layout JS.
  • Server vs. Client Components. Anything without 'use client' runs on the server and ships zero JavaScript to the browser. This is the largest single reduction available in the App Router and it requires no dynamic() call at all — just removing the directive where interactivity is not needed.
  • Shared vendor chunks. Next.js groups dependencies used by multiple routes into a common chunk automatically. Manually splitting a library that three routes share will often produce three smaller chunks whose combined download is larger than the single shared one it replaced.

The last point is the failure mode to guard against. A dynamic() call on a component that renders on every route adds a request without removing any bytes from the critical path; the browser now stalls on a separate network round trip where before it had the code inline. Verify each split against next build output before trusting it.

Verifying the Split, Not Just Assuming It

The @next/bundle-analyzer output is the ground truth here. After each change, the route’s chunk list either shows the library moved to a separate file with its own size, or it does not — no interpretation required. Two additional signals matter:

next build prints a First Load JS column per route. If a route’s number did not drop after a dynamic() call, the library is probably still being pulled in through a static import somewhere up the tree — a barrel file (index.ts re-exporting everything) is a frequent culprit, because importing one named export from a barrel pulls the whole barrel into the chunk.

Lighthouse on the deployed preview measures the user-visible result: LCP for the route, Total Blocking Time during hydration, and whether the deferred chunk arrives before or after the user interacts. In testing on the analytics route, TBT can drop by a substantial amount after splitting — the charting library’s initialization is no longer competing with the initial render for main-thread time.

When Not to Split

Not every import deserves a dynamic() call. Three cases consistently argue against it:

A component that renders in the initial viewport of the route it belongs to should not be deferred. The skeleton flash costs more in perceived performance than the saved bytes recover, and if the component is above the fold it participates in LCP — deferring it means deferring the metric itself.

A library under roughly 20 kB gzipped rarely earns the extra request. The savings are small enough that the added network round trip and the skeleton state are a net negative in most measurements. Use the bundle analyzer to check the actual number rather than estimating.

Anything that must be present for the first paint — critical text, layout chrome, hero imagery — belongs in the static bundle. Code splitting is a tool for delaying work the user has not asked for yet, not for delaying work they are already looking at.

The general shape of the workflow is stable: build with the analyzer, identify the large dependencies, check whether they are needed on every load of the route, split only the ones that are not, and re-measure. The framework handles the horizontal split across routes on its own; the vertical split inside a route is where the remaining gains live, and it is worth being deliberate about each one.