Streaming UIは、サーバーがHTMLを一度に返さず、準備できた部分から順にブラウザへ送って表示させる作り方です。Reactでは<Suspense>で囲んだ範囲がひとつの送信単位になり、Next.jsのApp Routerでは追加設定なしで有効になります。同じ言葉は、AIチャットが生成中の文章を数文字ずつ表示する画面にも使われます。この記事では、React 19.3.1でチャンクが届く順序を実測した結果をもとに、Next.js 16.4での実装、HTTPステータスとクローラーの扱い、途中のプロキシで段階表示が消える原因を説明し、後半でAIの応答を流す画面の設計とAI SDKのstreamUIの現状を扱います。
まとめ:Streaming UIの要点
- 仕組みは、HTTPの応答を分割して「シェル(レイアウトと代替表示)」を先に送り、
<Suspense>の中身が解決するたびに追加のHTMLとそれを差し込むスクリプトを送るものです。 - 効くのは最初の1バイトまでの時間(TTFB)と最初の描画(FCP)です。遅いAPIが1本あってもページ全体が止まらなくなります。
- Next.jsでは、ページ単位なら
loading.tsxを置くだけ、部品単位なら<Suspense>で囲みます。公式は「動的な処理の近くに<Suspense>を置く」方を推奨しています。 - 送信を始めた時点でステータス200が確定します。途中で
notFound()を呼んでも404にはならず、HTMLにnoindexが入るだけです。 - nginxやCDN、圧縮が応答をため込むと、サーバー側でストリーミングしていても利用者には一括表示に見えます。
- LCP要素(見出しやメイン画像)を
<Suspense>の中に入れるとLCPは遅くなります。ストリーミングを入れれば速くなる、という単純な話ではありません。 - AIチャットで文章を流す画面は、利用者がさかのぼったら自動スクロールを止める、描画の更新を間引く、閉じていないMarkdownを補う、途中で切れても読んだ文を消さない、の4点で崩れにくくなります。
Streaming UIの定義と従来のSSRとの違い
従来のサーバーサイドレンダリング(SSR)は、データを取り終えてからHTMLを組み立てて返すため、応答の開始はデータ取得とHTML生成の完了を待ちます。クエリを並列で実行するなら最も遅い処理が、直列なら待ち時間の合計が効きます。ストリーミングSSRは、データを待たずに描ける部分(レイアウト、ナビゲーション、<Suspense>の代替表示)を先に送り、残りを後から追記します。Next.jsの公式ガイドは、これをHTTP/1.1のチャンク転送(Transfer-Encoding: chunked)で行うと説明しています。HTTP/2以降ではチャンク転送の代わりにDATAフレームで分割して送るため、仕組みは同じでもヘッダーには現れません。
| 方式 | 応答の開始 | 最初に見えるもの | ステータスの決定 |
|---|---|---|---|
| CSR | 即時(空のHTML) | JS実行後の画面 | サーバーの振り分け次第 |
| 従来のSSR | 全データ取得後 | 完成したページ | 描画後に決められる |
| ストリーミングSSR | シェル完成時 | シェルと代替表示 | シェル送信時に確定 |
Next.jsでは、各ブロックのHTMLと一緒にハイドレーション用のコンポーネントペイロード(React Server Componentsのシリアライズ結果)も流れます。方式全体の比較はレンダリングとは?ブラウザ描画の仕組みとCSR/SSR/SSGの違い・選定基準を解説、RSCの仕組みはReact Server Components(RSC)とは?サーバーとクライアントの違い・使い分けと確認方法【React 19.3】で扱っています。
チャンクが届く順序:React 19.3.1での実測
フレームワークを挟まずにreact-dom/serverのrenderToPipeableStreamだけで、1秒と3秒で解決する2つのブロックを持つページを作り、ブラウザ以外の環境から届いたチャンクを時刻つきで記録しました。環境はNode.js v26.5.0、react/react-domともに19.3.1(2026-11-05時点のnpm latest)です。1回目の計測(2026-10-02、19.3.0)と到着順も目印も同じで、時刻の差は数十ミリ秒でした。
import { createServer } from 'node:http';
import { createElement as h, Suspense, use } from 'react';
import { renderToPipeableStream } from 'react-dom/server';
// 指定ミリ秒後に解決するPromise(DBやAPIの代役)
const wait = (ms) => new Promise((resolve) => setTimeout(() => resolve(ms), ms));
function Section({ label, promise }) {
const ms = use(promise); // 未解決のあいだは最寄りのSuspenseへ委ねる
return h('section', null, `${label}: ${ms}ms`);
}
function App({ a, b }) {
return h('html', null, h('body', null,
h('h1', null, 'shell'),
h(Suspense, { fallback: h('p', null, 'loading A') }, h(Section, { label: 'A', promise: a })),
h(Suspense, { fallback: h('p', null, 'loading B') }, h(Section, { label: 'B', promise: b }))));
}
createServer((req, res) => {
const isBot = /bot/i.test(req.headers['user-agent'] ?? '');
const { pipe } = renderToPipeableStream(h(App, { a: wait(1000), b: wait(3000) }), {
// 人にはシェルができた時点で流し、botには全部そろってから渡す
[isBot ? 'onAllReady' : 'onShellReady']() {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
},
onShellError() {
res.statusCode = 500;
res.end('shell error');
},
});
}).listen(3059);
観測側は、レスポンス本体をgetReader()で読み、チャンクごとに経過時間を出します。curlは自前でバッファリングするため、到着時刻を見るにはこの方法が確実です(Next.js公式ガイドも同じ手順を推奨しています)。
const ua = process.argv[2] ?? 'Mozilla/5.0';
const start = Date.now();
const res = await fetch('http://localhost:3059/', { headers: { 'User-Agent': ua } });
console.log(`headers +${Date.now() - start}ms status=${res.status} te=${res.headers.get('transfer-encoding')}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
for (let i = 0; ; i++) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true }).replace(/\s+/g, ' ');
console.log(`chunk ${i} +${Date.now() - start}ms ${value.length}B ${text.slice(0, 90)}`);
}
通常のUser-Agentと、Googlebot/2.1を名乗った場合の結果です(botの判定はこのデモ独自の/bot/iです)。
headers +210ms status=200 te=chunked
chunk 0 +213ms 265B <!DOCTYPE html><html><head></head><body><h1>shell</h1><!--$?--><template id="B:0"></templa
chunk 1 +1162ms 917B <div hidden id="S:0"><section>A: 1000ms</section></div><script>$RB=[];$RV=function(a){$RT=
chunk 2 +3163ms 88B <div hidden id="S:1"><section>B: 3000ms</section></div><script>$RC("B:1","S:1")</script>
chunk 3 +3163ms 14B </body></html>
--- UA=Googlebot/2.1
headers +3170ms status=200 te=chunked
chunk 0 +3173ms 144B <!DOCTYPE html><html><head></head><body><h1>shell</h1><!--$--><section>A: 1000ms</section>
chunk 1 +3173ms 14B </body></html>
読み取れることは3つあります。
- 最初のチャンク(約0.2秒、ローカル接続の確立を含む)には、
<h1>と、2つの代替表示が<template id="B:0">/<template id="B:1">の目印つきで入っています。ブラウザはこの時点で「shell」と読み込み中表示を描画できます(この実測は受信時刻であり、画面描画の時刻ではありません)。 - Aは約1.16秒、Bは約3.16秒に届き、それぞれ
<div hidden id="S:0">と、目印を差し替える$RC("B:1","S:1")のようなインラインスクリプトがセットになっています。差し替えは、アプリ本体のJavaScriptやハイドレーションの完了を待たずに実行されます。 onAllReadyを使ったbot向けの応答は、ヘッダーすら約3.2秒まで返らず、完成済みのHTMLが一度に届きます。React公式ドキュメントも、クローラー向けと静的生成ではonShellReadyの代わりにonAllReadyを使えると書いています(必須ではなく選択肢です)。
どちらの応答もステータスは200で、人向けの応答はAもBもできていない0.2秒の時点でそれを送っています。後述するSEOと運用の注意点は、すべてこの性質から来ます。
Next.jsでの実装:loading.tsxとSuspense境界
Next.js(2026-11-05時点のnpm latestは16.4.5)のApp Routerは、上の仕組みを最初から組み込んでいます。Next.js自体の導入手順はNext.jsとは?16系App Routerで最小アプリを動かす手順と採用判断で扱っています。
loading.tsxによるページ単位のストリーミング
page.tsxと同じ階層にloading.tsxを置くと、Next.jsがページ全体を<Suspense>で包み、このコンポーネントを代替表示に使います。レイアウトはシェルとしてすぐ送られ、ページ本体の準備ができたところで置き換わります。
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div aria-busy="true" style={{ minHeight: 480 }}>
<div className="skeleton" style={{ height: 32, width: 192 }} />
<div className="skeleton" style={{ height: 16, marginTop: 16 }} />
<div className="skeleton" style={{ height: 16, marginTop: 8 }} />
</div>
)
}
データがそろうまで何も意味のある表示ができないページなら、これで足ります。loading.tsxは画面遷移の際にプリフェッチされ、クリック直後に代替表示を出せる点も<Suspense>単体にない利点です。
Suspenseによるブロック単位の並行送信
独立したデータを読む部品が複数あるなら、それぞれを別の<Suspense>で囲みます。各境界は互いを待たず、解決した順に届きます。
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { Revenue } from './revenue'
import { RecentOrders } from './recent-orders'
import { CardSkeleton } from './card-skeleton'
export default function Dashboard() {
return (
<main>
<h1>売上ダッシュボード</h1>
<Suspense fallback={<CardSkeleton />}>
<Revenue />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<RecentOrders />
</Suspense>
</main>
)
}
Next.jsの公式ガイドは、loading.tsxより、動的な処理に近い位置へ置いた<Suspense>を優先するよう勧めています。理由は、階層の上のほうにあるloading.tsxも境界として成立してしまい、ページ全体が1枚のスケルトンに置き換わって細かく流せなくなるためです。上の例では<h1>が境界の外にあるので、見出しはシェルに入って最初のチャンクで表示されます。同じガイドは、レイアウトやページの先頭でcookies()やparamsをawaitすると、それより下がすべて静的シェルから外れるとも書いています。値を使う部品までawaitを下ろすのが、先に送れる範囲を広げる基本です。
Client Componentでのuseによる読み取り
Server Componentでfetchを開始し、awaitせずにPromiseのままClient Componentへ渡す書き方もあります。値を読むのはuseを呼ぶ部品だけなので、<Suspense>はその部品の周りに置けば足ります。
// app/dashboard/page.tsx(Server Component)
import { Suspense } from 'react'
import { StatsChart } from './stats-chart'
import { getStats } from './stats'
export default function Dashboard() {
const statsPromise = getStats() // awaitせずに渡す
return (
<Suspense fallback={<p>グラフを読み込み中</p>}>
<StatsChart dataPromise={statsPromise} />
</Suspense>
)
}
// app/dashboard/stats-chart.tsx
'use client'
import { use } from 'react'
import type { Stats } from './stats' // { revenue: number }
export function StatsChart({ dataPromise }: { dataPromise: Promise<Stats> }) {
const stats = use(dataPromise)
return <p>売上 {stats.revenue} 円</p>
}
useやTransitionを含むReact側の非同期UIの書き方はAsync Reactとは?Transition・Suspense・useOptimisticで非同期UIを組む実装【React 19.3】で詳しく扱っています。
Route Handlerによる生ストリームの送信
Reactの描画を通さず、テキストやServer-Sent Events(SSE)を少しずつ返したいときは、Route HandlerからWeb Streams APIのReadableStreamをそのまま返します。次は公式ガイドの例とほぼ同じ形で、200ミリ秒ごとに1行を送ります。
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
for (let i = 1; i <= 5; i++) {
controller.enqueue(encoder.encode(`chunk ${i}\n`))
await new Promise((resolve) => setTimeout(resolve, 200))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
},
})
}
公式の例はX-Content-Type-Options: nosniffを付けています。数バイトしかない応答はSafariが1024バイトまでため込むので(後述)、手元の確認ではChromeか観測スクリプトを使います。ReadableStreamとTransformStreamの使い方はStreams APIとは?ReadableStream・TransformStreamの使い方と対応状況【2026年10月】にまとめています。
HTTPステータスとクローラーの扱い
ヘッダーはシェルと一緒に送られるため、その後は変えられません。ステータスコード、リダイレクト、メタデータの扱いはこの制約から決まります。
送信開始後のnotFound・redirectの扱い
loading.tsxの代替表示が描画された時点、または<Suspense>の中で部品が待機に入った時点で、本文の送信が始まり200が確定します。その後にnotFound()が呼ばれると、Next.jsは404を返せないため、送信中のHTMLに<meta name="robots" content="noindex">を差し込みます。redirect()はHTTPのリダイレクトではなく、クライアント側の遷移になります。境界の内側で発生したエラーも同じで、最寄りのerror.tsxがその部分だけを置き換え、ステータスは200のままです。
検索結果からはnoindexで消えますが、アクセス解析や監視は404を数えられません。実際のHTTP 404が要るなら、存在確認を<Suspense>や待機の前に済ませます。
// app/posts/[slug]/page.tsx
// 前提:同じ階層と上位にloading.tsxが無いこと(あるとawait中に送信が始まる)
// postExists・Article・ArticleSkeletonの定義は省略
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const exists = await postExists(slug) // 軽い存在確認だけ先に済ませる
if (!exists) notFound() // ここならHTTP 404を返せる
return (
<Suspense fallback={<ArticleSkeleton />}>
<Article slug={slug} />
</Suspense>
)
}
同じ階層や上位にloading.tsxがあると、await paramsの時点で送信が始まり、この書き方でも404を返せません。その場合は、ページの描画前に動くproxy(Next.js 16でmiddlewareから改称)で存在確認だけを行い、見つからないURLをnot-foundルートへ書き換えます。公式は、proxyでの確認は速く保ち、本文の取得まではしないよう案内しています。
Googlebotとその他botでのメタデータ送信の違い
Next.jsはUser-Agentでbotを2種類に分けています。v16.4.5のソース(is-bot.ts)では、JavaScriptを実行できるGooglebotを「DOM bot」として扱い、人と同じくストリーミングで返します。一方、html-bots.tsの正規表現に当たるbot(Bingbot、Twitterbot、Slackbot、facebookexternalhit、AdsBot-Googleなどの「-Google」系、Google-で始まるbot)は「HTML限定bot」に分類され、generateMetadataの解決を待ってから、メタデータを<head>に入れたHTMLを返します。これはNext.js側の分類で、Bingbotのように実際にはJavaScriptを実行できるbotも含みます。Cache Componentsを使う構成では、メタデータが解決した後の本文はHTML限定botにも引き続きストリーミングで送られます。
この一覧はnext.config.jsのhtmlLimitedBotsで差し替えられますが、指定すると既定の一覧全体を上書きします。SNSのリンクプレビューでタイトルや画像が出ないときは、まずプレビュー用botのUser-Agentがこの一覧に入っているかを確かめます。
見落としやすいのは、Googleの検査ツールもHTML限定botに入ることです。Googleのクローラー一覧によると、リッチリザルトテストとSearch ConsoleのURL検査はGoogle-InspectionToolというUser-Agentでアクセスし、これがGoogle-で始まる正規表現に当たります。Next.jsは検査ツールに対して、メタデータの解決を待ってから応答を始めます(Cache Componentsを使う構成では、その後の本文はストリーミングで届くことがあります)。最初のチャンクの届き方がGooglebotとは違うため、Googlebotと同じ届き方を確かめるなら、前述の観測スクリプトのUser-AgentにGooglebotを含めて本番URLへ送ります。
段階表示が一括表示に変わるバッファリング経路
サーバーが正しく分割して送っていても、途中の層が応答をため込むと、利用者には数秒後に全部まとめて表示されたように見えます。
| 経路 | 起きること | 対策 |
|---|---|---|
| nginx等のリバースプロキシ | 既定で応答をバッファ | X-Accel-Buffering: no |
| CDN | 応答全体をためてから転送 | 事業者の設定・プランを確認 |
| gzip/Brotli圧縮 | 圧縮効率のため内部でため込む | フラッシュの間隔を確認 |
next startの既定gzip |
別途圧縮が無ければ自動で有効 | 前段で圧縮するならcompress: false |
| AWS Lambda | 既定では応答をまとめて返す | レスポンスストリーミングを明示的に有効化 |
| 静的エクスポート | ストリーミング非対応 | サーバーかDockerで動かす |
| Safari(WebKit) | 1024バイトまで描画しない | 実際のページでは通常超える |
curl |
出力をバッファ | -Nか上記の観測スクリプト |
nginxの場合、Next.js側からヘッダーで無効化できます。
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/:path*{/}?',
headers: [{ key: 'X-Accel-Buffering', value: 'no' }],
},
]
},
}
確認はChrome DevToolsのNetworkタブで、HTML文書の「Timing」を開きます。「Waiting for server response(TTFB)」が短く、「Content Download」が長い場合は、本文の受信に時間がかかっています。低速回線でも同じ見え方になるので、これだけでは分割して届いた証拠になりません。TTFBが長くContent Downloadが一瞬なら、サーバーの処理が遅いのか途中でため込まれているのかを切り分けます。サーバーの直近で観測スクリプトを動かし、そこではチャンクが分かれて届くのに利用者側で一度に届くなら、間のどこかの層でため込まれています。前章の観測スクリプトにAccept-Encoding: identityを付けて本番URLへ向ければ、圧縮を外した状態でのチャンク到着時刻も比べられます。
代替表示の設計:スケルトンとアニメーション
<Suspense>の代替表示は、中身が届いた瞬間に置き換わります。代替表示と本物の高さが違うと、その下の要素がずれてCLS(Cumulative Layout Shift)が悪化します。代替表示は最終的な部品と同じ寸法で作り、境界の外側にもmin-heightを指定して場所を確保しておきます。前章のloading.tsxでminHeightを指定しているのはこのためです。
スケルトンの明滅は、OSで「視差効果を減らす」を設定した利用者向けにprefers-reduced-motion(Media Queries Level 5)で止めます。
.skeleton {
background: #e5e7eb;
border-radius: 4px;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.5; }
}
@media (prefers-reduced-motion: reduce) {
.skeleton { animation: none; }
}
「フェードインで表示の遅れを補う」演出は、描画済みの要素を見えにくくする時間を足すだけで、LCPの計測は短くなりません。スケルトンが逆効果になる条件はスケルトンスクリーン(スケルトンUI)とは?スピナーとの使い分けとCSS・React・Next.jsの実装で整理しています。
Core Web Vitalsへの効き方
ストリーミングがどの指標に効くかは、境界をどこに置くかで決まります。Core Web Vitalsの「良好」の基準は、実ユーザーの計測値の75パーセンタイルで、LCPが2.5秒以下、INPが200ミリ秒以下、CLSが0.1以下です(INPは2024年3月12日にFIDと入れ替わっています)。
| 指標 | ストリーミングの影響 | 悪化させる置き方 |
|---|---|---|
| TTFB・FCP | シェルの描画時間まで短縮 | シェル生成前の遅いデータ待ち |
| LCP | 境界の外なら初期HTMLに含められる | メイン画像や見出しを境界の中に |
| CLS | 差し替え時にずれる可能性 | 寸法の違うスケルトン |
| INP | 境界ごとにハイドレーションを分割 | 境界がなく一括ハイドレーション |
LCPには注意が要ります。境界の中にある要素は、差し替え用のスクリプトが実行されるまで表示されません。Next.jsの公式ガイドは、LCP要素を境界の外か上に置き、next/imageのpreload指定で画像の取得だけでも最初のチャンクから始めるよう勧めています。Reactは低速回線やCPU負荷が高いときに想定外の境界でも代替表示を使うことがあるため、公式ガイドは不要な境界を足さないよう書いています。INPの改善手順はINPとは?Core Web Vitalsの応答性指標の基準値と3区間・改善手順にまとめています。
AIチャットの応答を流す画面の設計
ChatGPTのような対話画面で、生成中の文章を数文字ずつ表示する作りもStreaming UIと呼ばれます。英語圏の検索上位はこちらの意味が中心です。送る物はHTMLではなく、LLMのAPIがServer-Sent Events(SSE)などで返す差分の文字列で、ブラウザ側のJavaScriptがそれを画面に足していきます。受け取る処理は数行で書けますが、表示のほうは放っておくと崩れます。
自動スクロールと描画の頻度
末尾へ自動でスクロールし続けると、上の文を読み返そうとした利用者が毎回引き戻されます。Smashing Magazineの解説(2026年5月)は、末尾からの距離が60pxを超えたら追従をやめる方法を示しています。もう1つは描画の回数です。ブラウザの描画はおおむね1秒に60回ですが、トークンはそれより速く届くことがあり、1トークンごとにDOMを書き換えるとレイアウトの再計算が無駄に増えます。AI SDK 7のuseChatにはthrottle(ミリ秒)があり、受信と処理は遅らせずに画面の更新だけを間引けます(useChatのリファレンス)。
// components/chat-log.tsx
// 表示部分の抜粋。このuseChatに接続する送信フォームとAPIルートは別途必要。
'use client'
import { useChat } from '@ai-sdk/react'
import { useEffect, useRef, useState } from 'react'
export function ChatLog() {
// 描画の更新を50ミリ秒に1回までに間引く(受信と処理は遅らせない)
const { messages, status, error, stop, regenerate } = useChat({ throttle: 50 })
const logRef = useRef<HTMLDivElement>(null)
const [follow, setFollow] = useState(true)
// 利用者が末尾から60px以上さかのぼったら、自動スクロールをやめる
const handleScroll = () => {
const el = logRef.current
if (!el) return
setFollow(el.scrollHeight - el.scrollTop - el.clientHeight < 60)
}
useEffect(() => {
const el = logRef.current
if (el && follow) el.scrollTop = el.scrollHeight
}, [messages, follow])
const busy = status === 'submitted' || status === 'streaming'
return (
<section>
<div ref={logRef} onScroll={handleScroll} role="log" aria-label="チャット履歴" aria-busy={busy}
style={{ height: 480, overflowY: 'auto' }}>
{messages.map((m) => (
<div key={m.id}>
{m.parts.map((p, i) => (p.type === 'text' ? <p key={i}>{p.text}</p> : null))}
</div>
))}
{status === 'submitted' && <p>応答を待っています</p>}
{error && <p>応答が途中で止まりました。ここまでの内容は残っています。</p>}
</div>
{busy
? <button type="button" onClick={stop}>停止</button>
: <button type="button" onClick={() => regenerate()}>もう一度生成</button>}
</section>
)
}
このコードは@ai-sdk/react 4.0.167(ai 7.0.163)とTypeScript 7.0.2のstrictで型検査を通しています。statusはsubmitted(送信済みで最初のトークン待ち)、streaming(受信中)、ready、errorの4値です。submittedの間に何も出さないと送信に失敗したように見えるので、ここで待機表示を出します。
途中で切れたMarkdownの表示
LLMはMarkdownで書くことが多く、生成途中の文字列は**やリンクの括弧が閉じていない状態で届きます。そのまま描くと、閉じるまで記号が生のまま見え、閉じた瞬間に太字へ変わってちらつきます。VercelのStreamdown(npm latestは2.7.1)はreact-markdownの置き換えとして使える対策用の部品で、未完のMarkdownを補う処理はremendという別パッケージです。remend 1.4.0とmarked 18.1.3で、途中で切れた4つの文字列を変換した結果です。
入力 : "結論は**キャッシュを先に"
そのまま: <p>結論は**キャッシュを先に</p>
remend : "結論は**キャッシュを先に**"
変換後 : <p>結論は<strong>キャッシュを先に</strong></p>
入力 : "次のコードを使います。\n\n```ts\nconst res = await fetch(url)"
そのまま: <p>次のコードを使います。</p>\n<pre><code class="language-ts">const res = await fetch(url)\n</code></pre>
remend : "次のコードを使います。\n\n```ts\nconst res = await fetch(url)"
変換後 : <p>次のコードを使います。</p>\n<pre><code class="language-ts">const res = await fetch(url)\n</code></pre>
入力 : "詳しくは[公式ガイド](https://nextjs.org/docs/app/gui"
そのまま: <p>詳しくは[公式ガイド](<a href="https://nextjs.org/docs/app/gui">https://nextjs.org/docs/app/gui</a></p>
remend : "詳しくは[公式ガイド](streamdown:incomplete-link)"
変換後 : <p>詳しくは<a href="streamdown:incomplete-link">公式ガイド</a></p>
入力 : "`useChat`の`thro"
そのまま: <p><code>useChat</code>の`thro</p>
remend : "`useChat`の`thro`"
変換後 : <p><code>useChat</code>の<code>thro</code></p>
太字とインラインコードは閉じ記号を足してから整形し、書きかけのリンクはURLをstreamdown:incomplete-linkという仮の値に置き換えて、途中までのURLへリンクしないようにしています。閉じていないコードブロックは、CommonMarkの仕様で文書の終わりまでコードとして扱われるため、補わなくても崩れません。
停止・再試行と途中で切れた応答
停止ボタンは生成中ずっと見える位置に置きます。AI SDKのstop()は受信中のリクエストを中断します(チャットボットのガイド)。React版では、statusとerrorの更新に最新のメッセージが含まれるので、失敗した応答もそこまでの内容で描画されます。読んだ文を消して「エラーが発生しました」だけにせず、残した文の下に再生成のボタン(regenerate())を出します。コピーボタンは生成が終わってから出すと、書きかけの文がコピーされません。
ページを再読み込みしても生成を続けたい場合は、useChatのresumeとresumeStream()で再接続できます。ただしストリーム再開のガイドによると、この構成ではメッセージと生成中のストリームの保存先(Redisなど)を自分で用意する必要があり、stop()は接続を閉じるだけで生成そのものは止まりません。停止させるには、途中の応答を保存して生成を取り消す専用のエンドポイントを別に作ります。
スクリーンリーダーと視差効果への配慮
応答の入れ物にはrole="log"を付けます。WAI-ARIA 1.2では、logは新しい情報が末尾に順に足されるライブリージョンで、暗黙のaria-liveはpoliteです。ただ、数文字ごとに読み上げが割り込むと断片ばかり聞かされます。同じ仕様のaria-busy="true"は、その間の変更を支援技術が後回しにし、falseに戻ったときに1回の更新として扱ってよい(MAY)という状態です。上のコードは生成中にaria-busyを立てて、完了時にまとめて読ませる設計です。扱いは読み上げソフトによって違うため、NVDAやVoiceOverで実際に確かめます。
ほかに、生成中も入力欄からフォーカスを奪わないこと、点滅するカーソルや文字のフェードをprefers-reduced-motion: reduceで止めることも必要です。後者はスケルトンの章と同じ書き方で足ります。対話画面の操作設計からまとめて見直したい場合は、一創のUI/UXデザインでも相談を受けています。
AI SDKのstreamUIの現状と移行先
「streamUI」で検索される対象は、Vercel AI SDKのstreamUI関数です。LLMにツールを渡し、ツールが返したReactコンポーネントをServer Action経由でクライアントへ流す仕組みで、@ai-sdk/rscパッケージ(2026-11-05時点のnpm latestは3.0.163)に入っています。本体のaiは7系(7.0.163)に上がりましたが、RSC版の扱いは変わっていません。
ただし公式ドキュメントは、AI SDK RSCを実験的な機能とし、本番では「AI SDK UI」を使うよう勧めています。移行ガイドは、Server Actionではストリームを中断できない、部品が.done()のたびに再マウントされてちらつく、といった制約を挙げています。Vercel Labsの公式サンプル(ai-sdk-preview-rsc-genui)はREADMEで「AI SDK RSCの開発は現在停止中」と明記し、リポジトリもアーカイブされています。新しく作るなら、サーバーはstreamTextでツール呼び出しを流し、クライアントはuseChat(@ai-sdk/react)でtool-ツール名型のパーツを状態ごとに描き分けます。
// 表示部分の抜粋。送信フォームとstreamTextを呼ぶAPIルートは別途必要
'use client'
import { useChat } from '@ai-sdk/react'
import type { UIDataTypes, UIMessage } from 'ai'
import { Weather, type WeatherProps } from '@/components/weather'
// ツールの入出力を型で渡すと、part.output が WeatherProps になる
type ChatMessage = UIMessage<unknown, UIDataTypes, {
displayWeather: { input: { location: string }; output: WeatherProps }
}>
export function Chat() {
const { messages } = useChat<ChatMessage>()
return messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, i) => {
if (part.type === 'text') return <span key={i}>{part.text}</span>
if (part.type === 'tool-displayWeather') {
switch (part.state) {
case 'input-available':
return <p key={i}>天気を取得中</p>
case 'output-available':
return <Weather key={i} {...part.output} />
case 'output-error':
return <p key={i}>取得に失敗しました: {part.errorText}</p>
}
}
return null
})}
</div>
))
}
input-available(ツールの入力が確定し実行中)の間は読み込み表示、output-availableで本物の部品、という切り替えは、HTMLストリーミングの代替表示と同じ考え方です。useChatにツールの入出力の型を渡さないとpart.outputがunknownになり、{...part.output}が型エラー(TS2698)になります。上のコードはai 7.0.163とTypeScript 7.0.2で型検査を通した形です。パーツの型名と状態はAI SDK 7の生成UIのガイドでも同じです。サーバー側のルートを含むAI SDK 7の実装手順はVercel AI SDKの使い方|AI SDK 7でNext.jsチャットとLangChain連携を実装、AI SDK以外の生成UIフレームワークの比較はOpenUIとは?Generative UIを実装するフレームワークの仕組みと選び方【2026年】を参照してください。
Streaming UIの採用を避ける条件
ストリーミングは「遅いデータがあり、それ以外を先に見せる価値がある」ページで効きます。次の条件では入れても得がないか、損をします。
- 全データが速いページ:シェルと各ブロックの到着時刻がほぼ同じなら、差し替え用スクリプトとちらつきが増えるだけです。キャッシュで静的に返せないかを先に検討します。
- ステータスコードが仕様上重要なページ:監視や外部連携が404・500を前提にしているなら、存在確認と権限確認を境界の前に済ませます。済ませられないルートは従来のSSRで返します。
- ため込む層を外せない配信経路:社内プロキシやCDNのプランでバッファリングを無効にできないなら、利用者には効果が届きません。
- LCP要素がデータ依存のページ:商品詳細のメイン画像のように最大の要素が遅いデータに依存するなら、確実に縮むのはFCPだけです。LCPはそのデータ取得の時間より短くならないので、取得そのものを速くするのが先です。
ページの大半が静的なら、静的シェルをビルド時に生成して動的な部分だけを流すPartial Prerendering(Next.jsのCache Components)や、JavaScriptを動的な部品だけに限るアイランドアーキテクチャも選択肢になります。違いはアイランドアーキテクチャとは?仕組みとAstro 7の実装・JS量の実測、PPRとの違いで比較しています。
よくある質問
Streaming UIはSEOに悪影響がありますか?
サーバー側で描画したHTMLを送るので、それ自体は不利になりません。Googlebotは段階的に届いたHTMLもJavaScriptも処理でき、Next.jsが「HTML限定bot」に分類するBingbot等には、メタデータを<head>に入れた状態で返します。注意点は、送信開始後にnotFound()を呼ぶと404ではなく200とnoindexになることです。
streamUIとStreaming UIは同じものですか?
別物です。この記事でいうStreaming UIはHTMLを分割して送る描画方式を指し、streamUIはVercel AI SDKのRSC版にある関数名です。streamUIを含むAI SDK RSCは実験的な扱いで、公式はAI SDK UI(useChat)への移行を勧めています。チャット画面で文章を数文字ずつ出す表示もStreaming UIと呼ばれ、こちらは本文の「AIチャットの応答を流す画面の設計」で扱っています。
Streaming UIを使うにはNext.jsが必要ですか?
必要ありません。ReactのrenderToPipeableStream(Node.js)やrenderToReadableStream(Web Streams)を直接使えば、この記事の実測のように自前のサーバーでも動きます。React Routerなど他のフレームワークも<Suspense>によるストリーミングに対応しています。
ストリーミングしているのに一括で表示されるのはなぜですか?
多くはサーバーと利用者の間で応答がため込まれています。nginxならX-Accel-Buffering: no、CDNなら事業者の設定、圧縮ならフラッシュ間隔を確認します。手元のcurlもバッファリングするため、getReader()でチャンクの到着時刻を記録して確かめるのが確実です。
動画配信サービスのUIデザインのことではないのですか?
英語の「streaming UI design」は、NetflixのようなストリーミングサービスのUIデザイン事例を指して使われることもあります。この記事が扱うのは、HTMLやAIの応答を届いた順に表示するWebの作り方としてのStreaming UIで、配信サービスの画面設計は対象外です。
関連記事
- Async Reactとは?Transition・Suspense・useOptimisticで非同期UIを組む実装【React 19.3】
- React Server Components(RSC)とは?サーバーとクライアントの違い・使い分けと確認方法【React 19.3】
- スケルトンスクリーン(スケルトンUI)とは?スピナーとの使い分けとCSS・React・Next.jsの実装
- SSE(Server-Sent Events)とは?仕組み・WebSocketとの違い・実装を3ブラウザの実測で解説
- Vercel AI SDKの使い方|AI SDK 7でNext.jsチャットとLangChain連携を実装