May 5, 2026

Next.js Dynamic Imports and Code Splitting for Faster App Router Apps

A practical guide to Next.js dynamic imports covering App Router code splitting, lazy loading components, SSR tradeoffs, loading states, and bundle review habits.

Next.js
Performance
App Router
Code Splitting
React

8 min read

Next.js Dynamic Imports and Code Splitting for Faster App Router Apps

If you are searching for Next.js dynamic imports, you are probably trying to reduce the JavaScript a route sends before users can read or click. The App Router already gives you route-level splitting and Server Components, but large client libraries, dashboards, editors, maps, charts, and rarely used modals can still make a page heavier than it needs to be.

This guide explains practical Next.js code splitting App Router patterns: when to use next/dynamic, when normal route splitting is enough, how to handle loading states, and how to review lazy loading components Next.js changes without hiding real performance problems. It fits beside Next.js Script Optimization for Third-Party Performance, Next.js Font Optimization for Faster App Router Pages, and Next.js Image Optimization Best Practices for App Router Apps, because images, fonts, scripts, and client bundles all compete for the same first-load budget. For Keval's broader Next.js work, the Next.js Developer India page and Projects page show where these performance choices matter in real interfaces.

Start with the bundle you actually ship

Code splitting is not a guessing exercise. Before adding dynamic imports, inspect the route that feels slow and identify which client code is making it expensive.

A useful first pass:

  • run a production build
  • open the route in a browser
  • check the Network panel for JavaScript chunks
  • profile hydration and first interaction
  • list large client dependencies by feature
  • separate above-the-fold UI from secondary interactions

This matters because Next.js dynamic imports can delay code, but they do not make that code free. A chart library still has to download before the chart can render. A rich text editor still needs parsing and execution time. The goal is to keep non-essential work off the initial path, not to scatter dynamic imports until the code looks optimized.

If the route is mostly static content, first ask whether a component should be a Server Component. The best client chunk is often the one you never ship.

Know what the App Router already splits

The App Router automatically splits code by route segment. A component used only by /dashboard/reports does not need to be in the first load for /blog unless it is imported through shared layouts, providers, or global client components.

That means your first code-splitting review should look for accidental sharing:

  • large widgets imported in app/layout.tsx
  • broad "use client" files that wrap too much UI
  • provider components that import feature libraries
  • utility barrels that re-export client-heavy modules
  • modals and drawers mounted globally even when rarely opened

This is where Next.js code splitting App Router work overlaps with Next.js Client Components Best Practices for App Router Apps. Keep client boundaries small, and the framework has less JavaScript to split in the first place.

// Weak pattern: a global provider imports a feature-heavy widget.
"use client";

import { AnalyticsChart } from "@/components/analytics/AnalyticsChart";

export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      <AnalyticsChart />
    </>
  );
}

If that chart only belongs on analytics routes, move it there. Route placement beats dynamic import when the component has no reason to be global.

Use next/dynamic for expensive client-only UI

Use next/dynamic when a component is not needed for the initial route experience or depends on a large browser-only dependency. Good candidates include charts, maps, calendars, syntax highlighters, video players, command palettes, rich text editors, and complex admin-only tools.

import dynamic from "next/dynamic";

const RevenueChart = dynamic(
  () => import("@/components/dashboard/RevenueChart"),
  {
    loading: () => <div className="h-72 rounded-md border" />,
  }
);

export function DashboardSummary() {
  return (
    <section>
      <h2>Revenue trend</h2>
      <RevenueChart />
    </section>
  );
}

The loading UI reserves stable space so the chart does not push content down when the chunk arrives. This is the same layout-stability discipline used for images and widgets. A dynamic import that causes a visible jump is only moving the problem later.

For lazy loading components Next.js work, prefer meaningful fallback shapes over generic "Loading..." text. The fallback should match the final component's dimensions and density.

Disable SSR only for truly browser-only components

next/dynamic supports ssr: false, but it should not be the default. Turning off server rendering means the component does not contribute useful HTML to the initial response. That may be fine for a browser-only editor or map, but it is usually wrong for content users should see immediately.

import dynamic from "next/dynamic";

const MapPreview = dynamic(() => import("@/components/location/MapPreview"), {
  ssr: false,
  loading: () => <div className="h-80 rounded-md bg-slate-100" />,
});

This is reasonable when the component requires window, WebGL, geolocation, or a vendor library that cannot run on the server. It is weaker when the component is just a product card, article preview, pricing table, or navigation element. Those should render on the server whenever possible.

Before adding ssr: false, ask:

  1. Does the component need browser APIs during render?
  2. Would missing initial HTML hurt SEO or perceived speed?
  3. Can the browser-only part move into a smaller child component?
  4. Is the fallback stable and accessible?

