はじめに
こんにちは、株式会社TOKOSのツキヤです!
React18から、Suspense
が本格的に使えるようになりましたね。
適切に使うことでUXやパフォーマンス観点で有益な機能です✨
本記事では、そんなSuspense
をNext.jsと組み合わせて使う一般的な方法を説明します💪
Suspenseとは?
ひとことで簡単に言うと、「コンポーネントが読み込み(ロード)中の間に、別のコンポーネントを一時的に表示する仕組み」です!
では、「読み込み中」とはどんな状態でしょうか?🤔
これもひとことで言うと「Promise
が解決していない」データを持っている状態です!
簡単なコード例を見てみましょう!
export default function Page() {
const response = fetch("https://jsonplaceholder.typicode.com/posts")
return (
<main>
<Suspense fallback={<div>Loading...</div>}>
<PostList response={response} />
</Suspense>
<footer>
{/* 静的なフッターの内容 */}
</footer>
</main>
)
}
上記のPostList
というコンポーネントは、fetch
した返り値のresponse
という値をそのままprops
として受け取っています。
また、fetch
は非同期関数なので、返り値のresponse
はPromise
でラップされている状態です。(await
等を使用していないため)
この時、PostList
はresponse
の「Promise
が解決」するまではUIとして表示することができません🥶
この「Promise
が解決」するまでの間はSuspense
のfallback
の中の <div>Loading...</div>
という文字が表示されることになるわけです!
この時、「静的なUIは即座にUIに表示させることができる」点が嬉しいポイントです✨
今回の例で言うと、footer
は静的なUIなので、データのfetch
等を待たずに即座に表示された方がユーザー体験としては嬉しいですよね🤭Suspense
を使わずにすぐにawait
をするパターンだと、「Promise
が解決」されるまでは、画面内の全てのUIが表示されないことになってしまうのです😨
Promise(= 非同期)部分を適切にSuspense
で括ることで、使っていて気持ち良いサイトにすることができるのです!
Next.jsでSuspense対応をさせてみよう
それではざっくり理解が進んだところで、Next.jsでSuspense
を使っていきましょう!
流れとしては、「Suspense
を使わないバージョン」→「Suspense
を使うバージョン」で進めていきます。
前提知識
サーバーコンポーネントやRouteHandler等の理解をしている前提で説明していきます。
また、TypeScriptを使用しています。

分からない部分は必要に応じて調べていただけると幸いです🙏
環境
- Next.js:
15.2.4
- React:
19.1.0
エンドポイントの作成
まずはRouteHandlerを使ってエンドポイントを作成します!
import { NextResponse } from "next/server"
export type Post = {
id: number
title: string
body: string
}
export async function GET() {
// 3秒待機
await new Promise((resolve) => setTimeout(resolve, 3000))
return NextResponse.json([
{
id: 1,
title: "最初の投稿",
body: "これは最初の投稿の内容です。",
},
{
id: 2,
title: "2番目の投稿",
body: "これは2番目の投稿の内容です。",
},
{
id: 3,
title: "3番目の投稿",
body: "これは3番目の投稿の内容です。",
},
])
}
データは静的に返すことにします。
ローディングしているかどうかを分かりやすく確認したいので、中で3秒程度待たせるようなロジックを入れています!
page.tsx・PostListコンポーネントの作成
次に呼び出す側のUIを作成します!
import { Post } from "@/app/api/posts/route"
export default async function Page() {
const response = await fetch("http://localhost:3000/api/posts")
const posts = (await response.json()) as Post[]
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
{/* 静的なヘッダーの内容 */}
</header>
<main className="mx-auto max-w-7xl px-8">
<div className="mt-10 w-full max-w-4xl">
<h2 className="mb-8 text-3xl font-bold text-gray-800">投稿一覧</h2>
<PostList posts={posts} />
</div>
</main>
</div>
)
}
import { Post } from "@/app/api/posts/route"
type Props = {
posts: Post[]
}
export function PostList({ posts }: Props) {
return (
<div className="space-y-6">
<div className="grid gap-6">
{posts.map((item: Post) => (
<div
key={item.id}
className="rounded-lg border border-gray-200 bg-white p-6 shadow-md transition-shadow duration-100 hover:shadow-lg"
>
<h2 className="mb-2 text-xl font-semibold text-gray-700">{item.title}</h2>
<p className="text-gray-600">{item.body}</p>
</div>
))}
</div>
</div>
)
}
これでUIは下記のようになりました!

