Next.jsでOllamaのレスポンスをストリーミングする:実際に動くSSEパターン

Dev.to / 2026/5/19

💬 オピニオンDeveloper Stack & InfrastructureTools & Practical UsageModels & Research

要点

  • この記事は、Ollamaを使うNext.jsアプリ開発において、1回のブロッキングfetchだけで待つよりもトークンのストリーミングが大きなUX改善になる理由を説明しています。
  • Next.js 15のApp Router上でSSE(Server-Sent Events)を使い、Ollamaが生成するトークンをリアルタイムにブラウザへストリーミングする動作する構成を提示します。
  • 記事ではSSEとWebSocketを比較し、LLMのワンウェイ(サーバ→クライアント)ストリーミングにはSSEの方がシンプルで十分だと主張しています。
  • 主要な構成要素として、Ollamaのストリームを中継するAPIルート、ストリームを読み取るクライアント側のフック、段階的に文章が表示されるUIの3点を示しています。

Next.jsでOllamaのレスポンスをストリーミング:本当に動くSSEパターン

多くのNext.js + Ollamaのチュートリアルでは、await fetchを1回だけ使って終わりにしています。ユーザーが質問を入力して、8秒待つと、テキストの壁が表示されます。これはUXとして良くありません。

実際のLLMアプリでは、生成されるトークンをその場でストリーミングします。ユーザーはChatGPTのように、レスポンスが単語ごとに生成されていくのを見られます。この投稿では、バックエンドにOllamaを使い、Server-Sent Events(SSE)でNext.js 15 App Router上にそれを作る方法を紹介します。100行未満で本番投入レベル。

なぜWebSocketではなくSSEなのか

トレードオフ:

SSE WebSocket
片方向(サーバー → クライアント) 双方向
自動リコネクトが組み込み 自分で実装する必要あり
プレーンHTTP、アップグレード不要 アップグレードのハンドシェイクが必要
プロキシ経由で動作 時にブロックされる
ストリーミングのオーバーヘッド 最小 フレームのオーバーヘッドが小さくない

LLMのストリーミングでは、必要なのはサーバー → クライアントだけです。SSEはシンプルさで勝ちます。WebSocketは、双方向のストリーミング(音声、リアルタイム共同編集、ツール呼び出しの対話)を必要とするまで過剰です。

アーキテクチャ

ブラウザ → /api/chat(Next.jsのルートハンドラ) → Ollama(localhost:11434)
                ↑
                Ollamaがトークンを生成するたびにSSEチャンクをブラウザへ返す

3つの要素:

  1. サーバールート — Ollamaのストリームをレスポンスへパイプします。
  2. クライアントフック — ストリームを読み取り、状態を更新します。
  3. UI — 生成されていくテキストを描画します。

サーバー:ルートハンドラ

app/api/chat/route.ts

export async function POST(request: Request) {
  const { message } = await request.json();

  const ollama = await fetch("http://localhost:11434/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: "qwen2.5:7b",
      messages: [{ role: "user", content: message }],
      stream: true,
    }),
  });

  if (!ollama.ok || !ollama.body) {
    return new Response("upstream error", { status: 502 });
  }

  const stream = new ReadableStream({
    async start(controller) {
      const reader = ollama.body!.getReader();
      const decoder = new TextDecoder();
      const encoder = new TextEncoder();
      let buffer = "";

      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          buffer += decoder.decode(value, { stream: true });

          const lines = buffer.split("
");
          buffer = lines.pop() ?? "";

返却形式: {"translated": "翻訳されたHTML"}for (const line of lines) {
            if (!line.trim()) continue;
            try {
              const obj = JSON.parse(line);
              if (obj.message?.content) {
                const sseChunk = `data: ${JSON.stringify({
                  delta: obj.message.content,
                })}

`;
                controller.enqueue(encoder.encode(sseChunk));
              }
              if (obj.done) {
                controller.enqueue(
                  encoder.encode(`data: ${JSON.stringify({ done: true })}

`)
                );
              }
            } catch {
              // JSON ではない行を無視する
            }
          }
        }
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    },
  });
}

