Next.js FCP 2.5秒→0.8秒:Server Componentsと画像戦略の全記録

モバイル回線でのLighthouse計測結果を見て、FCP(First Contentful Paint)が2.5秒を超えていた時の絶望感は、多くのエンジニアが経験するものです。最近担当したECサイトのトップページ改修案件でも、リッチなインタラクションを追加するたびに初期描画が遅延し、SEO順位にも悪影響が出ていました。本記事では、この問題を解決し、実際にモバイルFCPを0.8秒まで短縮した「泥臭い」チューニングログを共有します。

なぜFCP改善が必要なのか:ハイドレーションの罠

我々が直面していた最大の問題は、クライアントサイドでのJavaScript実行コストの肥大化でした。特にNext.jsパフォーマンスにおいて、不必要なコンポーネントまでクライアントに送信してしまう「全部盛り」の実装は致命的です。従来のPages Routerや不適切なApp Router設計では、ユーザーが見るだけの静的なコンテンツまでハイドレーションの対象となり、TBT(Total Blocking Time)とFCPを悪化させます。

計測された症状: 低速な3G回線シミュレーション下で、メインスレッドがJavaScriptのパースと実行で1.2秒間ブロックされ、その間画面が真っ白のままになる現象を確認しました。

ここで重要なのが、フロントエンド最適化の基本に立ち返ることです。つまり、「ブラウザに送るJavaScriptを減らす」ことです。これを実現する最強の武器が、Next.js 13以降で標準化されたReact Server Components(RSC)です。

React Server Componentsによるバンドル削減

多くの開発者がやりがちなミスは、インタラクションが必要なボタン(例えば「カートに追加」)を含む巨大な商品リスト全体を"use client"にしてしまうことです。これにより、商品データのフォーマットロジックや日付処理ライブラリまでバンドルに含まれてしまいます。

以下は、実際に修正を行った際のビフォア・アフターの概念コードです。Core Web Vitalsのスコアを改善するために、コンポーネントの責務を明確に分離しました。

ポイント: インタラクションが必要な部分だけを末端のクライアントコンポーネントとして切り出し、重いデータ取得や整形はサーバーコンポーネントで行います。
// ❌ 悪い例:親コンポーネントで "use client" を宣言しているため
// moment.jsのような重いライブラリもクライアントに配信されてしまう
"use client";
import moment from 'moment';

export default function ProductList({ products }) {
  return (
    <div>
      {products.map(p => (
        <div key={p.id}>
          <h2>{p.name}</h2>
          <p>発売日: {moment(p.date).format('YYYY-MM-DD')}</p>
          <button onClick={() => addToCart(p)}>購入</button>
        </div>
      ))}
    </div>
  );
}
// ✅ 良い例:Server Component (デフォルト)
// JavaScriptバンドルにはHTMLのみが含まれ、ライブラリは含まれない
import { format } from 'date-fns'; // サーバー側でのみ実行される
import AddToCartButton from './AddToCartButton'; // 小さなクライアントコンポーネント

export default async function ProductList() {
  const products = await fetchProducts(); // DBアクセスも直接可能

  return (
    <div>
      {products.map(p => (
        <div key={p.id}>
          <h2>{p.name}</h2>
          <!-- サーバーでレンダリング済みのHTMLとして配信 -->
          <p>発売日: {format(new Date(p.date), 'yyyy-MM-dd')}</p>
          <!-- ここだけがハイドレーション対象 -->
          <AddToCartButton product={p} />
        </div>
      ))}
    </div>
  );
}

次世代画像フォーマットと遅延読み込み戦略

JavaScriptの削減と並行して、LCP(Largest Contentful Paint)とFCPに直結するのが画像の扱いです。Next.jsのnext/imageコンポーネントを使うだけでは不十分で、適切な設定が必要です。

特にファーストビューに入るメインビジュアルにはpriority属性を付与し、それ以外は徹底的に遅延読み込みさせます。さらに、next.config.jsでAVIF形式を有効にすることで、WebPよりもさらに圧縮率を高めることができます。これにより、回線帯域を圧迫せず、FCP改善に大きく貢献します。

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 1080, 1200], // 必要なサイズのみ定義
  },
}
module.exports = nextConfig
// HeroSection.tsx
import Image from 'next/image';

export default function HeroSection() {
  return (
    <div className="hero">
      <!-- priority属性でプリロードを指示し、LCPを改善 -->
      <Image
        src="/hero-banner.jpg"
        alt="新商品キャンペーン"
        width={1200}
        height={600}
        priority={true}
        sizes="(max-width: 768px) 100vw, 1200px"
        className="object-cover"
      />
    </div>
  );
}
GitHubで公式の画像最適化サンプルを見る

パフォーマンス改善結果の比較

上記の実装変更を適用し、本番環境で計測した結果が以下の通りです。RSCの導入と画像の最適化だけで、モバイル体験は別次元のものになりました。

指標 (Mobile) 改善前 (Pages Router) 改善後 (App Router + RSC) 変化率
FCP 2.5s 0.8s -68%
LCP 3.8s 1.2s -68%
JS Bundle Size 480KB (Gzipped) 145KB (Gzipped) -70%
Lighthouse Score 45 (Red) 96 (Green) 2.1倍
成果: バンドルサイズの劇的な削減により、特にAndroidのローエンド端末での操作性が飛躍的に向上しました。

結論

Next.jsアプリケーションのパフォーマンス改善において、魔法のような設定値は存在しません。しかし、React Server Componentsの仕組みを正しく理解し、クライアントに送信するJavaScriptを極限まで削ぎ落とすこと、そして画像を適切に配信すること。この2点を徹底するだけで、FCP 1秒未満という数字は十分に達成可能です。もし現在のプロジェクトでパフォーマンスに課題を感じているなら、まずは"use client"の使用箇所を見直すところから始めてみてください。

Post a Comment