Concept
The beginner framing: Next.js performance work at the app level is about shipping less JavaScript to the browser, understanding where your bundle size actually comes from, and observing production behavior well enough to catch regressions.
The precise mental model: Server Components are automatically code-split by default, this is one of the App Router's foundational performance properties, covered in Server/Client Component Composition. next/dynamic exists for the other half: deferring a Client Component's own bundle until it's actually needed, rather than including it in the initial JavaScript payload.
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
const HeavyModal = dynamic(() => import("../components/HeavyModal")); // separate bundle, loaded on demand
export default function Page() {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(true)}>Open</button>
{open && <HeavyModal />} {/* HeavyModal's code doesn't load until THIS renders */}
</div>
);
}next/dynamic is a composite of React's own React.lazy() and <Suspense>, it behaves identically whether used in the App Router or the Pages Router, which is what makes it useful for incremental migrations too.
ssr: false, only meaningful for Client Components
const ClientOnlyWidget = dynamic(() => import("./ClientOnlyWidget"), { ssr: false });By default, a dynamically-imported Client Component is still prerendered (SSR'd) the first time. Setting ssr: false skips that prerendering entirely, appropriate for something that can only run in the browser at all (directly reading window at module scope, for instance). This option only has an effect on Client Components, it does nothing meaningful applied to a Server Component, since a Server Component was never going to ship JavaScript to the client (or be "SSR skipped" in the same sense) regardless.
Dynamically importing a Server Component: a real, current limitation
import dynamic from "next/dynamic";
const ServerWidget = dynamic(() => import("./ServerWidget")); // a Server ComponentIf you dynamically import a Server Component, only the Client Component children nested inside it actually get lazy-loaded, the Server Component itself was never going to ship JS anyway, so there's nothing to defer there. This pattern is still useful specifically because it helps preload static assets (like CSS) associated with that subtree. One current, documented gotcha: when a Server Component dynamically imports a Client Component, automatic code splitting for that Client Component is not currently supported, worth verifying rather than assuming in a specific version.
Bundle analysis and observability
The @next/bundle-analyzer plugin visualizes what's actually contributing to your JavaScript bundle size, the standard tool for identifying an unexpectedly large dependency before it becomes a production problem (see Server/Client Component Composition's bundle-inflation discussion for the most common root cause: an overly-broad "use client" boundary).
// instrumentation.ts, runs ONCE per server instance startup, before it accepts requests
import { registerOTel } from "@vercel/otel";
export function register() {
registerOTel("next-app");
}