コンテンツへスキップ
·8分で読めます

2026年のCore Web Vitals:本当に効果のある改善策

理論は忘れて――実際のNext.js本番サイトでLCPを2.5秒以下、CLSをゼロ、INPを200ms以下にするために私が実際にやったこと。曖昧なアドバイスではなく、具体的なテクニック。

シェア:X / TwitterLinkedIn

このサイトを速くするのに丸2週間を費やしました。「M3 MacBookでのLighthouse監査で速く見える」という意味の速さではありません。本当に速い。150ドルのAndroidスマートフォンで、地下鉄のトンネル内の不安定な4G接続で速い。重要なところで速い、ということです。

結果:LCPは1.8秒以下、CLSは0.00、INPは120ms以下。3つすべてがラボスコアではなくCrUXデータでグリーン。そしてその過程で気づいたことがあります――インターネット上のパフォーマンスアドバイスのほとんどは、古いか、曖昧か、その両方です。

「画像を最適化しましょう」はアドバイスではありません。文脈なしの「遅延読み込みを使いましょう」は危険です。「JavaScriptを最小化しましょう」は当たり前ですが、何を削るべきかについては何も教えてくれません。

以下が、重要な順に、私が実際にやったことです。

2026年にCore Web Vitalsがまだ重要な理由#

率直に言います:GoogleはCore Web Vitalsをランキングシグナルとして使用しています。唯一のシグナルではなく、最も重要なシグナルですらありません。コンテンツの関連性、バックリンク、ドメインオーソリティは依然として支配的です。しかし、2つのページが同等のコンテンツと権威性を持つ境界線上では、パフォーマンスがタイブレーカーになります。そしてインターネット上では、数百万のページがその境界線上にあります。

でも一旦SEOは忘れてください。パフォーマンスを気にすべき本当の理由はユーザーです。データはここ5年でほとんど変わっていません:

  • ページの読み込みに3秒以上かかると、モバイル訪問の53%が離脱する(Google/SOASTA調査、今も有効)
  • レイテンシーが100ms増えるごとに、コンバージョンが約1%減少する(Amazonのオリジナル調査、繰り返し検証済み)
  • レイアウトシフトを経験したユーザーは、購入やフォーム入力を完了する可能性が大幅に低下する

2026年のCore Web Vitalsは3つのメトリクスで構成されています:

メトリクス測定対象良好改善が必要不良
LCP読み込みパフォーマンス≤ 2.5秒2.5秒 – 4.0秒> 4.0秒
CLS視覚的安定性≤ 0.10.1 – 0.25> 0.25
INP応答性≤ 200ms200ms – 500ms> 500ms

これらのしきい値は、2024年3月にINPがFIDに置き換わって以来変わっていません。しかし、それらを達成するためのテクニックは進化しており、特にReact/Next.jsエコシステムにおいて顕著です。

LCP:最も重要な指標#

Largest Contentful Paintは、ビューポート内の最大の可視要素がレンダリングを完了するタイミングを測定します。ほとんどのページでは、ヒーロー画像、見出し、または大きなテキストブロックがこれに該当します。

ステップ1:実際のLCP要素を特定する#

何かを最適化する前に、LCP要素が何かを知る必要があります。皆ヒーロー画像だと思い込みます。しかし、<h1>をレンダリングするWebフォントだったりします。CSSで適用された背景画像だったりします。<video>のポスターフレームだったりします。

Chrome DevToolsを開き、Performanceパネルに移動してページロードを記録し、「LCP」マーカーを探してください。どの要素がLCPをトリガーしたかを正確に教えてくれます。

web-vitalsライブラリを使ってプログラム的にログを取ることもできます:

tsx
import { onLCP } from "web-vitals";
 
onLCP((metric) => {
  console.log("LCP element:", metric.entries[0]?.element);
  console.log("LCP value:", metric.value, "ms");
});

このサイトでは、LCP要素はホームページのヒーロー画像と、ブログ記事の最初の段落テキストでした。2つの異なる要素、2つの異なる最適化戦略。

ステップ2:LCPリソースをプリロードする#

LCP要素が画像の場合、最もインパクトのある改善はプリロードです。デフォルトでは、ブラウザはHTMLをパースするときに画像を発見します。つまり、画像リクエストはHTMLがダウンロードされ、パースされ、<img>タグに到達するまで開始されません。プリロードはその発見を最初に移動させます。

Next.jsでは、レイアウトやページにプリロードリンクを追加できます:

tsx
import Head from "next/head";
 
export default function HeroSection() {
  return (
    <>
      <Head>
        <link
          rel="preload"
          as="image"
          href="/images/hero-optimized.webp"
          type="image/webp"
          fetchPriority="high"
        />
      </Head>
      <section className="relative h-[600px]">
        <img
          src="/images/hero-optimized.webp"
          alt="Hero banner"
          width={1200}
          height={600}
          fetchPriority="high"
          decoding="sync"
        />
      </section>
    </>
  );
}

