レビュー済み·難易度: 初級·更新: 2026-04-17

Next.js App Router の loading.tsx と Suspense でローディング UI を実装する

loading.tsx を配置するだけでルートセグメント単位のローディング UI が自動適用される仕組みと、Suspense を使った部分的な待機表示の実装例。

nextjsui-componentrouting

対応バージョン

nextjs 15react 19

前提環境

Next.js App Router のディレクトリ構成(layout.tsx / page.tsx)を理解していること

概要

Next.js App Router では、ルートセグメントに loading.tsx を置くだけでページ全体のローディング UI が自動適用される。 内部的には page.tsx<Suspense> でラップされるため、サーバーコンポーネントの非同期処理が完了するまで loading.tsx の内容が表示される。 コンポーネント単位で待機させたい場合は <Suspense fallback={...}> を直接使う。

インストール

# 追加インストールは不要

loading.tsx によるページ単位のローディング UI

// src/app/posts/loading.tsx
export default function Loading() {
  return (
    <div className="space-y-4">
      {Array.from({ length: 5 }).map((_, i) => (
        <div key={i} className="animate-pulse rounded border p-4">
          <div className="mb-2 h-4 w-1/3 rounded bg-gray-200" />
          <div className="h-3 w-full rounded bg-gray-100" />
        </div>
      ))}
    </div>
  );
}
// src/app/posts/page.tsx
async function getPosts() {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts");
  if (!res.ok) throw new Error("取得に失敗しました");
  return res.json() as Promise<{ id: number; title: string; body: string }[]>;
}

export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <ul className="space-y-4">
      {posts.map((post) => (
        <li key={post.id} className="rounded border p-4 text-sm">
          <p className="font-medium">{post.title}</p>
          <p className="text-gray-500">{post.body}</p>
        </li>
      ))}
    </ul>
  );
}

Suspense によるコンポーネント単位のローディング UI

ページの一部だけを遅延させたい場合は <Suspense> を直接使う。

// src/app/dashboard/page.tsx
import { Suspense } from "react";

function SkeletonCard() {
  return (
    <div className="animate-pulse rounded border p-4">
      <div className="mb-2 h-4 w-1/2 rounded bg-gray-200" />
      <div className="h-3 w-full rounded bg-gray-100" />
    </div>
  );
}

async function RecentPosts() {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=3");
  const posts = (await res.json()) as { id: number; title: string }[];

  return (
    <ul className="space-y-2">
      {posts.map((post) => (
        <li key={post.id} className="rounded border px-4 py-2 text-sm">
          {post.title}
        </li>
      ))}
    </ul>
  );
}

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <h1 className="text-lg font-bold">ダッシュボード</h1>
      <section>
        <h2 className="mb-2 text-sm font-medium text-gray-600">最近の投稿</h2>
        <Suspense fallback={<SkeletonCard />}>
          <RecentPosts />
        </Suspense>
      </section>
    </div>
  );
}

ポイント

  • loading.tsx を置くだけでルートセグメント全体にローディング UI が適用される(page.tsx が自動的に <Suspense> でラップされる)
  • <Suspense fallback={...}> はコンポーネント単位で使え、ページの一部だけを遅延させられる
  • スケルトン UI は animate-pulse と背景色だけで実装できる(外部ライブラリ不要)
  • loading.tsx は同一セグメントの layout.tsx の中で機能するため、ヘッダーやナビゲーションはローディング中も表示され続ける

注意点

loading.tsx はそのセグメント配下の page.tsx を自動的に Suspense でラップする。コンポーネント単位で待機させたい場合は Suspense を直接使う。どちらも fallback に同じスケルトンコンポーネントを渡せる。

関連サンプル