【Next.js】Client Componentの中にServer Componentを混ぜてバンドルが膨らんだ話

JavaScript

こんにちは、かつコーチです。

前回まででNext.jsの基本ルーティングとServer Actionsを使ったデータ更新まで解説してきました。

ここからはN11〜N15の5本で、Server ComponentsとClient Componentsをより実践的に使いこなす応用編に入ります。

初回のテーマは、私が実際にプロダクトコードで踏み抜いた「バンドルサイズが想定外に膨らんだ」失敗談です。

何が起きるか

Server ComponentとClient Componentの境界線

Next.jsのApp Routerでは、コンポーネントは基本的にServer Component(サーバー側でのみ実行され、JavaScriptとしてブラウザに送られない)です。

ファイルの先頭に'use client'を書くと、そのファイルとそこからimportされるモジュールはClient Componentの境界に入ります。

Client Componentはブラウザで実行する必要があるため、クライアント向けJavaScriptバンドルに含まれます。

importしただけでバンドルに混入する

問題は、Client Componentの中でServer Componentを普通にimportして使ってしまうケースです。

Next.jsのビルドは、Client Componentからimportされたモジュールを「クライアントでも動く必要があるもの」として扱います。

そのためServer Component側でしか使わないはずの重いライブラリ(画像処理、Markdownパーサー、DBクライアントなど)まで、クライアントバンドルに巻き込まれてしまいます。

基本の書き方

手順1:問題が起きる書き方を再現する

まず、実際に問題が起きるコードを見てみます。

// app/components/ArticleBody.tsx(Server Component)
import { marked } from "marked"; // Markdownパーサー(サーバー専用の想定)

interface Props {
  markdown: string;
}

export function ArticleBody({ markdown }: Props) {
  const html = marked.parse(markdown);
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// app/components/LikeButton.tsx(Client Component)
'use client';

import { useState } from "react";
import { ArticleBody } from "./ArticleBody"; // ← Server Componentをimport

export function LikeButton({ markdown }: { markdown: string }) {
  const [liked, setLiked] = useState(false);

  return (
    <div>
      <ArticleBody markdown={markdown} />
      <button onClick={() => setLiked(!liked)}>
        {liked ? "いいね済み" : "いいね"}
      </button>
    </div>
  );
}

LikeButton'use client'が付いているので、ここからimportされたArticleBodyもクライアントバンドルの対象になります。

ArticleBodyが内部で使っているmarkedライブラリごと、ブラウザに送られてしまうのです。

手順2:Composition Patternで解決する

正しい解決策は、Server Componentをimportするのではなく、children(またはprops)として渡す形に変えることです。

これは公式にも推奨されているComposition Patternと呼ばれる書き方です。

// app/components/LikeButton.tsx(Client Component)
'use client';

import { useState } from "react";
import type { ReactNode } from "react";

interface Props {
  children: ReactNode;
}

export function LikeButton({ children }: Props) {
  const [liked, setLiked] = useState(false);

  return (
    <div>
      {children}
      <button onClick={() => setLiked(!liked)}>
        {liked ? "いいね済み" : "いいね"}
      </button>
    </div>
  );
}
// app/page.tsx(Server Component)
import { LikeButton } from "./components/LikeButton";
import { ArticleBody } from "./components/ArticleBody";

export default function Page() {
  return (
    <LikeButton>
      <ArticleBody markdown="# こんにちは" />
    </LikeButton>
  );
}

LikeButtonはもうArticleBodyを直接importしていません。

親であるServer Component(page.tsx)側でArticleBodyをレンダリングし、その結果(JSXツリー)をchildrenとしてLikeButtonに渡しているだけです。

ArticleBody自体はサーバーで実行され、結果のHTMLだけがクライアントに送られるので、markedライブラリはクライアントバンドルに含まれません。

手順3:バンドルサイズを確認する習慣をつける

この手のimportミスは、見た目では気づきにくいのが厄介なところです。

next buildを実行すると、ルートごとのバンドルサイズが表示されるので、想定より数十KB〜数百KB大きい箇所がないか確認するとよいです。

npm run build

出力の「First Load JS」の欄が、そのページを開いたときにブラウザがダウンロードするJavaScriptの目安になります。

つまずきポイント・一次情報の体験談

実際に起きたこと

私がハマったのは、記事詳細ページに「いいねボタン」を追加したときでした。

いいねボタン自体は単純なuseStateだけのClient Componentで、実装は10分もかからずに終わりました。

ところが動作確認のためにChrome DevToolsのNetworkタブを見たところ、そのページのJavaScriptが他のページより明らかに大きいことに気づきました。

原因の調査

next buildのログを見ると、記事詳細ページの「First Load JS」だけが突出して大きく、差分は約180KBもありました。

原因を探るために@next/bundle-analyzerを導入し、バンドルの中身を可視化してみたところ、Markdownパーサーのmarkedがクライアントバンドルの中にしっかり含まれていることが判明しました。

❌ Before:Client ComponentがServer Componentを直接import

'use client';

import { ArticleBody } from "./ArticleBody"; // marked込みでバンドルされる

export function LikeButton({ markdown }: { markdown: string }) {
  // ...
  return <ArticleBody markdown={markdown} />;
}

原因は明らかで、LikeButton(Client Component)がArticleBody(内部でmarkedを使うServer Component)をそのままimportしていたことでした。

'use client'の境界を跨いだimportは、そこから先すべてがクライアント扱いになるという仕組みを、頭では知っていても実装時にうっかり見落としていたのです。

✅ After:Composition Patternでchildrenとして渡す

先ほど紹介した手順2の形に書き換えたところ、next buildのログで「First Load JS」が約180KB減少しました。

@next/bundle-analyzerで確認すると、markedはクライアントバンドルから完全に消えていました。

この経験から、Client Componentを書くときは「このコンポーネントは本当にimportしているものすべてをブラウザで動かす必要があるか」を毎回自問するようになりました。

見分け方のコツ

チーム開発では、以下の観点でレビューすると事故を防ぎやすいです。

  • 'use client'が付いたファイルのimport文を見て、DBアクセスや重いライブラリを使うモジュールが混ざっていないか確認する
  • 「操作(クリック、入力)だけをClient Component化し、表示内容はServer Componentのまま渡す」という発想を基本にする
  • 迷ったらComposition Patternでchildren/propsとして渡せないか、先に検討する

まとめ

この記事のポイント

  • 'use client'を付けたファイルからimportされたモジュールは、Server Componentであってもクライアントバンドルに含まれてしまう
  • 重いライブラリを使うServer Componentを誤ってimportすると、バンドルサイズが大きく膨らむ原因になる
  • 解決策は、Server Componentをimportせずchildren/propsとして親から渡すComposition Patternを使うこと
  • next buildのログや@next/bundle-analyzerで、定期的にバンドルサイズの異常をチェックする習慣が有効

次に読むべき記事

Composition Patternを使いこなすには、そもそもServer Componentでどうデータを取得しているかの理解が欠かせません。

次回は、Server Componentでのデータフェッチの基本を解説します。

→ 次の記事:Server Componentでのデータフェッチの基本

タイトルとURLをコピーしました