今現在の状況が「Suspenseを使わないバージョン」になっています!
エンドポイント側で3秒待つようにしているので、ページにアクセスすると画面全体の描画までに時間がかかることが分かると思います🥶
Suspense有りで実装してみる
それでは、Suspense
有りバージョンに変更してみます!
(変更がある部分のみ抜粋しています)
export default async function Page() {
const response = fetch("http://localhost:3000/api/posts")
return (
<div className="min-h-screen bg-gray-50">
{/* ... */}
<main className="mx-auto max-w-7xl px-8">
<div className="mt-10 w-full max-w-4xl">
<h2 className="mb-8 text-3xl font-bold text-gray-800">投稿一覧</h2>
<Suspense
fallback={
<div className="space-y-6">
<div className="grid gap-6">
{[1, 2, 3].map((i) => (
<div
key={i}
className="rounded-lg border border-gray-200 bg-white p-6 shadow-md"
>
<div className="mb-2 h-6 w-3/4 animate-pulse rounded bg-gray-200"></div>
<div className="h-4 w-full animate-pulse rounded bg-gray-200"></div>
</div>
))}
</div>
</div>
}
>
<PostList responsePromise={response}/>
</Suspense>
</div>
</main>
</div>
)
}
type Props = {
responsePromise: Promise<Response>
}
export function PostList({ posts }: Props) {
const response = await responsePromise
const posts = (await response.json()) as Post[]
return (
<div className="space-y-6">
{/* 同じ内容 */}
</div>
)
}
page.tsx
側でawait
していた部分をPostList.tsx
側で行うようにした点と、Suspense
(+fallback
)コンポーネントを追加した部分が変更点となります!
こうすることで、Promise
を使用していない部分は即時でUIに描画され、PostList
の3秒待っている間はfallback
のUIが表示されることになります💪
イメージとしては、「Promise
を解決」する部分を子コンポーネントにズラした形です!
サーバーコンポーネントでの使用方法
…と書いたのですが、上記での実装自体がサーバーコンポーネントでの使用方法になっています!
具体的には、コンポーネント自体はasync
な関数コンポーネントにした上で、中でawait
を使うだけです。
応用として、先ほどのコードを改善してみます。
結論としては、props
で渡すのでは無く子コンポーネント側で直接fetch
をしてしまいます!
// propは不要!!
// type Props = {
// responsePromise: Promise<Response>
// }
export function PostList() {
const response = await fetch("http://localhost:3000/api/posts")
const posts = (await response.json()) as Post[]
return (
<div className="space-y-6">
{/* 同じ内容 */}
</div>
)
}
こうすることで、props
の受け渡しすら必要無くなります💪
(page.tsx
側はfetch部分の記述をする必要が無くなります)
また、もし他のコンポーネントかもこのエンドポイントを叩きたいとなっても、Next.jsにはRequest Memoizationという機能があるので、重複した通信は無くなります✨
クライアントコンポーネントでの使用方法
ボタン等インタラクティブなコンポーネントの場合はサーバーコンポーネントを使えない = await
を使うことができません😨
そんな場合(=クライアントコンポーネント)でもSuspense
を使う方法(≒「Promiseの解決方法」)を解説します。
端的に言うと、「use
」を使うのが一番手っ取り早いです!
まずはコードを見てみます!
export default async function Page() {
const response = fetch("http://localhost:3000/api/posts")
return (
<div className="min-h-screen bg-gray-50">
{/* ... */}
<main className="mx-auto max-w-7xl px-8">
<div className="mt-10 w-full max-w-4xl">
<h2 className="mb-8 text-3xl font-bold text-gray-800">投稿一覧</h2>
<Suspense
fallback={/* ... */}
>
<PostList responsePromise={response.then((res) => res.json())}/>
</Suspense>
</div>
</main>
</div>
)
}
"use client"
import { use } from "react"
import { Post } from "@/app/api/posts/route"
type Props = {
responsePromise: Promise<Post[]>
}
export function PostList({ responsePromise }: Props) {
// ここ!!
const posts = use(responsePromise)
return (
<div className="space-y-6">
{/* 同じ内容 */}
</div>
)
}
変更としては、親側でresponse.then((res) => res.json())
としてthen
の記述を追加し、子側のクライアントコンポーネントでuse
を使用しているだけです!
「use
」をひとことで言うと、「Promise
を解決する」hookです!
本記事で度々出ている「Promise
を解決する」というワードにピッタリのhookですね😎

「use
」はReact19からの新機能です✨
このように、サーバーコンポーネントでもクライアントコンポーネントでもSuspenseを使ってユーザー体験を向上することができます💪
おわりに
Suspenseの利点としては他にも、「Promise.all
が不要になる」「ローディング中のUIをfallbackに任せられるためDX(Developer Experience)が向上する」等もあったりします!
また、紹介したuse
を使わなくても、SWRやTanStack Queryのようなライブラリを使うことでよりスマートに記述することも可能です😆
Suspenseのベストプラクティスは定まってないように思うので、本記事を参考にした上でぜひカスタマイズしてみてください!
基本は「より子コンポーネント側でPromiseを解決させるべき」だと思っています🤔
また、ページ遷移自体を高速にしたい場合はloading.tsx
を使用することも検討した方が良いかもしれないです。
僕もケースバイケースで考えて行きたいです💪