重要な点が 2 つあります:

  • stream: true を Ollama 呼び出しで指定します。これがないと、Ollama は生成がすべて完了した後に、1 つの大きなレスポンスを返します。
  • X-Accel-Buffering: no ヘッダーです。nginx やレスポンスをバッファリングする CDN の背後にデプロイする場合、これは SSE に限って無効にします。これがないと、チャンクは最後にまとめて到着するのが見えるようになります。

Client: the hook

import { useState } from "react";

export function useChatStream() {
  const [response, setResponse] = useState("");
  const [loading, setLoading] = useState(false);

  async function send(message: string) {
    setResponse("");
    setLoading(true);

    const r = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message }),
    });

返却形式: {"translated": "翻訳されたHTML"}if (!r.body) {
      setLoading(false);
      return;
    }

    const reader = r.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    while (true) {
      const {done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("

");
      buffer = lines.pop() ?? "";

      for (const line of lines) {
        if (!line.startsWith("data: ")) continue;
        const json = JSON.parse(line.slice(6));
        if (json.delta) {
          setResponse((prev) => prev + json.delta);
        }
        if (json.done) {
          setLoading(false);
        }
      }
    }
  }

  return { response, loading, send };
}

以上でストリーミングの処理は完了です。send("hello") を呼び出すと、response がトークン単位で更新されます。

UI: the chat box

"use client";
import { useState } from "react";
import { useChatStream } from "./useChatStream";

export default function Chat() {
  const [input, setInput] = useState("");
  const { response, loading, send } = useChatStream();

  return (
    <div className="max-w-2xl mx-auto p-4 space-y-4">
      <div className="min-h-[200px] p-4 border rounded whitespace-pre-wrap">
        {response || (loading ? "thinking..." : "ask me anything")}
      </div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          send(input);
          setInput("");
        }}
        className="flex gap-2"
      >
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          className="flex-1 px-3 py-2 border rounded"
          placeholder="Ask Ollama..."
        />
        <button
          type="submit"
          disabled={loading || !input}
          className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
        >
          送信
        </button>
      </form>
    </div>
  );
}

pnpm dev を実行し、ページを開いて、トークンがリアルタイムに表示されるのを確認してください。

本番環境レベルの追加

上の雛形はローカルで動作します。公開するには:

  • 認証。 上流ストリームを開く前に、ルートハンドラで認証チェックを追加してください。そうしないと、URLを知っている誰でもあなたのローカルCPUを消費できます。
  • 会話履歴。 上のハンドラは1つのメッセージだけを受け取ります。実際のチャットでは毎回、完全な履歴を送ります。messages: ChatMessage[] を受け取り、Ollamaに転送してください。
  • キャンセル。 ユーザーが離脱したら、上流のfetchを中止します。AbortController.signal を渡し、切断時に controller.abort() を呼び出してください。
  • バックプレッシャー。 クライアントが遅い場合、コントローラのキューが増えます。controller.desiredSize を使ってこれを検知し、Ollamaからの読み取りを一時停止します。
  • Vercelへのデプロイ。 Edge Runtimeはこのパターンで動きますが、関数のタイムアウトが30秒です。より長い生成を行うなら、Node Runtimeを使うか、自前ホスティングしてください。開発マシン上で動いているローカルモデルは、当然ながらVercelから呼び出すことはできません。プロダクションではOllamaをマネージドの推論エンドポイントに置き換えることになります。

なぜこれが重要か

トークンがストリーミングされるようになると、ローカルのLLMは遅いAPIのように感じなくなり、本物のアシスタントのように感じられます。体感の待ち時間は「クラッシュした?」から「自然な会話」へ変わります。

このシリーズの前半で扱った関数呼び出しやRAGパターンと組み合わせることで、これは実ローカルAIスタックの3つ目のピースです。ローカルデータとローカルツールでチャットをストリーミングし、すべてノートPCの上で動かします。

このスタックは、2年前には実行可能な本番環境オプションとして存在していませんでした。2026年には、TypeScriptで150行です。

返却形式: {"translated": "翻訳されたHTML"}