fetchPriority="high"に注目してください。これは新しいFetch Priority APIで、ゲームチェンジャーです。これがないと、ブラウザは独自のヒューリスティクスを使ってリソースの優先順位を決定しますが、特にファーストビューに複数の画像がある場合、そのヒューリスティクスはしばしば間違います。

このサイトでは、LCP画像にfetchPriority="high"を追加したことでLCPが約400ms短縮されました。1行の変更で得られた史上最大の改善です。

ステップ3:レンダリングブロッキングリソースの排除#

CSSはレンダリングをブロックします。すべてです。<link rel="stylesheet">で読み込まれる200KBのスタイルシートがある場合、ブラウザは完全にダウンロードしてパースするまで何もペイントしません。

修正は3つのアプローチです:

  1. クリティカルCSSのインライン化 ―― ファーストビューのコンテンツに必要なCSSを抽出し、<head>内の<style>タグにインライン化します。Next.jsはCSS ModulesやTailwindを適切なパージと共に使用すると、これを自動的に行います。

  2. 非クリティカルCSSの遅延読み込み ―― ファーストビュー以下のコンテンツ用のスタイルシート(フッターのアニメーションライブラリ、チャートコンポーネント)がある場合、非同期で読み込みます:

html
<link
  rel="preload"
  href="/styles/charts.css"
  as="style"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
  <link rel="stylesheet" href="/styles/charts.css" />
</noscript>
  1. 未使用CSSの削除 ―― Tailwind CSS v4はJITエンジンでこれを自動的に行います。しかし、サードパーティのCSSライブラリをインポートしている場合は監査してください。あるコンポーネントライブラリが単一のツールチップコンポーネントのために180KBのCSSをインポートしているのを発見しました。20行のカスタムコンポーネントに置き換えて170KB削減しました。

ステップ4:サーバーレスポンス時間(TTFB)#

TTFBが遅ければLCPは速くなりません。サーバーのレスポンスに800msかかるなら、LCPは少なくとも800ms + それ以外のすべてになります。

このサイト(VPS上のNode.js + PM2 + Nginx)では、コールドヒット時のTTFBは約180msでした。それを維持するためにやったことはこうです:

  • ブログ記事にISR(Incremental Static Regeneration) ―― ページはビルド時にプリレンダリングされ、定期的に再検証されます。最初のアクセスはNginxのリバースプロキシキャッシュから直接静的ファイルを配信します。
  • Edgeキャッシュヘッダー ―― 静的ページにCache-Control: public, s-maxage=3600, stale-while-revalidate=86400
  • NginxでのGzip/Brotli圧縮 ―― 転送サイズを60-80%削減。
bash
# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
 
# Brotli (if ngx_brotli module is installed)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;

LCPのbefore/after:

  • 最適化前:3.8秒(75パーセンタイル、CrUX)
  • プリロード + fetchPriority + 圧縮後:1.8秒
  • 合計改善:53%削減

CLS:千のシフトによる死#

Cumulative Layout Shiftは、ページ読み込み中に可視コンテンツがどれだけ動き回るかを測定します。CLSが0なら何もシフトしなかったことを意味します。CLSが0.1を超えると、何かがユーザーを視覚的にイライラさせていることを意味します。

CLSは最も過小評価されるメトリクスです。すべてがキャッシュされた高速な開発マシンでは気づきません。しかし、ユーザーは遅い接続のスマートフォンで、フォントが遅れて読み込まれ、画像が一つずつポップインする状況で気づきます。

よくある犯人#

1. 明示的なサイズが指定されていない画像

これが最も一般的なCLSの原因です。画像が読み込まれると、その下のコンテンツが押し下げられます。修正は驚くほどシンプルです:<img>タグには常にwidthheightを指定しましょう。

tsx
// BAD — causes layout shift
<img src="/photo.jpg" alt="Team photo" />
 
// GOOD — browser reserves space before image loads
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />

Next.jsの<Image>を使用している場合、dimensionsを指定するか、サイズ指定された親コンテナでfillを使用すれば、自動的に処理されます。

しかし注意点があります:fillモードを使用する場合、親コンテナには明示的なサイズが必要です。そうしないと画像がCLSを引き起こします:

tsx
// BAD — parent has no dimensions
<div className="relative">
  <Image src="/photo.jpg" alt="Team" fill />
</div>
 
// GOOD — parent has explicit aspect ratio
<div className="relative aspect-video w-full">
  <Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>

2. FOUT/FOITを引き起こすWebフォント

カスタムフォントが読み込まれると、フォールバックフォントでレンダリングされたテキストがカスタムフォントで再レンダリングされます。2つのフォントのメトリクスが異なる場合(ほぼ常に異なります)、すべてがシフトします。

