デザインについての学習メモブログ

Next.js入門 #16 実践編 パフォーマンス最適化 — 画像・フォント・バンドルサイズ

記事内に広告が含まれています。

Next.js入門 #16 実践編 パフォーマンス最適化 — 画像・フォント・バンドルサイズ

#15までで、機能・品質ともに一通り揃ったミニブログが完成しました。

今回は視点を変えて、表示速度にフォーカスします。

どれだけ機能が豊富でも、表示が遅いアプリは離脱率が上がり、SEO評価にも悪影響を与えます。

この記事では、next/imagenext/font・動的インポートという3つの武器を使い、実際にLighthouseのスコアやCore Web Vitalsを改善していく過程を追います。

この記事は#7〜#8の基礎知識が前提です。

この記事のゴール

  • Core Web Vitalsという指標の意味を理解する
  • next/imagenext/fontを正しく使い、表示速度を改善する
  • 動的インポートでバンドルサイズを削減する
  • Lighthouseでビフォーアフターを計測する

Core Web Vitalsとは

Core Web VitalsとはGoogleが提唱する、Webページの体感速度を測るための指標群です。

主に次の3つで構成されます。

指標何を測るか目安
LCP(Largest Contentful Paint)最も大きな要素(画像や見出しなど)が表示されるまでの時間2.5秒以内
INP(Interaction to Next Paint)クリックなどの操作に対する応答の速さ200ミリ秒以内
CLS(Cumulative Layout Shift)表示中にレイアウトがガタつく度合い0.1以下

この3つは、実際のGoogle検索の順位にも影響を与えるとされています。

「なんとなく速い/遅い」ではなく、こうした具体的な数値で改善の効果を確認していきます。

計測環境を用意する

まず、Chrome DevToolsの「Lighthouse」タブでビフォーの数値を記録しておきます。

アプリを本番ビルドで起動する

Bash
npm run build
npm run start

つまずきやすいポイント

npm run buildを実行すると、「Cannot find module ‘vitest-mock-extended’」のようなエラーで失敗することがあります。

これは、#15で作ったlib/__mocks__/prisma.ts(テスト専用ファイル)まで、本番ビルドの型チェック対象に含まれてしまっているためです。

tsconfig.jsonexcludeに、テスト関連のファイルを追加してください。

JSON
// tsconfig.json
{
  "exclude": [
    "node_modules",
    "**/*.test.ts",
    "**/__mocks__/**",
    "tests/**"
  ]
}

*playwright.config.tsは含めない

DevTools → Lighthouseタブ → 「Analyze page load」を実行

Chromeで対象ページを開き、DevTools → Lighthouseタブ → 「Analyze page load」を実行

開発モード(npm run dev)はデバッグ用のコードが含まれるため計測に適しません。

必ず本番ビルドで計測するのがポイントです。

next/imageを正しく使う

#14で軽く触れましたが、ここで改めてnext/imageのポイントを整理します。

width/heightを正しく指定してCLSを防ぐ

TSX
// Bad: サイズ未指定だと、画像読み込み前後でレイアウトがガタつく
<Image src={post.coverImage} alt={post.title} fill />

// Good: 実際のアスペクト比に合わせてwidth/heightを固定
<Image
  src={post.coverImage}
  alt={post.title}
  width={640}
  height={360}
  className="rounded-md object-cover"
/>

width / heightをあらかじめ指定しておくことで、画像の読み込みが完了する前に領域が確保され、CLS(レイアウトのガタつき)を防げます。

ファーストビューの画像にはpriorityを付ける

TSX
// app/posts/[id]/page.tsx
<Image
  src={post.coverImage}
  alt={post.title}
  width={1200}
  height={630}
  priority // LCP対象の画像には必須
/>

priorityを指定すると、その画像がページ読み込み時に優先的にプリロードされます。

記事詳細ページのアイキャッチ画像のように、LCPの対象になりやすい画像には必ず付けましょう。

逆に、一覧ページの2枚目以降の画像など、スクロールしないと見えないものには付けてはいけません。

全部にpriorityを付けると、かえって最初の読み込みが重くなってしまいます。

sizesで無駄な転送量を減らす

TSX
// app/posts/page.tsx(一覧ページ)
<Image
  src={post.coverImage}
  alt={post.title}
  width={640}
  height={360}
  sizes="(max-width: 768px) 100vw, 33vw"
  className="rounded-md object-cover"
/>

sizesを指定すると、画面幅に応じて実際に必要なサイズの画像だけをブラウザが選んでダウンロードします。

スマートフォンで閲覧しているユーザーに、PC用の大きな画像を配信してしまう無駄を防げます。

next/fontでWebフォントを最適化する

Google Fontsを<link>タグで読み込む従来の方法は、外部サーバーへのリクエストが発生し、フォント読み込み中に文字が表示されない/レイアウトが崩れるといった問題を起こしがちです。

next/fontはビルド時にフォントファイルをダウンロードして自己ホスティングすることで、これらを解消します。

TSX
// app/layout.tsx(フォント設定の追加部分のみ抜粋)
import { Noto_Sans_JP } from "next/font/google";

const notoSansJP = Noto_Sans_JP({
  subsets: ["latin"],
  weight: ["400", "700"],
  display: "swap",
  variable: "--font-noto-sans-jp",
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja" className={notoSansJP.variable}>
      <body className="font-sans">{children}</body>
    </html>
  );
}

⚠️注意: これはフォント設定の変更点だけを示す抜粋です。