This keeps Next.js dynamic imports from becoming a workaround for server/client boundary mistakes.

Lazy load interaction surfaces after intent

Some features should load only after the user shows intent. A command palette, settings drawer, export dialog, or markdown editor might not belong in the initial page at all.

"use client";

import { useState } from "react";
import dynamic from "next/dynamic";

const ExportDialog = dynamic(() => import("./ExportDialog"), {
  loading: () => null,
});

export function ExportButton() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button type="button" onClick={() => setIsOpen(true)}>
        Export report
      </button>
      {isOpen ? <ExportDialog onClose={() => setIsOpen(false)} /> : null}
    </>
  );
}

This pattern is strong when the feature is optional and the first click can tolerate a small delay. If the dialog must open instantly, prefetch it when intent becomes likely:

"use client";

import { useState } from "react";

let exportDialogPromise: Promise<unknown> | null = null;

function warmExportDialog() {
  exportDialogPromise ??= import("./ExportDialog");
}

export function ExportButton() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <button
      type="button"
      onMouseEnter={warmExportDialog}
      onFocus={warmExportDialog}
      onClick={() => setIsOpen(true)}
    >
      Export report
    </button>
  );
}

Hover and focus preloading works best for desktop and keyboard users. It should be treated as an enhancement, not a dependency. The click path still needs to work when no preloading happens.

Avoid dynamic imports inside render loops

Dynamic imports should be declared at module scope, not recreated inside component render or array maps. Keep the import stable so React and Next.js can reason about the chunk.

import dynamic from "next/dynamic";

const ActivityTimeline = dynamic(() => import("./ActivityTimeline"));

export function ProjectPage() {
  return (
    <aside>
      <ActivityTimeline />
    </aside>
  );
}

Avoid this:

export function ProjectPage() {
  const ActivityTimeline = dynamic(() => import("./ActivityTimeline"));

  return <ActivityTimeline />;
}

The second version hides the component definition inside render and makes the code harder to review. Keep dynamic imports visible near the top of the file, with a fallback that explains the expected size of the delayed UI.

Split dependencies by feature, not by file count

Code splitting is about user journeys, not creating more chunks for their own sake. A route with five tiny dynamic chunks can feel worse than one well-timed chunk because each split adds coordination overhead.

Prefer feature-level splits:

  • load the charting package with the chart
  • load the editor with the editor route or modal
  • load the map SDK with the map component
  • load syntax highlighting only on code-heavy article pages
  • load admin tools only inside admin route groups

This also keeps ownership clear. If a feature becomes slow, you can inspect one chunk and one interaction path instead of chasing imports across the app.

For data-heavy UI, pair code splitting with server-first data decisions. TanStack Query with Next.js App Router: Server State Without useEffect explains how to hydrate interactive data without making every dashboard widget fetch from scratch after mount. Next.js Caching and Revalidation Guide for App Router Apps covers the server freshness side.

Review accessibility and loading states

When you delay UI, users still need a coherent experience. A dynamic component should not leave a blank hole unless the feature is truly optional and invisible until opened.

Use stable loading states for visible sections:

function ChartSkeleton() {
  return (
    <div
      className="h-72 rounded-md border border-slate-200 bg-slate-50"
      aria-label="Loading revenue chart"
      role="status"
    />
  );
}

For dialogs and command palettes, make sure focus behavior still works when the chunk loads. If the user clicks a button, the eventual dialog should receive focus, expose a clear title, and support closing with expected controls. Performance improvements should not remove basic interaction quality.

Also watch for hydration mismatch. If the server fallback and client state produce different first renders, the optimization can introduce instability. React Hydration Mismatch in Next.js: Causes, Fixes, and Prevention is the companion guide for that failure mode.

Measure the result in a production build

The review is not finished until you compare before and after. Use a production build because development mode has different bundling and runtime behavior.

npm run build
npm run start

Then inspect:

  • first-load JavaScript for the route
  • size and timing of the delayed chunk
  • interaction delay when opening lazy UI
  • layout shift around fallback replacement
  • whether the component still renders useful HTML when SSR remains enabled

If the delayed chunk loads immediately anyway because another shared component imports the same dependency, the dynamic import did not buy much. Trace the dependency and move the heavy import to the narrowest feature boundary.

Final takeaway

Good Next.js dynamic imports work starts with boundaries. Let the App Router split routes naturally, keep Server Components in charge of static UI and data loading, and use next/dynamic for expensive client UI that users do not need immediately.

The best Next.js code splitting App Router strategy is precise: split charts, maps, editors, and optional dialogs by feature; avoid broad client wrappers; reserve stable loading space; and measure the production result. Handled that way, lazy loading components Next.js patterns reduce first-load JavaScript without making the interface feel delayed, unstable, or harder to maintain.