現代的な修正はfont-display: swapサイズ調整されたフォールバックフォントの組み合わせです:

tsx
// Using next/font — the best approach for Next.js
import { Inter } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  // next/font automatically generates size-adjusted fallback fonts
  // This eliminates CLS from font swapping
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/fontはNext.jsの中でも本当に優れた機能の一つです。ビルド時にフォントをダウンロードし、セルフホスティングし(ランタイムでのGoogle Fontsリクエストなし)、サイズ調整されたフォールバックフォントを生成するので、フォールバックからカスタムフォントへの切り替え時にレイアウトシフトがゼロになります。next/fontに切り替えた後、フォントによるCLSを0.00と測定しました。切り替え前は、標準のGoogle Fonts <link>で0.04-0.08でした。

3. 動的コンテンツの挿入

広告、Cookieバナー、通知バー ―― 初期レンダリング後にDOMに挿入されるものは、コンテンツを押し下げるとCLSを引き起こします。

修正:動的コンテンツが読み込まれる前にスペースを確保しましょう。

tsx
// Cookie banner — reserve space at the bottom
function CookieBanner() {
  const [accepted, setAccepted] = useState(false);
 
  if (accepted) return null;
 
  return (
    // Fixed positioning doesn't cause CLS because it
    // doesn't affect document flow
    <div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
      <p>We use cookies. You know the drill.</p>
      <button onClick={() => setAccepted(true)}>Accept</button>
    </div>
  );
}

動的要素にposition: fixedposition: absoluteを使用することは、CLSフリーなアプローチです。これらの要素は通常のドキュメントフローに影響しないからです。

4. aspect-ratio CSSのテクニック

アスペクト比はわかっているが正確なサイズがわからないレスポンシブコンテナには、CSSのaspect-ratioプロパティを使用します:

tsx
// Video embed without CLS
function VideoEmbed({ src }: { src: string }) {
  return (
    <div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
      <iframe
        src={src}
        className="w-full h-full"
        title="Embedded video"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media"
        allowFullScreen
      />
    </div>
  );
}

aspect-videoユーティリティ(aspect-ratio: 16/9)は正確な量のスペースを確保します。iframeが読み込まれてもシフトなし。

5. スケルトンスクリーン

非同期で読み込まれるコンテンツ(APIデータ、動的コンポーネント)には、期待されるサイズに合致するスケルトンを表示します:

tsx
function PostCardSkeleton() {
  return (
    <div className="animate-pulse rounded-lg border p-4">
      <div className="h-48 w-full rounded bg-gray-200" />
      <div className="mt-4 space-y-2">
        <div className="h-6 w-3/4 rounded bg-gray-200" />
        <div className="h-4 w-full rounded bg-gray-200" />
        <div className="h-4 w-5/6 rounded bg-gray-200" />
      </div>
    </div>
  );
}
 
function PostList() {
  const { data: posts, isLoading } = usePosts();
 
  if (isLoading) {
    return (
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        {Array.from({ length: 6 }).map((_, i) => (
          <PostCardSkeleton key={i} />
        ))}
      </div>
    );
  }
 
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
      {posts?.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

重要なのは、PostCardSkeletonPostCard同じサイズであることです。スケルトンが200pxの高さで実際のカードが280pxの高さだと、やはりシフトが発生します。

CLSの結果:

  • 改善前:0.12(フォントスワップだけで0.06)
  • 改善後:0.00 ―― 文字通りゼロ、CrUXデータの数千回のページロードにわたって

INP:噛み付く新参者#

Interaction to Next Paintは2024年3月にFirst Input Delayに置き換わりましたが、最適化が根本的に難しいメトリクスです。FIDは最初のインタラクションが処理されるまでの遅延のみを測定していました。INPはページのライフサイクル全体を通じてすべてのインタラクションを測定し、最悪のもの(75パーセンタイル)を報告します。

つまり、ページのFIDは優秀でも、例えばロードから30秒後にドロップダウンメニューをクリックすると500msのリフローが発生するような場合、INPはひどい結果になり得ます。

高いINPの原因#

  1. メインスレッドでの長いタスク ―― 50msを超えるJavaScript実行はメインスレッドをブロックします。長いタスク中に発生するユーザーインタラクションは待たされます。
  2. Reactでの高コストな再レンダリング ―― 200個のコンポーネントを再レンダリングさせる状態更新には時間がかかります。ユーザーが何かをクリックし、Reactが差分計算を行い、ペイントが300ms経つまで発生しない。
  3. レイアウトスラッシング ―― ループ内でレイアウトプロパティ(offsetHeightなど)を読み取ってから書き込む(style.heightの変更など)と、ブラウザに同期的なレイアウト再計算を強制します。
  4. 巨大なDOM ―― DOMノードが多いほど、スタイルの再計算とレイアウトが遅くなります。5,000ノードのDOMは500ノードのDOMより顕著に遅くなります。

scheduler.yield()で長いタスクを分割する#

INPに対する最もインパクトのあるテクニックは、ブラウザがチャンク間でユーザーインタラクションを処理できるように長いタスクを分割することです:

tsx
async function processLargeDataset(items: DataItem[]) {
  const results: ProcessedItem[] = [];
 
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));
 
    // Every 10 items, yield to the browser
    // This lets pending user interactions get processed
    if (i % 10 === 0 && "scheduler" in globalThis) {
      await scheduler.yield();
    }
  }
 
  return results;
}

