React, Vue.js
投稿日:

AI駆動開発でNext.js App Routerのコンポーネント設計を爆速化する

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。

先日、バイクのツーリングルートを計画していて、山道を行くか海沿いを行くか迷っていたんです。「どっちが景色いいですか?」とAIに聞いてみたら、「目的地によります」と返ってきました。そりゃそうだ。目的地を伝えなければ、最適なルートは出せない。

これ、Next.jsのコンポーネント設計にそのまま当てはまる話だったりします。

Next.js 13以降のApp Routerでは、Server ComponentsとClient Componentsを使い分ける必要があります。この「使い分け」がなかなか厄介で、何をサーバーに置いて何をクライアントに置くかの判断を間違えると、パフォーマンスが悪くなったり、そもそもビルドが通らなかったりします。

ここでAI駆動開発が威力を発揮します。「このページのデータフローを整理して、Server ComponentsとClient Componentsの境界を提案してください」とAIに聞くと、設計のたたき台が一瞬で出てくる。もちろんそのまま使えるわけではありませんが、ゼロから考えるよりも圧倒的に速いです。

今回は、実際の案件で使ったワークフローをベースに、AIにApp Routerのコンポーネント設計を相談しながら実装する方法を紹介します。

Server ComponentsとClient Components、何が悩ましいのか

App Routerの基本ルールはシンプルです。

  • デフォルトはServer Component(サーバーで実行、クライアントにJavaScriptを送らない)
  • 'use client'を宣言するとClient Component(ブラウザで実行、インタラクティブ)

シンプルなはずなのに、実際に設計を始めると「このコンポーネントはどっち?」という判断が連続します。

たとえば、ECサイトの商品詳細ページを考えてみてください。商品データの取得はサーバーでやりたい。でも「カートに入れる」ボタンはクライアントでインタラクションが必要。レビューの一覧はサーバーで十分だけど、レビューの投稿フォームはクライアント。画像ギャラリーのスライダーは? 価格の税込/税抜の切替は?

バイクのルート計画と同じで、「目的地(ページの要件)」が明確でないと、最適な配置は決まりません。ここがAIに相談するポイントです。

AIにコンポーネント設計を相談するワークフロー

ステップ1:ページの要件をAIに伝える

AIに漠然と「商品詳細ページを設計して」と聞いても、抽象的な回答しか返ってきません。ポイントはデータの流れとインタラクションを具体的に伝えることです。

私が実際にClaudeに送ったプロンプトはこんな感じです。

Next.js App Router(TypeScript)で商品詳細ページを設計します。
以下の要件に基づいて、Server ComponentsとClient Componentsの
境界を提案してください。

【データ】
- 商品情報(名前、価格、説明、画像URL配列)はAPIから取得
- レビュー一覧はAPIから取得(ページネーションあり)
- カートの状態はクライアント側で管理(zustand)

【インタラクション】
- 画像ギャラリー:サムネイルクリックでメイン画像切替
- 数量選択:プルダウンで1〜10を選択
- カートに入れるボタン:クリックでzustandのストアに追加
- レビュー投稿フォーム:テキスト入力+星評価+送信
- 価格表示:税込/税抜の切替トグル

【制約】
- 初回表示はできるだけ速く(LCPを意識)
- SEOが必要(商品情報とレビューはサーバーでレンダリング)

ここが重要です。「データ」「インタラクション」「制約」の3つを分けて伝えると、AIの出力精度が格段に上がります。ギターのTAB譜を読むときと同じで、「どの弦を(データ)、どのフレットで(インタラクション)、どのリズムで(制約)」が明確なら、演奏は正確になります。

ステップ2:AIの提案を受け取って評価する

AIから返ってきた設計案のコアとなる部分がこちらです。

// app/products/[id]/page.tsx(Server Component)
import { getProduct, getReviews } from '@/lib/api';
import { ProductImages } from './ProductImages';
import { AddToCart } from './AddToCart';
import { ReviewList } from './ReviewList';
import { ReviewForm } from './ReviewForm';

type Props = {
  params: Promise<{ id: string }>;
};

export default async function ProductPage({ params }: Props) {
  const { id } = await params;
  const [product, reviews] = await Promise.all([
    getProduct(id),
    getReviews(id),
  ]);

  return (
    <main className="max-w-5xl mx-auto px-4 py-8">
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        {/* Client: 画像のスライド操作が必要 */}
        <ProductImages images={product.images} />

        <div>
          {/* Server: 静的な商品情報 */}
          <h1 className="text-2xl font-bold">{product.name}</h1>
          <p className="mt-2 text-gray-600">{product.description}</p>

          {/* Client: 税込/税抜切替、数量選択、カート追加 */}
          <AddToCart product={product} />
        </div>
      </div>

      <section className="mt-12">
        <h2 className="text-xl font-bold mb-4">レビュー</h2>
        {/* Server: レビュー一覧はSEO対象 */}
        <ReviewList reviews={reviews} />
        {/* Client: フォーム入力が必要 */}
        <ReviewForm productId={id} />
      </section>
    </main>
  );
}