実際のapp/layout.tsxには、#12#13で追加したProviders / Header / Toasterなどがすでに存在しているはずなので、それらを消さずにclassName={notoSansJP.variable}font-sansクラスの追加分だけを既存のコードに反映してください。

CSS
/* app/globals.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";

@theme inline {
  --font-sans: var(--font-noto-sans-jp);
  /* 既存の --color-background などはそのまま */
}

#11#14で触れたとおり、このプロジェクトはTailwind v4のためtailwind.config.tsは存在しません。

フォントの対応付けも、app/globals.css@themeブロックに1行追加するだけで完了します。

display: "swap"を指定すると、フォントの読み込みが完了するまでの間、代替フォントでテキストを先に表示し、読み込み完了後に切り替えます。

ユーザーが「真っ白な画面で待たされる」状態を避けられます。

動的インポートでバンドルサイズを削減する

#12#14で追加してきたTanStack Query・react-markdown・shadcn/uiのDialogなど、機能が増えるほどJavaScriptのバンドルサイズは大きくなっていきます。

バンドルサイズが大きいと、ページの初期表示に必要なJavaScriptのダウンロード・パース・実行に時間がかかり、INPやLCPが悪化します。

バンドルサイズを可視化する

Bash
npm install -D @next/bundle-analyzer
TypeScript
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";

const bundleAnalyzer = withBundleAnalyzer({
  enabled: process.env.ANALYZE === "true",
});

export default bundleAnalyzer({
  // 既存の設定
});
Bash
ANALYZE=true npm run build

ANALYZE=trueのようにコマンドの前に環境変数を置く書き方は、macOS/Linuxのシェル(bash/zshなど)で使える構文です。

Windowsのコマンドプロンプトではこのままだとエラーになります。

OSを問わず動かしたい場合は、cross-envを使うと解決します。

Bash
npm install -D cross-env
JSON
// package.json
{
  "scripts": {
    "analyze": "cross-env ANALYZE=true next build"
  }
}

こうしておけば、npm run analyzeだけでOSを問わず同じ結果になります。

実行すると、各ページがどのライブラリをどれだけの容量含んでいるかを視覚化したレポートがブラウザで開きます。

ここで、react-markdownや投稿用のDialogが、記事一覧を「見るだけ」のユーザーにも読み込まれてしまっていることに気づくはずです。

投稿モーダルを動的インポートに切り替える

記事を「見るだけ」のユーザーは、投稿フォーム(Markdownエディタや画像アップロードを含む、比較的重いコンポーネント)を必要としません。

next/dynamicを使い、モーダルを開くまで読み込みを遅延させます。

TSX
// components/header.tsx
"use client";

import dynamic from "next/dynamic";
import { Button } from "@/components/ui/button";
import { usePostDialogStore } from "@/stores/use-post-dialog-store";

const NewPostDialog = dynamic(() => import("@/app/posts/_components/new-post-dialog"), {
  ssr: false, // モーダルの中身はクライアント操作でしか使われないためSSR不要
});

export default function Header() {
  const { isOpen, open } = usePostDialogStore();

  return (
    <header className="flex items-center justify-between p-4">
      <h1 className="text-lg font-bold">ミニブログ</h1>
      <Button onClick={open}>新しい記事を書く</Button>
      {isOpen && <NewPostDialog />}
    </header>
  );
}

isOpentrueになるまでNewPostDialog自体がインポートされないため、react-markdownや画像アップロード関連のコードも、実際にモーダルを開くまでダウンロードされません。

記事一覧を見るだけのユーザーにとっての初期表示は、これだけで軽くなります。

react-markdownの遅延読み込み

同様に、記事詳細ページのReactMarkdownも動的インポートにできます。

記事詳細ページのPostContentは、DBに保存されているMarkdown文字列(post.content)を、見出しやコードブロック付きのHTMLとしてレンダリングするコンポーネントでした(#14)。

詳細ページを開いたタイミングで初めて読み込むようにします。

TSX
// app/posts/[id]/post-content.tsx
import dynamic from "next/dynamic";

const ReactMarkdown = dynamic(() => import("react-markdown"), {
  loading: () => <p className="text-muted-foreground">読み込み中...</p>,
});

export default function PostContent({ content }: { content: string }) {
  return <ReactMarkdown>{content}</ReactMarkdown>;
}

Server Component内ではnext/dynamicssr: falseが使えないため、PostContentのようにClient Componentとして切り出す必要があります。

ページ全体をClient Componentにする必要はなく、Markdown描画部分だけを切り出すのがポイントです。

ビフォーアフターを計測する

一連の最適化を終えたら、再度Lighthouseで計測します。

目安として、次のような変化が見られれば成功です。

指標BeforeAfter
LCP3.8秒1.9秒
バンドルサイズ(記事一覧ページ)420KB210KB
CLS0.180.02

数値はあくまで一例です。実際の改善幅は、コンテンツ量やネットワーク環境によって変わります。

まとめ

この記事では、以下の最適化を行いました。

  • next/imagewidth / height / priority / sizesを適切に設定し、LCPとCLSを改善
  • next/fontでWebフォントを自己ホスティングし、フォント起因のレイアウトのガタつきを防止
  • @next/bundle-analyzerでバンドルサイズを可視化し、投稿モーダルとMarkdownレンダリングを動的インポートに切り替え

パフォーマンス改善は、一度やって終わりではなく、機能追加のたびにバンドルサイズを確認する習慣を持つことが大切です。

次回の#17 環境ごとのデプロイ戦略 — ステージングと本番を分けるでは、ここまで作ってきたアプリを、GitHub ActionsによるCI/CDパイプラインを通じて安全に本番デプロイする方法を扱います。