0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · 강의

동적 가져오기, 코드 분할과 지연 하이드레이션

next/dynamic으로 중요하지 않은 컴포넌트의 로드를 늦추고 상호작용 가능 시간을 줄이도록 로딩을 조정하는 방법을 배웁니다.

동적 가져오기, 코드 분할과 지연 하이드레이션은(는) CoddyKit의 무료 Next.js 15 Fullstack (App Router + Server Actions) 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Next.js 15 Fullstack (App Router + Server Actions) 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

Why Code Splitting Matters in Next.js 15

Modern Next.js applications can grow large fast. Without code splitting, the browser downloads every component and dependency on the first page load — even code the user may never need.

Next.js 15 splits your bundle automatically at the route level using the App Router. But route-level splitting alone is not enough. Consider these common performance killers:

  • A rich text editor loaded on every page but only used in the admin dashboard
  • A heavy chart library rendered below the fold
  • A modal or drawer that is never opened by most visitors

For these cases you need component-level code splitting via next/dynamic and lazy hydration strategies. Together they reduce your initial JavaScript payload, improve Time-to-Interactive (TTI), and raise your Core Web Vitals scores.

Introducing next/dynamic

next/dynamic is Next.js's wrapper around React.lazy with additional features tailored for SSR. It returns a component that is loaded on demand — the JavaScript for that component is placed in a separate chunk and only fetched when the component is about to render.

Basic usage is straightforward:

  • Import dynamic from 'next/dynamic'
  • Pass a factory function that returns a dynamic import()
  • Optionally pass a loading fallback component shown while the chunk downloads

The resulting component can be used exactly like any normal React component in your JSX.

'use client';

import dynamic from 'next/dynamic';

// The HeavyEditor chunk is NOT included in the initial bundle.
// It is fetched only when <HeavyEditor /> is first rendered.
const HeavyEditor = dynamic(
  () => import('@/components/HeavyEditor'),
  {
    loading: () => <p>Loading editor…</p>,
  }
);

export default function AdminPage() {
  return (
    <main>
      <h1>Admin Dashboard</h1>
      <HeavyEditor />
    </main>
  );
}

Disabling SSR for Client-Only Components

Some components depend on browser APIs (window, document, localStorage) and cannot run on the server at all. Attempting SSR on these causes hydration mismatches or runtime errors.

next/dynamic supports an ssr: false option that tells Next.js to skip server rendering entirely for that component. The placeholder (or nothing) is sent in the HTML, and the real component is mounted only in the browser.

Common use cases for ssr: false:

  • Canvas / WebGL renderers
  • Browser-only animation libraries (e.g. GSAP ScrollTrigger)
  • Components that read window.matchMedia on mount
  • Third-party widgets that inject into document.body
'use client';

import dynamic from 'next/dynamic';

// This component uses `window` and `document` internally.
// ssr: false prevents Next.js from attempting to render it on the server.
const ConfettiBlast = dynamic(
  () => import('@/components/ConfettiBlast'),
  {
    ssr: false,
    loading: () => null, // render nothing until JS loads
  }
);

export default function CelebrationBanner() {
  return (
    <section>
      <h2>You did it! 🎉</h2>
      <ConfettiBlast particleCount={200} />
    </section>
  );
}

Named Exports and next/dynamic

By default next/dynamic expects the imported module to have a default export. When you need to dynamically import a named export, you must extract it inside the factory function.

This is done by returning the named export from the async factory, effectively making it the default for the dynamic wrapper:

'use client';

import dynamic from 'next/dynamic';

// Module exports: { LineChart, BarChart, PieChart }
// We only need LineChart — extract it inside the factory.
const LineChart = dynamic(
  () =>
    import('@/components/charts/ChartLibrary').then(
      (mod) => mod.LineChart
    ),
  { loading: () => <div className="h-64 animate-pulse bg-gray-100" /> }
);

interface SalesChartProps {
  data: { month: string; revenue: number }[];
}

export default function SalesChart({ data }: SalesChartProps) {
  return <LineChart data={data} width={600} height={300} />;
}

Conditional Dynamic Imports — Load on Interaction

The most powerful pattern is to defer a component until the user actually needs it — triggered by a click, hover, or scroll event. This avoids loading the chunk even during idle time.

The approach uses React state to conditionally render the dynamically imported component. Until the user interacts, the chunk is never requested. Once they click (or trigger the condition), React renders the dynamic component and the browser fetches the chunk on demand.

This pattern is ideal for:

  • Modals and drawers opened by a button
  • Settings panels
  • Video players that start on play
  • Comment sections below a long article
'use client';

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

const FeedbackModal = dynamic(
  () => import('@/components/FeedbackModal'),
  { loading: () => <p>Opening…</p> }
);