この設計案自体は悪くありません。Server Componentをトップレベルに置き、インタラクションが必要な部分だけをClient Componentに切り出しています。AIが正しく判断できたポイントは以下の3つです。

  • ページ全体をServer Componentにして、データ取得をサーバーで完結させている
  • 画像ギャラリー、カート操作、レビューフォームをClient Componentに分離している
  • Promise.allで商品データとレビューを並行取得している

ただし、私が修正した箇所もあります。

ステップ3:AIの設計を人間が修正する

AIの提案をそのまま使わなかった部分は2点です。

修正1:AddToCartコンポーネントのpropsが大きすぎる

AIの初版では<AddToCart product={product} />と商品オブジェクト全体を渡していました。Client Componentに渡すpropsは、サーバーからクライアントへシリアライズされます。商品オブジェクトに画像URLの配列や長い説明文が含まれていると、無駄なデータがクライアントに送られます。

// AI初版:productオブジェクト全体を渡している
<AddToCart product={product} />

// 修正版:必要なプロパティだけを渡す
<AddToCart
  productId={product.id}
  name={product.name}
  price={product.price}
  taxRate={product.taxRate}
/>

これはバイクの荷造りと同じです。ツーリングに行くのに家財道具を全部積む人はいません。必要なものだけを選んで積む。Server → Clientのデータ転送も同じ発想です。

修正2:レビュー一覧のページネーションが考慮されていない

AIの提案では<ReviewList reviews={reviews} />とレビュー全件を渡していましたが、実案件ではレビューが数百件あります。初回は10件だけサーバーでレンダリングし、「もっと見る」ボタンでクライアント側から追加取得する設計にしました。

// 修正版:ReviewListをServer/Clientのハイブリッドに
// ReviewList.tsx(Server Component)
import { ReviewLoadMore } from './ReviewLoadMore';

type Props = {
  initialReviews: Review[];
  productId: string;
  totalCount: number;
};

export function ReviewList({ initialReviews, productId, totalCount }: Props) {
  return (
    <div>
      {initialReviews.map(review => (
        <div key={review.id} className="border-b py-4">
          <div className="flex items-center gap-2">
            <span className="text-yellow-500">{'★'.repeat(review.rating)}</span>
            <span className="text-sm text-gray-500">{review.author}</span>
          </div>
          <p className="mt-1">{review.body}</p>
        </div>
      ))}
      {totalCount > initialReviews.length && (
        <ReviewLoadMore productId={productId} offset={initialReviews.length} />
      )}
    </div>
  );
}
// ReviewLoadMore.tsx(Client Component)
'use client';

import { useState } from 'react';

type Review = {
  id: string;
  rating: number;
  author: string;
  body: string;
};

type Props = {
  productId: string;
  offset: number;
};

export function ReviewLoadMore({ productId, offset }: Props) {
  const [reviews, setReviews] = useState<Review[]>([]);
  const [currentOffset, setCurrentOffset] = useState(offset);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  const loadMore = async () => {
    setLoading(true);
    const res = await fetch(
      `/api/reviews?productId=${productId}&offset=${currentOffset}&limit=10`
    );
    const data: Review[] = await res.json();
    setReviews(prev => [...prev, ...data]);
    setCurrentOffset(prev => prev + data.length);
    setHasMore(data.length >= 10);
    setLoading(false);
  };

  return (
    <div>
      {reviews.map(review => (
        <div key={review.id} className="border-b py-4">
          <div className="flex items-center gap-2">
            <span className="text-yellow-500">{'★'.repeat(review.rating)}</span>
            <span className="text-sm text-gray-500">{review.author}</span>
          </div>
          <p className="mt-1">{review.body}</p>
        </div>
      ))}
      {hasMore && (
        <button
          onClick={loadMore}
          disabled={loading}
          className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
        >
          {loading ? '読み込み中...' : 'もっと見る'}
        </button>
      )}
    </div>
  );
}

初回表示の10件はServer Componentでレンダリングされるため、SEOに効きます。追加分はClient Componentからfetchするため、初回のJSバンドルには含まれません。ロックバンドのライブで言えば、セットリストの最初の数曲はMCなしで一気に畳みかけて観客を引き込み、中盤以降はMCを挟みながら盛り上げていく構成に似ています。最初にサーバーで一気にレンダリングし、追加分はクライアントのインタラクションで読み込む。この「サーバーとクライアントの境界をどこに引くか」こそ、AI駆動開発で人間が判断すべき部分です。

