ブログ記事

Next.js 15リリース!App Router v2を即座に試してみた

昨日リリースされたNext.js 15のApp Router v2。破壊的変更もあるけど、パフォーマンスが劇的に向上。実プロジェクトで試した結果をレポート。

5分で読めます
R
Rina
Daily Hack 編集長
Web開発
React Next.js RSC 失敗談 トラブルシューティング
Next.js 15リリース!App Router v2を即座に試してみたのヒーロー画像

昨日 Next.js 15 がリリースされました!App Router v2 の改善がすごいという噂を聞いて、既存プロジェクトで即座にアップグレードしてみました。

結論から言うと、移行は大変だけど価値はあります。

プロジェクトの概要

中規模の EC サイトのリニューアル案件で:

  • 商品数: 約 3,000 点
  • 月間 PV: 10 万程度
  • 既存は Next.js 12(Pages Router)

「パフォーマンス改善も兼ねて RSC 導入しよう」という軽いノリで始めたのが間違いでした。

最初にハマった問題

1. “use client”地獄

最初、どのコンポーネントに "use client" を付けるべきか分からず、エラーが出るたびに追加していたら、ほぼ全部 Client Component になってしまいました。

Error: Event handlers cannot be passed to Client Component props.
<button onClick={function} children=...>
                 ^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.

このエラーを見るたびに "use client" を追加…の繰り返し。

2. データフェッチングの混乱

Server Component と Client Component でデータの取得方法が違うことに混乱しました。

// これはServer Componentなのに...
export default function ProductList() {
  const [products, setProducts] = useState([]);
  
  useEffect(() => {
    // Server ComponentでuseEffectは使えない!
    fetch('/api/products').then(...)
  }, []);
}
// Server Componentとして正しく実装
export default async function ProductList() {
  // 直接データベースから取得
  const products = await db.product.findMany({
    take: 20
  });
  
  return <ProductGrid products={products} />;
}
最初の実装(動かない)
// これはServer Componentなのに...
export default function ProductList() {
  const [products, setProducts] = useState([]);
  
  useEffect(() => {
    // Server ComponentでuseEffectは使えない!
    fetch('/api/products').then(...)
  }, []);
}
修正後
// Server Componentとして正しく実装
export default async function ProductList() {
  // 直接データベースから取得
  const products = await db.product.findMany({
    take: 20
  });
  
  return <ProductGrid products={products} />;
}

3. Hydrataion Error地獄

開発環境では動くのに、本番ビルドするとエラーが…

Error: Hydration failed because the initial UI does not match what was rendered on the server.

原因は、Server Component で Date オブジェクトを使っていたこと:

// NG: タイムゾーンの違いでエラー
<p>更新日: {new Date(product.updatedAt).toLocaleString()}</p>

// OK: フォーマットしてから渡す
<p>更新日: {format(product.updatedAt, 'yyyy/MM/dd HH:mm')}</p>

転機:考え方を変えた

1 週間格闘した後、根本的にアプローチを変えました。

RSCの正しい考え方

「すべてを Server Component にして、必要な部分だけ Client Component にする」 のではなく、 「ページの構造を考えて、適切に分離する」

うまくいった実装パターン

1. レイアウトの分離

// app/layout.tsx (Server Component)
export default function RootLayout({ children }) {
  // ナビゲーションのデータをサーバーで取得
  const categories = await getCategories();
  
  return (
    <html>
      <body>
        <Header categories={categories} />
        {children}
        <Footer />
      </body>
    </html>
  );
}

// components/Header.tsx (一部Client Component)
export default function Header({ categories }) {
  return (
    <header>
      <Logo />
      <Navigation categories={categories} />
      <SearchBox /> {/* これだけClient Component */}
    </header>
  );
}

2. データフェッチングの階層化

商品一覧ページの実装例:

// app/products/page.tsx (Server Component)
export default async function ProductsPage({ searchParams }) {
  const products = await getProducts(searchParams);
  
  return (
    <div>
      <ProductFilters /> {/* Client Component */}
      <ProductList products={products} /> {/* Server Component */}
    </div>
  );
}

// components/ProductList.tsx (Server Component)
export default function ProductList({ products }) {
  return (
    <div className="grid">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// components/ProductCard.tsx (一部Client Component)
export default function ProductCard({ product }) {
  return (
    <article>
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price}円</p>
      <AddToCartButton productId={product.id} /> {/* Client Component */}
    </article>
  );
}

3. キャッシュ戦略

パフォーマンスが劇的に改善したのは、適切なキャッシュ設定でした:

// 商品データは1時間キャッシュ
export async function getProducts() {
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 } // 1時間
  });
  return products.json();
}

// カテゴリーは24時間キャッシュ
export async function getCategories() {
  const categories = await fetch('https://api.example.com/categories', {
    next: { revalidate: 86400 } // 24時間
  });
  return categories.json();
}

実際のパフォーマンス改善

Before (Pages Router):

  • First Contentful Paint: 2.3s
  • Time to Interactive: 4.1s
  • JavaScriptバンドルサイズ: 312KB

After (App Router + RSC):

  • First Contentful Paint: 0.9s
  • Time to Interactive: 1.8s
  • JavaScriptバンドルサイズ: 187KB

特に初期表示が速くなったのは体感できるレベルでした。

学んだ教訓

  1. 最初から完璧を目指さない

    • まずは動くものを作って、徐々に Server Component 化
  2. エラーメッセージを読む

    • RSC のエラーは親切。ちゃんと読めば解決策が書いてある
  3. デバッグツールを活用

    • React DevTools で、どれが Server/Client Component か確認できる
  4. パフォーマンス測定は必須

    • 「速くなったはず」じゃなくて、実際に測定する

まとめ

React Server Components は確かに学習曲線が急ですが、正しく使えば大きなメリットがあります。

ただし、既存プロジェクトの移行は慎重に。新規プロジェクトから始めることをおすすめします。

あと、公式ドキュメントは必読です。私は最初読まずに始めて後悔しました…

これから始める人へ

Next.jsのApp Router Playgroundで、実際の動作を確認しながら学ぶのがおすすめです。

参考リンク

Rinaのプロフィール画像

Rina

Daily Hack 編集長

フルスタックエンジニアとして10年以上の経験を持つ。 大手IT企業やスタートアップでの開発経験を活かし、 実践的で即効性のある技術情報を日々発信中。 特にWeb開発、クラウド技術、AI活用に精通。

この記事は役に立ちましたか?

あなたのフィードバックが記事の改善に役立ちます

この記事は役に立ちましたか?

Daily Hackでは、開発者の皆様に役立つ情報を毎日発信しています。