export default function FeedbackButton() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button
        onClick={() => setOpen(true)}
        className="btn-primary"
      >
        Leave Feedback
      </button>

      {/* FeedbackModal chunk is only fetched after the first click */}
      {open && (
        <FeedbackModal onClose={() => setOpen(false)} />
      )}
    </>
  );
}

Lazy Hydration with 'use client' Boundaries

In the App Router, components are React Server Components (RSC) by default. Only components marked 'use client' ship JavaScript to the browser and hydrate.

This means you get free lazy hydration by keeping components as Server Components whenever possible. The server renders the HTML; no hydration cost is paid at all.

When you do need interactivity, push the 'use client' boundary as far down the tree as possible — to the exact leaf component that needs event handlers or state. Parent layout and wrapper components stay as RSC and add zero client JS.

  • Bad: Mark the entire page layout as 'use client' because one button needs onClick
  • Good: Extract only the button into its own 'use client' component; the rest stays RSC
// app/products/[id]/page.tsx — Server Component (no 'use client')
import { getProduct } from '@/lib/db';
import AddToCartButton from '@/components/AddToCartButton'; // 'use client'

interface PageProps {
  params: { id: string };
}

export default async function ProductPage({ params }: PageProps) {
  // Runs on the server — zero client JS for this component
  const product = await getProduct(params.id);

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p className="price">${product.price}</p>

      {/* Only this leaf component ships client JS */}
      <AddToCartButton productId={product.id} />
    </article>
  );
}

Intersection Observer — Hydrate on Scroll

Components below the fold do not need to hydrate immediately. A popular pattern is to use the Intersection Observer API to defer mounting (and therefore hydrating) a component until it scrolls into the viewport.

You can combine this with next/dynamic to achieve true lazy hydration: the JS chunk is requested only when the component enters the viewport, and mounting happens right after the chunk arrives.

Libraries like react-intersection-observer make this ergonomic. The pattern below mounts CommentsSection only once the user scrolls near it:

'use client';

import { useRef, useState, useEffect } from 'react';
import dynamic from 'next/dynamic';

const CommentsSection = dynamic(
  () => import('@/components/CommentsSection'),
  { loading: () => <div className="h-32 animate-pulse bg-gray-100" /> }
);

export default function ArticlePage() {
  const sentinelRef = useRef<HTMLDivElement>(null);
  const [showComments, setShowComments] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setShowComments(true);
          observer.disconnect(); // hydrate once, then stop observing
        }
      },
      { rootMargin: '200px' } // start loading 200px before viewport
    );

    if (sentinelRef.current) observer.observe(sentinelRef.current);
    return () => observer.disconnect();
  }, []);

  return (
    <article>
      <h1>A Very Long Article</h1>
      <p>…article content…</p>

      {/* Sentinel sits at the bottom; triggers chunk fetch when visible */}
      <div ref={sentinelRef} />
      {showComments && <CommentsSection />}
    </article>
  );
}

Preloading Chunks on Hover

Waiting until a click to start downloading a chunk adds latency: the user sees a spinner while the network request completes. A smarter UX is to preload the chunk on hover — typically 100–300 ms before the user clicks, which is often enough time for the chunk to arrive.

next/dynamic exposes a static .preload() method on the returned component. Calling it triggers the dynamic import without rendering anything, priming the browser cache so that when the component mounts it is nearly instant.

'use client';

import dynamic from 'next/dynamic';

const ShareDialog = dynamic(
  () => import('@/components/ShareDialog')
);

import { useState } from 'react';

export default function ShareButton() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button
        // Preload the chunk as soon as the user hovers
        onMouseEnter={() => ShareDialog.preload()}
        // Open (render) on click — chunk is likely already cached
        onClick={() => setOpen(true)}
        className="btn-secondary"
      >
        Share
      </button>

      {open && <ShareDialog onClose={() => setOpen(false)} />}
    </>
  );
}

Analyzing Bundle Size with @next/bundle-analyzer

Before you can optimise bundle size you need to see it. The @next/bundle-analyzer package wraps Webpack Bundle Analyzer and generates a visual treemap of every module in your build.

Setup is two steps:

  • Install: npm install --save-dev @next/bundle-analyzer
  • Wrap your Next.js config with the analyzer

Run ANALYZE=true next build and two browser tabs open — one for the client bundle, one for the server bundle. Look for:

  • Unexpectedly large modules in the initial chunk
  • Duplicate libraries (e.g. two versions of date-fns)
  • Libraries that should be dynamically imported but appear in the main chunk
// next.config.ts
import type { NextConfig } from 'next';
import bundleAnalyzer from '@next/bundle-analyzer';

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
  openAnalyzer: true,
});

const nextConfig: NextConfig = {
  // your existing config…
  experimental: {
    optimizePackageImports: [
      // Tell Next.js to tree-shake these icon/component libraries
      // so only the icons you actually import are bundled
      '@heroicons/react',
      'lucide-react',
      '@radix-ui/react-icons',
    ],
  },
};