scheduler.yield()はChrome 129以降(2024年9月)で利用可能で、メインスレッドに譲るための推奨方法です。サポートしていないブラウザには、setTimeout(0)ラッパーにフォールバックできます:

tsx
function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

緊急でない更新にuseTransitionを使う#

React 18以降ではuseTransitionが提供され、特定の状態更新が緊急ではなく、より重要な作業(ユーザー入力への応答など)で中断できることをReactに伝えます:

tsx
import { useState, useTransition } from "react";
 
function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
 
    // This update is urgent — the input must reflect the keystroke immediately
    setQuery(value);
 
    // This update is NOT urgent — filtering 10,000 items can wait
    startTransition(() => {
      const filtered = items.filter((item) =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  }
 
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleSearch}
        placeholder="Search..."
        className="w-full rounded border px-4 py-2"
      />
      {isPending && (
        <p className="mt-2 text-sm text-gray-500">Filtering...</p>
      )}
      <ul className="mt-4 space-y-2">
        {filteredItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

startTransitionがなければ、検索入力へのタイプ入力はもたつきます。Reactが10,000アイテムを同期的にフィルタリングしてからDOMを更新しようとするからです。startTransitionがあれば、入力は即座に更新され、フィルタリングはバックグラウンドで行われます。

複雑な入力ハンドラを持つツールページでINPを測定しました。useTransition導入前:INP 380ms。導入後:INP 90ms。React APIの変更だけで76%の改善です。

入力ハンドラのデバウンス#

高コストな操作(API呼び出し、重い計算)をトリガーするハンドラには、デバウンスを適用します:

tsx
import { useCallback, useRef } from "react";
 
function useDebounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): T {
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
 
  return useCallback(
    ((...args: unknown[]) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => fn(...args), delay);
    }) as T,
    [fn, delay]
  );
}
 
// Usage
function LiveSearch() {
  const [results, setResults] = useState<SearchResult[]>([]);
 
  const search = useDebounce(async (query: string) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await response.json();
    setResults(data.results);
  }, 300);
 
  return (
    <input
      type="text"
      onChange={(e) => search(e.target.value)}
      placeholder="Search..."
    />
  );
}

300msが私の定番のデバウンス値です。ユーザーが遅延を感じないほど短く、キーストロークごとの発火を防ぐのに十分な長さです。

重い計算にはWeb Workerを使う#

本当に重い計算(巨大なJSONのパース、画像操作、複雑な計算)がある場合は、メインスレッドから完全に切り離しましょう:

tsx
// worker.ts
self.addEventListener("message", (event) => {
  const { data, operation } = event.data;
 
  switch (operation) {
    case "sort": {
      // This could take 500ms for large datasets
      const sorted = data.sort((a: number, b: number) => a - b);
      self.postMessage({ result: sorted });
      break;
    }
    case "filter": {
      const filtered = data.filter((item: DataItem) =>
        complexFilterLogic(item)
      );
      self.postMessage({ result: filtered });
      break;
    }
  }
});
tsx
// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
 
function useWorker() {
  const workerRef = useRef<Worker>();
 
  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/worker.ts", import.meta.url)
    );
    return () => workerRef.current?.terminate();
  }, []);
 
  const process = useCallback(
    (operation: string, data: unknown): Promise<unknown> => {
      return new Promise((resolve) => {
        if (!workerRef.current) return;
 
        workerRef.current.onmessage = (event) => {
          resolve(event.data.result);
        };
 
        workerRef.current.postMessage({ operation, data });
      });
    },
    []
  );
 
  return { process };
}

Web Workerは別スレッドで動作するため、2秒の計算でもINPには全く影響しません。メインスレッドはユーザーインタラクションの処理のために自由なままです。

INPの結果:

  • 改善前:340ms(最悪のインタラクションは複雑な入力処理を持つ正規表現テスターツール)
  • useTransition + デバウンス適用後:110ms
  • 改善:68%削減

Next.js固有の改善#

Next.js(App Router付きの13以降)を使用している場合、ほとんどの開発者が十分に活用していない強力なパフォーマンスプリミティブにアクセスできます。

next/image ―― ただし適切に設定する#

next/imageは優秀ですが、デフォルト設定ではパフォーマンスを取りこぼしています:

tsx
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    deviceSizes: [640, 750, 828, 1080, 1200],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 365, // 1 year
  },
};
 
export default nextConfig;

主要な設定:

  • formats: ["image/avif", "image/webp"] ―― AVIFはWebPより20-50%小さくなります。順序が重要です:Next.jsはまずAVIFを試み、WebPにフォールバックし、元のフォーマットにフォールバックします。
  • minimumCacheTTL ―― デフォルトは60秒です。ブログでは画像は変わりません。1年間キャッシュしましょう。
  • deviceSizesimageSizes ―― デフォルトには3840pxが含まれています。4K画像を配信しない限り、このリストを削りましょう。各サイズは個別のキャッシュ画像を生成し、未使用のサイズはディスクスペースとビルド時間を無駄にします。

そして常にsizesプロパティを使って、画像がどのサイズでレンダリングされるかをブラウザに伝えましょう:

tsx
// Full-width hero image
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  sizes="100vw"
  priority // LCP image — don't lazy load it!
/>
 
// Card image in a responsive grid
<Image
  src="/card.jpg"
  alt="Card"
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

sizesがないと、ブラウザは300pxのスロットに1200pxの画像をダウンロードするかもしれません。バイトと時間の無駄です。

LCP画像のpriorityプロパティは重要です。遅延読み込みを無効にし、自動的にfetchPriority="high"を追加します。LCP要素がnext/imageなら、priorityを追加するだけでほぼ目標達成です。

next/font ―― レイアウトシフトゼロのフォント#

CLSセクションで触れましたが、強調する価値があります。next/fontは、一貫してCLSゼロを達成する唯一のフォント読み込みソリューションです:

tsx
import { Inter, JetBrains_Mono } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});
 
const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-mono",
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="en"
      className={`${inter.variable} ${jetbrainsMono.variable}`}
    >
      <body className="font-sans">{children}</body>
    </html>
  );
}

2つのフォント、CLSゼロ、ランタイムでの外部リクエストゼロ。フォントはビルド時にダウンロードされ、自ドメインから配信されます。

Suspenseによるストリーミング#

ここがNext.jsのパフォーマンスにおいて本当に面白くなるところです。App Routerを使えば、ページの一部を準備ができた順にブラウザへストリーミングできます:

tsx
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
 
export default function BlogPage() {
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
      <div className="lg:col-span-2">
        {/* This loads fast — stream it immediately */}
        <h1 className="text-4xl font-bold">Blog</h1>
 
        {/* This requires a database query — stream it when ready */}
        <Suspense fallback={<PostListSkeleton />}>
          <PostList />
        </Suspense>
      </div>
 
      <aside>
        {/* Sidebar can load independently */}
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
      </aside>
    </div>
  );
}

ブラウザはシェル(見出し、ナビゲーション、レイアウト)を即座に受け取ります。記事リストとサイドバーはデータが利用可能になった順にストリーミングされます。ユーザーは高速な初期表示を見て、コンテンツが段階的に埋まっていきます。

これはLCPにおいて特に強力です。LCP要素が見出し(記事リストではない)であれば、データベースクエリがどれだけ時間がかかっても即座にレンダリングされます。

ルートセグメント設定#

Next.jsでは、ルートセグメントレベルでキャッシュと再検証を設定できます:

tsx
// app/blog/page.tsx
// Revalidate this page every hour
export const revalidate = 3600;
 
// app/tools/[slug]/page.tsx
// These tool pages are fully static — generate at build time
export const dynamic = "force-static";
 
// app/api/search/route.ts
// API route — never cache
export const dynamic = "force-dynamic";

このサイトでは、ブログ記事はrevalidate = 3600(1時間)を使用しています。ツールページはデプロイ間でコンテンツが変わらないためforce-staticを使用。検索APIはリクエストが毎回一意なのでforce-dynamicを使用。

結果:ほとんどのページが静的キャッシュから配信され、キャッシュされたページのTTFBは50ms未満、サーバーはほとんど負荷がかかりません。

計測ツール:目ではなくデータを信じる#

パフォーマンスに対するあなたの感覚は信頼できません。あなたの開発マシンには32GBのRAM、NVMe SSD、ギガビット接続があります。ユーザーにはありません。

私が使用する計測スタック#

1. Chrome DevTools Performanceパネル

利用可能な最も詳細なツールです。ページロードを記録し、フレームチャートを見て、長いタスクを特定し、レンダリングブロッキングリソースを見つけます。デバッグ時間の大半をここで過ごします。

注目すべきポイント:

  • タスクの赤い角 = 長いタスク(>50ms)
  • JavaScriptによってトリガーされたLayout/Paintイベント
  • 大きな「Evaluate Script」ブロック(JavaScriptが多すぎる)
  • 遅れて発見されたリソースを示すネットワークウォーターフォール

2. Lighthouse