画像ギャラリーのClient Component

AIの提案から画像ギャラリーの実装も見ておきます。Client Componentとして正しく動作するコードです。

// ProductImages.tsx(Client Component)
'use client';

import { useState } from 'react';
import Image from 'next/image';

type Props = {
  images: string[];
};

export function ProductImages({ images }: Props) {
  const [selectedIndex, setSelectedIndex] = useState(0);

  if (images.length === 0) {
    return (
      <div className="aspect-square bg-gray-100 flex items-center justify-center">
        <span className="text-gray-400">No Image</span>
      </div>
    );
  }

  return (
    <div>
      <div className="aspect-square relative overflow-hidden rounded-lg bg-gray-100">
        <Image
          src={images[selectedIndex]}
          alt="商品画像"
          fill
          className="object-contain"
          priority={selectedIndex === 0}
          sizes="(max-width: 768px) 100vw, 50vw"
        />
      </div>
      {images.length > 1 && (
        <div className="flex gap-2 mt-4">
          {images.map((src, i) => (
            <button
              key={src}
              onClick={() => setSelectedIndex(i)}
              className={`relative w-16 h-16 rounded border-2 overflow-hidden ${
                i === selectedIndex ? 'border-blue-600' : 'border-gray-200'
              }`}
            >
              <Image
                src={src}
                alt={`商品画像 ${i + 1}`}
                fill
                className="object-cover"
                sizes="64px"
              />
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

このコンポーネントでは、最初の画像にのみpriorityを付けてLCPを最適化しています(なお、Next.js 15以降ではpriorityの代わりにpreloadプロパティが推奨されていますが、priorityも引き続き動作します)。ここはAIの提案をほぼそのまま採用しました。Next.jsのImageコンポーネントのprioritysizesを正しく設定している点は、AIの出力として優秀です。

AI駆動開発でコンポーネント設計を進めるときのコツ

半年ほどこのワークフローを回してみて、うまくいくパターンが見えてきました。

プロンプトに「データ」「インタラクション」「制約」を分けて書く。 これだけでAIの出力精度が大きく変わります。「商品ページを作って」ではなく、何がサーバーから来て、何がクライアントで動いて、どんな制約があるかを明示する。

AIの提案は「構造」を採用し、「境界」は自分で引き直す。 コンポーネントの分割単位はAIの提案がそのまま使えることが多いですが、Server/Clientの境界とpropsの設計は人間が調整すべきです。特にpropsのシリアライズコストはAIが見落としがちです。

'use client'の位置は「できるだけ葉に近く」が原則。 ページ全体をClient Componentにしてしまうと、App Routerの恩恵がほぼなくなります。AIにもこの制約を伝えておくと、適切な提案が出やすくなります。

ちなみに、プロンプトを書いている時間は5分くらいですが、AIの出力を評価・修正する時間は30分以上かかります。「AIに聞けば一瞬で終わる」は半分正しくて半分嘘ですね。でも、ゼロから2時間かけて設計するよりは確実に速い。

まとめ

今回は、Next.js App Routerのコンポーネント設計をAI駆動開発で進めるワークフローを紹介しました。

App Routerの設計で最も悩ましいのは、Server ComponentsとClient Componentsの境界をどこに引くかという判断です。この判断を効率化するために、AIへのプロンプトの書き方と、AIの出力をどう評価・修正するかが鍵になります。

今回紹介したワークフローで押さえておきたいポイントは以下の4つです。

  • AIへのプロンプトは「データ」「インタラクション」「制約」を分けて伝えると精度が上がる
  • AIはコンポーネントの分割単位を正しく提案できるが、Server/Clientの境界やpropsの最適化は人間の判断が必要
  • レビュー一覧のように「初回はServer、追加はClient」のハイブリッド設計は、AIに指示しないと出てこない
  • Client Componentに渡すpropsは最小限に。シリアライズコストを意識する

AI駆動開発のポイントは、「AIに丸投げ」ではなく「AIに構造のたたき台を出させて、人間が境界を調整する」ことです。Next.js App Routerの設計は判断ポイントが多いからこそ、AIとの協働が効きます。

株式会社ファストコーディングでは、Next.jsを中心としたフロントエンド開発と、AI駆動開発を取り入れた効率的な制作プロセスでプロジェクトを支援しています。「App Routerへの移行を相談したい」「AI駆動開発を取り入れたい」という方は、お問い合わせフォームからお気軽にご連絡ください。