export default withBundleAnalyzer(nextConfig);

Dynamic Imports in Server Components

You can also use dynamic import() inside Server Components — not via next/dynamic, but via plain ES dynamic import. The result is still server-rendered; the benefit is conditional loading on the server: you avoid importing heavy modules when they are not needed for a given request.

A typical case is locale-specific data, feature-flag-gated renderers, or optional plugins loaded based on configuration:

// app/report/page.tsx — Server Component
import type { NextPage } from 'next';

interface ReportPageProps {
  searchParams: { format?: string };
}

const ReportPage: NextPage<ReportPageProps> = async ({ searchParams }) => {
  const format = searchParams.format ?? 'html';

  if (format === 'pdf') {
    // Heavy PDF renderer is only imported when the query param is 'pdf'.
    // It never reaches the browser — this is pure server-side splitting.
    const { renderPDF } = await import('@/lib/pdf-renderer');
    const pdfBuffer = await renderPDF({ title: 'Q2 Report' });

    return new Response(pdfBuffer, {
      headers: { 'Content-Type': 'application/pdf' },
    }) as unknown as JSX.Element;
  }

  // Default: lightweight HTML version
  const { ReportView } = await import('@/components/ReportView');
  return <ReportView />;
};

export default ReportPage;

Measuring Impact — Core Web Vitals

Code splitting and lazy hydration directly improve two Core Web Vitals:

  • LCP (Largest Contentful Paint) — less blocking JS means the browser paints the largest element sooner
  • INP (Interaction to Next Paint) — smaller main-thread work during load means the page responds faster to the first user tap

Measure before and after your changes using:

  • next build && next start + Chrome DevTools Lighthouse (local, reproducible)
  • web-vitals npm package + the useReportWebVitals hook from next/navigation to log metrics to your analytics backend in production
  • Vercel Speed Insights or Google Search Console for real-user data

A common result after switching a heavy component to next/dynamic: the initial JS payload drops by 30–60 KB (gzipped), translating to 200–500 ms faster TTI on a median mobile connection.

Knowledge Check: When to Use ssr: false

You are integrating a third-party mapping library that accesses window.navigator.geolocation synchronously during module initialisation. Which next/dynamic configuration is correct and why?

Recap — Dynamic Imports and Lazy Hydration

In this lesson you learned how to cut time-to-interactive in Next.js 15 App Router applications by deferring JavaScript that is not needed upfront.

Key takeaways:

  • next/dynamic splits a component into a separate chunk fetched on demand — reducing the initial JS payload
  • ssr: false skips server rendering for components that rely on browser-only APIs, preventing runtime errors
  • Named exports are handled by extracting them inside the factory: import(…).then(mod => mod.Named)
  • Conditional rendering ({open && <Modal />}) defers the chunk fetch until the first render of the component
  • Component.preload() on hover primes the cache before the user clicks, hiding network latency
  • Keeping 'use client' boundaries as narrow as possible gives you free lazy hydration for the RSC parts of your tree
  • Intersection Observer enables viewport-triggered hydration for below-the-fold content
  • @next/bundle-analyzer and useReportWebVitals let you measure the real impact of these optimisations

Apply these techniques to the heaviest components in your bundle first — editors, charts, maps, and media players — for the greatest TTI gains.

자주 묻는 질문

“동적 가져오기, 코드 분할과 지연 하이드레이션” 강의는 무료인가요?

네 — “동적 가져오기, 코드 분할과 지연 하이드레이션” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Next.js 15 Fullstack (App Router + Server Actions) 강의 전체를 잠금 해제할 수 있습니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.

“동적 가져오기, 코드 분할과 지연 하이드레이션”에서 뭘 배우나요?

next/dynamic으로 중요하지 않은 컴포넌트의 로드를 늦추고 상호작용 가능 시간을 줄이도록 로딩을 조정하는 방법을 배웁니다. 브라우저에서 직접 실행하는 실습 코드로 Next.js 15 Fullstack (App Router + Server Actions)을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

Next.js 15 Fullstack (App Router + Server Actions)을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 Next.js 15 Fullstack (App Router + Server Actions)은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.

“동적 가져오기, 코드 분할과 지연 하이드레이션” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 Next.js 15 Fullstack (App Router + Server Actions) 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 Next.js 15 Fullstack (App Router + Server Actions) 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 클라이언트 번들 분석과 축소
  2. Turbopack과 컴파일러 설정 심층 분석
  3. server-only와 client-only를 활용한 모듈 경계
  4. 동적 가져오기, 코드 분할과 지연 하이드레이션
← Next.js 15 Fullstack (App Router + Server Actions)(으)로 돌아가기