素早いチェックには良いですが、Lighthouseのスコアに最適化しないでください。Lighthouseはシミュレートされたスロットリング環境で実行され、実際の条件と完全には一致しません。Lighthouseで98点を取りながら、実際のLCPが4秒のページを見たことがあります。

Lighthouseは方向性のガイダンスとして使い、スコアボードとしては使わないでください。

3. PageSpeed Insights

本番サイトにとって最も重要なツールです。過去28日間の実際のChromeユーザーからの測定である実際のCrUXデータを表示するからです。ラボデータは起こりうることを教えてくれます。CrUXデータは実際に起こっていることを教えてくれます。

4. web-vitalsライブラリ

本番サイトに追加して、実際のユーザーメトリクスを収集しましょう:

tsx
// components/analytics/WebVitals.tsx
"use client";
 
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
 
function sendToAnalytics(metric: Metric) {
  // Send to your analytics endpoint
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });
 
  // Use sendBeacon so it doesn't block page unload
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", {
      body,
      method: "POST",
      keepalive: true,
    });
  }
}
 
export function WebVitals() {
  useEffect(() => {
    onCLS(sendToAnalytics);
    onINP(sendToAnalytics);
    onLCP(sendToAnalytics);
    onFCP(sendToAnalytics);
    onTTFB(sendToAnalytics);
  }, []);
 
  return null;
}

これにより、CrUXに似た独自のデータが得られますが、より詳細です。ページ、デバイスタイプ、接続速度、地理的地域など、必要に応じてセグメント化できます。

5. Chrome User Experience Report (CrUX)

CrUX BigQueryデータセットは無料で、数百万のオリジンの28日間のローリングデータを含んでいます。サイトに十分なトラフィックがあれば、自分のデータをクエリできます:

sql
SELECT
  origin,
  p75_lcp,
  p75_cls,
  p75_inp,
  form_factor
FROM
  `chrome-ux-report.materialized.metrics_summary`
WHERE
  origin = 'https://yoursite.com'
  AND yyyymm = 202603

ウォーターフォール撲滅リスト#

サードパーティスクリプトは、ほとんどのWebサイトにおいてパフォーマンスキラーの筆頭です。私が発見したものと、それに対してやったことを紹介します。

Google Tag Manager (GTM)#

GTM自体は約80KBです。しかしGTMは他のスクリプトを読み込みます ―― アナリティクス、マーケティングピクセル、A/Bテストツール。15個の追加スクリプトを合計2MB読み込むGTM設定を見たことがあります。

私のアプローチ:本番環境ではGTMを使わない。 アナリティクススクリプトを直接読み込み、すべてをdeferし、待てるスクリプトにはloading="lazy"を使用します:

tsx
// Instead of GTM loading everything
// Load only what you need, when you need it
 
export function AnalyticsScript() {
  return (
    <script
      defer
      src="https://analytics.example.com/script.js"
      data-website-id="your-id"
    />
  );
}

どうしてもGTMを使う必要がある場合は、ページがインタラクティブになった後に読み込みましょう:

tsx
"use client";
 
import { useEffect } from "react";
 
export function DeferredGTM({ containerId }: { containerId: string }) {
  useEffect(() => {
    // Wait until after page load to inject GTM
    const timer = setTimeout(() => {
      const script = document.createElement("script");
      script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
      script.async = true;
      document.head.appendChild(script);
    }, 3000); // 3 second delay
 
    return () => clearTimeout(timer);
  }, [containerId]);
 
  return null;
}

はい、最初の3秒で離脱するユーザーのデータは失われます。私の経験では、それは許容できるトレードオフです。そういうユーザーはどのみちコンバージョンしていませんでした。

チャットウィジェット#

ライブチャットウィジェット(Intercom、Drift、Crisp)は最悪の犯人の一つです。Intercomだけで400KB以上のJavaScriptを読み込みます。ユーザーの2%しかチャットボタンをクリックしないページで、98%のユーザーに400KBのJavaScriptを配信していることになります。

私の解決策:インタラクション時にウィジェットを読み込む。

tsx
"use client";
 
import { useState } from "react";
 
export function ChatButton() {
  const [loaded, setLoaded] = useState(false);
 
  function loadChat() {
    if (loaded) return;
 
    // Load the chat widget script only when the user clicks
    const script = document.createElement("script");
    script.src = "https://chat-widget.example.com/widget.js";
    script.onload = () => {
      // Initialize the widget after script loads
      window.ChatWidget?.open();
    };
    document.head.appendChild(script);
    setLoaded(true);
  }
 
  return (
    <button
      onClick={loadChat}
      className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
      aria-label="Open chat"
    >
      {loaded ? "Loading..." : "Chat with us"}
    </button>
  );
}

未使用のJavaScript#

