Next.jsでComfyUIを使う画像生成アプリは、ブラウザからComfyUIに直結せず、Route Handlerを間に置いて接続先とworkflowをサーバー側に閉じ込めます。ブラウザが送るのは検証済みのpromptなどだけで、ComfyUIのURL・ノード構成・出力ファイルはブラウザに出しません。
情報確認日:2026年8月22日(日本時間)
結論:3層に分け、ComfyUIを知っているのはサーバーだけにする
責任範囲
- ブラウザ → Next.js → ComfyUIの3層構成
- Route Handlerで接続先とworkflowを閉じ込める書き方
- prompt・seed・サイズの検証Schema
- SDKと差し替えられるclient層
- 画像の返し方と一時ファイルの片付け
- 外部公開前の認証・レート制限・サイズ・タイムアウト
API経路の基本はComfyUI APIの使い方、公式SDKはTypeScript SDKの記事で扱っています。この記事は層分けと検証に絞り、React側の進捗UIとComfyUI自体の外部公開は別記事で扱います。
ブラウザからComfyUIに直結しない理由
Server APIには既定で認証がありません。ブラウザから直接叩く構成では、アドレスとworkflow JSONの全文が開発者ツールで見え、誰でも任意のworkflowを送れます。
[ブラウザ] [Next.js(サーバー)] [ComfyUI]
prompt, size ──POST──▶ /api/generate
├ Schema検証(allowlist・範囲)
├ workflow雛形に値を差し込む
└ comfyClient.submit() ──POST /prompt──▶ Queue
job_id ◀──JSON── { jobId }
/api/jobs/{id} ──GET──▶ comfyClient.history() ──GET /history/{prompt_id}──▶
{ status, imageUrl } ◀──
/api/images?… ──GET──▶ comfyClient.view() ──GET /view?filename=…──▶
画像バイナリ ◀──
ブラウザが知っているもの: 自分のprompt・jobId・画像URL(Next.js上のパス)
ブラウザが知らないもの: ComfyUIのアドレス・workflow JSON・ノードID・出力ファイル名
守りたいのは下2行です。自前のjob IDだけを渡し、prompt_idや出力ファイル名を返さなければ、ComfyUIを別サーバーやCloudに移してもブラウザ側は変わりません。
Next.js Route Handlerに接続先とSecretを閉じ込める
app/api/<path>/route.tsのexport async function POST(request: Request)がハンドラになります(Next.js公式ドキュメント、16.3系で確認)。サーバーでだけ動くため、内部URLを安全に扱えます。
# .env.local(Gitに入れない)
COMFY_BASE_URL=http://127.0.0.1:8188
COMFY_WORKFLOW_PATH=./workflows/txt2img_api.json
# NEXT_PUBLIC_ を付けた変数はブラウザに配信される。ComfyUI関連には付けない
// app/api/generate/route.ts
import { generateInputSchema } from "@/lib/generate-schema";
import { comfyClient } from "@/lib/comfy-client";
import { jobs } from "@/lib/jobs";
export const runtime = "nodejs";
export async function POST(request: Request) {
const raw: unknown = await request.json().catch(() => null);
const parsed = generateInputSchema.safeParse(raw);
if (!parsed.success) {
return Response.json({ error: "invalid input" }, { status: 400 });
}
const promptId = await comfyClient.submit(parsed.data);
const jobId = jobs.create(promptId); // 自前IDを発行し、prompt_idはサーバー内にだけ保持
return Response.json({ jobId }, { status: 202 });
}
NEXT_PUBLIC_を付けない環境変数はブラウザに出ません。request.json()の結果はunknownで受け、Schema検証を通るまで信用しません。
入力値をSchemaで検証する
受け取る値はprompt・seed・サイズ程度に限定します。モデル名・ノードID・ファイルパス・workflow JSONは受け取らず、サーバーで固定するか一覧から選ばせます。
// lib/generate-schema.ts(zodの例。検証ライブラリは他でも構わない)
import { z } from "zod";
export const SIZE_PRESETS = {
square: { width: 1024, height: 1024 },
portrait: { width: 832, height: 1216 },
landscape: { width: 1216, height: 832 },
} as const;
export const generateInputSchema = z.object({
prompt: z.string().trim().min(1).max(800),
negative: z.string().trim().max(400).default(""),
seed: z.number().int().min(0).max(4294967295).optional(), // 未指定ならサーバーで採番
size: z.enum(["square", "portrait", "landscape"]).default("square"),
});
export type GenerateInput = z.infer<typeof generateInputSchema>;
| 入力 | 受け取り方 | 受け取らない理由・上限の根拠 |
|---|---|---|
| prompt / negative | 文字列・文字数上限 | 無制限は生成時間の膨張につながる |
| seed | 整数・範囲 | JavaScriptの安全な整数範囲に収める |
| size | プリセット名のenum | 任意の幅・高さはVRAM枯渇の原因になる |
| model / LoRA | 固定か、一覧からID選択 | ファイル名を受けると任意ファイルを指定される |
| workflow JSON | 受け取らない | 任意ノードの実行を許すことになる |
ComfyUIへjobを投入するclient層
通信はlib/comfy-client.tsに閉じ込め、Route Handlerからはsubmit・status・fetchImageだけを呼びます。後で公式SDKやAPI v2に差し替えてもRoute Handlerは変わりません。以下は公式ドキュメントで確認したPOST /prompt・GET /history/{prompt_id}・GET /viewだけを使う最小版です。
// lib/comfy-client.ts
import fs from "node:fs/promises";
import { SIZE_PRESETS, type GenerateInput } from "./generate-schema";
const BASE = process.env.COMFY_BASE_URL!;
const WORKFLOW_PATH = process.env.COMFY_WORKFLOW_PATH!;
// 雛形workflow(API形式)内のノードID。File → Export Workflow (API) で書き出したJSONに合わせる
const NODE = { positive: "6", negative: "7", latent: "5", sampler: "3" } as const;
async function loadTemplate() {
return JSON.parse(await fs.readFile(WORKFLOW_PATH, "utf8"));
}
export const comfyClient = {
async submit(input: GenerateInput): Promise<string> {
const wf = await loadTemplate();
const size = SIZE_PRESETS[input.size];
wf[NODE.positive].inputs.text = input.prompt;
wf[NODE.negative].inputs.text = input.negative;
wf[NODE.latent].inputs.width = size.width;
wf[NODE.latent].inputs.height = size.height;
wf[NODE.sampler].inputs.seed = input.seed ?? Math.floor(Math.random() * 4294967295);
const res = await fetch(`${BASE}/prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: wf }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`comfy submit failed: ${res.status}`);
const data = await res.json();
return data.prompt_id as string;
},
async status(promptId: string) {
const res = await fetch(`${BASE}/history/${promptId}`, { signal: AbortSignal.timeout(5_000) });
const history = await res.json();
const entry = history[promptId];
if (!entry) return { done: false as const };
const images: { filename: string; subfolder: string; type: string }[] = [];
for (const out of Object.values<any>(entry.outputs ?? {})) {
for (const img of out.images ?? []) images.push(img);
}
return { done: true as const, images };
},
async fetchImage(img: { filename: string; subfolder: string; type: string }) {
const q = new URLSearchParams(img);
const res = await fetch(`${BASE}/view?${q}`, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) throw new Error(`comfy view failed: ${res.status}`);
return res; // body をそのままストリームで返す
},
};
NODEはFile → Export Workflow (API)で書き出したJSONに合わせます。WebSocket通知とQueue制御は別記事で扱います。
結果をブラウザに返す
/viewのURLを渡すとComfyUIのアドレスが露出します。Next.js側に画像用のRoute Handlerを置き、対応表からfilename / subfolder / typeを引いて中継します。
// app/api/jobs/[id]/route.ts
import { comfyClient } from "@/lib/comfy-client";
import { jobs } from "@/lib/jobs";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params; // Next.js 15以降、paramsはPromise
const job = jobs.get(id);
if (!job) return Response.json({ error: "not found" }, { status: 404 });
const s = await comfyClient.status(job.promptId);
if (!s.done) return Response.json({ status: "running" });
jobs.attachImages(id, s.images); // 対応表に保存。ブラウザには index だけ渡す
return Response.json({
status: "done",
images: s.images.map((_, i) => `/api/jobs/${id}/image/${i}`),
});
}
画像本体はapp/api/jobs/[id]/image/[index]/route.tsでcomfyClient.fetchImage()のbodyをそのまま返し、Cache-Controlはprivateにします。
保存先と片付け
- 中継だけ:
/viewで都度取る。最小だがComfyUI側のフォルダが増え続ける - Next.js側にコピー:完了時にアプリのストレージに保存し、ComfyUI側は定期削除する
対応表には作成時刻を持たせて期限切れを削除します。画像へのprompt埋め込みは、残すか--disable-metadataで止めるかを決めます。
再現してほしい確認:最小画面で1枚生成する
- txt2imgのworkflowを
File → Export Workflow (API)で書き出し、workflows/txt2img_api.jsonに置く app/page.tsxにtextareaと送信ボタンだけのフォームを作り、/api/generateにPOST、jobIdで/api/jobs/{id}を2秒間隔で取得する- Networkタブで、リクエスト先がすべて
/api/…で、8188やComfyUIのホスト名が現れないことを確認する - 空文字・上限超過・
size: "huge"で400が返ることを確認する
| 確認項目 | 見る場所 | 期待する状態 | 結果 |
|---|---|---|---|
| ComfyUIのアドレス露出 | Networkタブ・ソース | 現れない | |
| 不正入力の拒否 | /api/generate |
400。理由の詳細を出し過ぎない | |
| 画像の取得経路 | <img src> |
/api/jobs/…/image/0のみ |
|
| ComfyUI停止時 | /api/generate |
タイムアウト後に5xx。ブラウザが固まらない |
外部公開前に必須化する対策
ここまでは誰でも生成を依頼できる状態です。外に出す前に4つを入れます。
- 認証:
/api/generateをログイン済みユーザーに限定し、Route Handlerの先頭で確認する - レート制限:ユーザー・IP単位で同時job数と時間あたり回数に上限を置く。GPUは1台なので他ユーザーの待ち時間に直結する
- サイズ上限:promptの文字数、サイズプリセット、img2imgならアップロード画像のバイト数と拡張子
- タイムアウト:fetchに
AbortSignal.timeoutを付け、jobの最大待ち時間を超えたらPOST /interruptか失敗扱いにする
ComfyUIそのものは外に出さない:127.0.0.1かプライベート網にだけ置きます。LAN外から使う場合の認証・TLS・プロキシは別記事で扱います。
よくある質問
Server Actionsではなく Route Handler を使う理由は?
画像のバイナリ中継とポーリング用GETが必要なため、HTTPを直接扱えるRoute Handlerが素直です。
job の対応表をメモリに置くと何が困りますか?
再起動やスケールアウトで消えます。複数インスタンスにする段階でRedisやデータベースに移します。
公式SDKを使えばこのclient層は不要ですか?
構造は同じです。lib/comfy-client.tsの中身をSDK呼び出しに書き換えれば、Route Handlerはそのまま使えます。
まとめ
守るべき境界は「ComfyUIを知っているのはサーバーだけ」です。Route Handlerで入力を検証し、workflow雛形はサーバーが持ち、ブラウザには自前のjob IDと自サイト内の画像URLだけを返します。通信をclient層に閉じ込めればSDKやCloudへの移行も容易です。外部公開前には認証・レート制限・サイズ上限・タイムアウトを必須にしてください。