AI活用

Next.jsからComfyUIを呼ぶ画像生成アプリ|APIを安全に隠す構成

Next.jsのRoute Handlerを間に置き、ComfyUIの接続先とworkflowをサーバー側に閉じ込める3層構成を解説。zodによる入力検証、client層の分離、画像の中継、外部公開前の認証・レート制限・タイムアウトまで分かります。

この記事の目次
  1. 結論:3層に分け、ComfyUIを知っているのはサーバーだけにする
  2. ブラウザからComfyUIに直結しない理由
  3. Next.js Route Handlerに接続先とSecretを閉じ込める
  4. 入力値をSchemaで検証する
  5. ComfyUIへjobを投入するclient層
  6. 結果をブラウザに返す
  7. 保存先と片付け
  8. 再現してほしい確認:最小画面で1枚生成する
  9. 外部公開前に必須化する対策
  10. よくある質問
  11. Server Actionsではなく Route Handler を使う理由は?
  12. job の対応表をメモリに置くと何が困りますか?
  13. 公式SDKを使えばこのclient層は不要ですか?
  14. まとめ

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.tsexport 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からはsubmitstatusfetchImageだけを呼びます。後で公式SDKやAPI v2に差し替えてもRoute Handlerは変わりません。以下は公式ドキュメントで確認したPOST /promptGET /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 をそのままストリームで返す
  },
};

NODEFile → 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.tscomfyClient.fetchImage()bodyをそのまま返し、Cache-Controlprivateにします。

保存先と片付け

  • 中継だけ/viewで都度取る。最小だがComfyUI側のフォルダが増え続ける
  • Next.js側にコピー:完了時にアプリのストレージに保存し、ComfyUI側は定期削除する

対応表には作成時刻を持たせて期限切れを削除します。画像へのprompt埋め込みは、残すか--disable-metadataで止めるかを決めます。

再現してほしい確認:最小画面で1枚生成する

  1. txt2imgのworkflowをFile → Export Workflow (API)で書き出し、workflows/txt2img_api.jsonに置く
  2. app/page.tsxにtextareaと送信ボタンだけのフォームを作り、/api/generateにPOST、jobId/api/jobs/{id}を2秒間隔で取得する
  3. Networkタブで、リクエスト先がすべて/api/…で、8188やComfyUIのホスト名が現れないことを確認する
  4. 空文字・上限超過・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への移行も容易です。外部公開前には認証・レート制限・サイズ上限・タイムアウトを必須にしてください。

スポンサーリンク