Chrome DevToolsでCoverageを実行してください(Ctrl+Shift+P > "Show Coverage")。現在のページで各スクリプトがどれだけ実際に使用されているかを正確に示してくれます。

典型的なNext.jsサイトでは、通常以下のものが見つかります:

  • コンポーネントライブラリの全体読み込み ―― UIライブラリからButtonをインポートすると、ライブラリ全体がバンドルされます。解決策:ツリーシェイキング可能なライブラリを使うか、サブパスからインポートする(import { Button } from "lib"の代わりにimport Button from "lib/Button")。
  • モダンブラウザ向けのポリフィル ―― PromisefetchArray.prototype.includesのポリフィルを配信していないか確認してください。2026年には不要です。
  • デッドフィーチャーフラグ ―― 6ヶ月間「オン」のままだったフィーチャーフラグの背後にあるコードパス。フラグとデッドブランチを削除しましょう。

Next.jsバンドルアナライザーを使ってオーバーサイズのチャンクを見つけます:

tsx
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
 
const nextConfig = {
  // your config
};
 
export default process.env.ANALYZE === "true"
  ? withBundleAnalyzer({ enabled: true })(nextConfig)
  : nextConfig;
bash
ANALYZE=true npm run build

これでバンドルのビジュアルツリーマップが開きます。120KBの日付フォーマットライブラリを発見し、ネイティブのIntl.DateTimeFormatに置き換えました。マークダウンを使わないページで90KBのマークダウンパーサーがインポートされているのを発見しました。積み重なる小さな改善です。

レンダリングブロッキングCSS#

LCPセクションで触れましたが、非常に一般的なので繰り返す価値があります。<head>内のすべての<link rel="stylesheet">はレンダリングをブロックします。5つのスタイルシートがあれば、ブラウザは5つすべてを待ってから何かをペイントします。

TailwindとNext.jsはこれをうまく処理します ―― CSSはインライン化され最小限です。しかし、サードパーティCSSをインポートしている場合は監査してください:

tsx
// BAD — loads entire library CSS on every page
import "some-library/dist/styles.css";
 
// BETTER — dynamic import so it only loads on pages that need it
const SomeComponent = dynamic(
  () => import("some-library").then((mod) => {
    // CSS is imported inside the dynamic component
    import("some-library/dist/styles.css");
    return mod.SomeComponent;
  }),
  { ssr: false }
);

実際の最適化ストーリー#

このサイトのツールページの実際の最適化を紹介しましょう。15以上のインタラクティブツールを持つページで、それぞれが独自のコンポーネントを持ち、正規表現テスターやJSONフォーマッターのようなものはJavaScriptが重い構成です。

開始時点#

初期測定値(CrUXデータ、モバイル、75パーセンタイル):

  • LCP: 3.8秒 ―― 不良
  • CLS: 0.12 ―― 改善が必要
  • INP: 340ms ―― 不良

Lighthouseスコア:62。

調査#

LCP分析: LCP要素はページの見出し(<h1>)で、本来は即座にレンダリングされるべきでした。しかし、以下によって遅延していました:

  1. コンポーネントライブラリからの200KBのCSSファイル(レンダリングブロッキング)
  2. Google Fonts CDNから読み込まれたカスタムフォント(遅い接続で800msのFOIT)
  3. ページがキャッシュなしで毎リクエストサーバーレンダリングされるため、TTFBが420ms

CLS分析: 3つの原因:

  1. Google Fontsのフォールバックからカスタムフォントへのフォントスワップ:0.06
  2. 高さが確保されていないツールカードの読み込み:0.04
  3. ページ上部に挿入されるCookieバナーが全体を押し下げる:0.02

INP分析: 正規表現テスターツールが最悪の犯人でした。正規表現入力のキーストロークごとに:

  1. ツールコンポーネント全体の完全な再レンダリング
  2. テスト文字列に対する正規表現の評価
  3. 正規表現パターンのシンタックスハイライト

キーストロークあたりの合計時間:280-400ms。

修正#

第1週:LCPとCLS

  1. Google Fonts CDNをnext/fontに置き換え。フォントはセルフホスティングされ、ビルド時に読み込まれ、サイズ調整されたフォールバック付き。フォントによるCLS:0.06 → 0.00

  2. コンポーネントライブラリのCSSを削除。使用していた3つのコンポーネントをTailwindで書き直し。削除した合計CSS:180KB。レンダリングブロッキングCSS:排除

  3. ツールページとツール詳細ページにrevalidate = 3600を追加。最初のアクセスはサーバーレンダリング、以降のアクセスはキャッシュから配信。TTFB:420ms → 45ms(キャッシュ時)

  4. すべてのツールカードコンポーネントに明示的なサイズを追加し、レスポンシブレイアウトにaspect-ratioを使用。カードによるCLS:0.04 → 0.00

  5. Cookieバナーを画面下部のposition: fixedに移動。バナーによるCLS:0.02 → 0.00

第2週:INP

  1. 正規表現テスターの結果計算をstartTransitionでラップ:
tsx
function RegexTester() {
  const [pattern, setPattern] = useState("");
  const [testString, setTestString] = useState("");
  const [results, setResults] = useState<RegexResult[]>([]);
  const [isPending, startTransition] = useTransition();
 
  function handlePatternChange(value: string) {
    setPattern(value); // Urgent: update the input
 
    startTransition(() => {
      // Non-urgent: compute matches
      try {
        const regex = new RegExp(value, "g");
        const matches = [...testString.matchAll(regex)];
        setResults(
          matches.map((m) => ({
            match: m[0],
            index: m.index ?? 0,
            groups: m.groups,
          }))
        );
      } catch {
        setResults([]);
      }
    });
  }
 
  return (
    <div>
      <input
        value={pattern}
        onChange={(e) => handlePatternChange(e.target.value)}
        className={isPending ? "opacity-70" : ""}
      />
      {/* results rendering */}
    </div>
  );
}

正規表現テスターのINP:380ms → 85ms

  1. JSONフォーマッターの入力ハンドラにデバウンスを追加(300msの遅延)。JSONフォーマッターのINP:260ms → 60ms

  2. ハッシュジェネレーターの計算をWeb Workerに移動。大きな入力のSHA-256ハッシュ化が完全にメインスレッド外で行われるようになりました。ハッシュジェネレーターのINP:200ms → 40ms

結果#

2週間の最適化後(CrUXデータ、モバイル、75パーセンタイル):

  • LCP:3.8秒 → 1.8秒(53%改善)
  • CLS:0.12 → 0.00(100%改善)
  • INP:340ms → 110ms(68%改善)

Lighthouseスコア:62 → 97。

3つのメトリクスすべてが確実に「良好」の範囲内。モバイルでページは瞬時に感じられます。そして改善後の1ヶ月間でオーガニック検索トラフィックが12%増加しました(因果関係は証明できませんが ―― 他の要因も働いていました)。

チェックリスト#

この記事から何も持ち帰らないとしても、すべてのプロジェクトで確認するチェックリストがこちらです:

LCP#

  • DevToolsでLCP要素を特定
  • LCP画像にpriority(またはfetchPriority="high")を追加
  • <head>でLCPリソースをプリロード
  • レンダリングブロッキングCSSを排除
  • next/fontでフォントをセルフホスティング
  • Brotli/Gzip圧縮を有効化
  • 可能な場合は静的生成またはISRを使用
  • 静的アセットにアグレッシブなキャッシュヘッダーを設定

CLS#

  • すべての画像に明示的なwidthheight
  • サイズ調整されたフォールバック付きnext/fontを使用
  • 動的コンテンツはposition: fixed/absoluteまたは事前確保されたスペースを使用
  • スケルトンスクリーンが実際のコンポーネントサイズと一致
  • ロード後のページ上部へのコンテンツ挿入なし

INP#

  • インタラクションハンドラ中に長いタスク(>50ms)がない
  • 緊急でない状態更新はstartTransitionでラップ
  • 入力ハンドラをデバウンス(300ms)
  • 重い計算はWeb Workerにオフロード
  • 可能な場合はDOMサイズを1,500ノード以下に

全般#

  • サードパーティスクリプトはページがインタラクティブになった後に読み込み
  • バンドルサイズを分析してツリーシェイキング
  • 未使用CSSを削除
  • 画像はAVIF/WebP形式で配信
  • 本番環境にリアルユーザーモニタリングを導入(web-vitalsライブラリ)

おわりに#

パフォーマンス最適化は一回限りの作業ではありません。それは規律です。新しい機能、新しい依存関係、新しいサードパーティスクリプトのすべてがリグレッションの可能性を持っています。速さを維持するサイトは、誰かがメトリクスを継続的に監視しているサイトであり、一回限りの最適化スプリントをやったサイトではありません。

リアルユーザーモニタリングを設定しましょう。メトリクスがリグレッションしたときのアラートを設定しましょう。パフォーマンスをコードレビュープロセスの一部にしましょう。誰かが200KBのライブラリを追加したら、5KBの代替案がないか聞いてください。誰かがイベントハンドラに同期的な計算を追加したら、遅延できないかワーカーに移せないか聞いてください。

この記事のテクニックは理論的なものではありません。このサイトで私が実際にやったことであり、裏付けとなる実数があります。結果は異なるでしょう ―― すべてのサイトは違い、すべてのオーディエンスは違い、すべてのインフラは違います。しかし原則は普遍的です:読み込みを減らし、賢く読み込み、メインスレッドをブロックしないこと。

ユーザーは速いサイトに対してお礼のメッセージは送ってくれません。でも滞在してくれます。また来てくれます。そしてGoogleも気づきます。

関連記事