HiAPI
ブログ

チュートリアル

hiapi API を使って AI 画像生成機能を Next.js アプリに追加する

ルートハンドラープロキシ、非同期タスクのポーリング、永続ストレージ、そして CLS ゼロ——約 150 行の TypeScript で本番環境に近い統合を実現します。

Jun 16, 2026
hiapi API を使って AI 画像生成機能を Next.js アプリに追加する

画像生成モデルを Next.js 14 App Router アプリに組み込むのは小さなプロジェクトですが、ほぼ誰もが同じ 4 つの重要な難所でつまずきます:

  1. API には 60〜120 秒かかる——単一のクライアントリクエストとしては長すぎ、ほとんどのエッジランタイムにとっても長すぎます。
  2. API キーをブラウザに送ることはできないため、ルートハンドラーをプロキシとして用意する必要があります。
  3. 結果 URL の有効期限は短いため、ユーザーがリロードする前に永続ストレージへ保存する必要があります。
  4. 画像が返ってくるまでサイズは不明で、そのままレンダリングすると Cumulative Layout Shift(累積レイアウトシフト)を大きく悪化させます。

このチュートリアルでは、本番環境に近い hiapi 画像 API 統合——タスク作成、ポーリング、永続アップロード、そして CLS セーフなレンダリング——を約 150 行の TypeScript で完成させます。すべてのコードは実際の app/ ディレクトリにそのまま配置できます。


非同期タスクの規約

hiapi はすべての画像・動画モデルに対して単一のエンドポイントを提供します:POST https://api.hiapi.ai/v1/tasks。完全に非同期であり、タスクを送信し、終端状態になるまでポーリングし、その後出力をダウンロードします。長時間接続の HTTP も、100 秒のプロキシタイムアウトも、Edge ランタイムの落とし穴もありません。

タスクには 4 つの状態があり得ます:

  • pending——受け付けられたがまだ取り出されていない
  • running——生成中
  • success——output[0].url が準備完了(署名付き、期限付き)
  • fail——error.code と error.message が設定済み

1K 画像 1 枚の生成には通常 30〜120 秒かかります。その大部分はモデルの実行時間であり、API 自体が測定可能なオーバーヘッドを加えることはありません。


ステップ 1:プロジェクトのセットアップ

Next.js 14+ App Router プロジェクトと 1 つの環境変数が必要です:

# .env.local
HIAPI_API_KEY=sk-...   # ブラウザに絶対に露出しないこと

このキーをサーバー側のシークレットとしてデプロイプラットフォーム(Vercel、Cloudflare Pages、Railway)に追加してください。NEXT_PUBLIC_ プレフィックスを付けてはいけません。

永続ストレージを使う場合(推奨——ステップ 5 で扱います)は、さらに以下を追加します:

R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=...
R2_PUBLIC_BASE=https://cdn.your-domain.com   # バケット上のカスタムドメイン

ステップ 2:ルートハンドラー——なぜ必要なのか

ルートハンドラーが存在する唯一の理由:API キーをサーバー側に保持することです。React コンポーネントから発行されるリクエストはすべてユーザーのブラウザ上で動作し、クライアントコードにコンパイルされた文字列は DevTools のタブ 1 つ分の距離で盗まれる可能性があります。

ルートハンドラーはまた、リクエストが課金対象エンドポイントに到達する前にレート制限を適用し、使用状況を記録し、プロンプトを検証するための、単一で監査可能な場所を提供します。

app/api/images/route.ts を作成します:

// app/api/images/route.ts
import { NextResponse } from "next/server";

export const runtime = "nodejs";          // 長時間 fetch には Edge ではなく Node が必要
export const maxDuration = 180;           // Vercel:サーバーレス関数のタイムアウトを延長

const HIAPI = "https://api.hiapi.ai/v1/tasks";

type CreateBody = {
  prompt: string;
  model?: string;
  aspectRatio?: string;   // 例:"16:9"、"3:2"、"1:1"
  resolution?: "1K" | "2K" | "4K";
};

export async function POST(req: Request) {
  const body = (await req.json()) as CreateBody;
  if (!body?.prompt || body.prompt.length > 2000) {
    return NextResponse.json({ error: "invalid prompt" }, { status: 400 });
  }

  const res = await fetch(HIAPI, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.HIAPI_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: body.model ?? "Nano-Banana-2",
      input: {
        prompt: body.prompt,
        aspect_ratio: body.aspectRatio ?? "16:9",
        resolution: body.resolution ?? "1K",
      },
    }),
  });

  if (!res.ok) {
    const text = await res.text();
    return NextResponse.json({ error: text }, { status: res.status });
  }

  const { data } = await res.json();
  // data.taskId はクライアントがポーリングに使うハンドル
  return NextResponse.json({ taskId: data.taskId });
}

重要なのに忘れがちな設定が 2 つあります:

  • runtime = "nodejs"——多くの環境では、Edge ランタイムは単一の fetch を約 25 秒に制限するため、ポーリングループには不向きです。
  • maxDuration = 180——Vercel のサーバーレス関数はデフォルトで Hobby プランが 10 秒、Pro プランが 60 秒です。この値を手動で引き上げないと、サーバー側で待機する実装を選んだ場合、ルートがポーリングの途中で強制終了されます。

ステップ 3:ポーリングエンドポイント(あるいは:なぜサーバー側で待つべきでないか)

クライアントに進捗を表示する方法は 2 つあります:

方式 メリット デメリット
サーバー側で待って一度に返す クライアントリクエストが 1 回で済む サーバーレスの呼び出しを 60〜120 秒占有し、タイムアウトしやすく、進捗フィードバックがない
サーバーが taskId を返し、クライアントがポーリング サーバーコストが低く、リアルタイムな進捗 エンドポイントをもう 1 つ作る必要がある

本番環境では、2 つ目の方式が明らかに優れています。app/api/images/[id]/route.ts を追加します:

// app/api/images/[id]/route.ts
import { NextResponse } from "next/server";

export const runtime = "nodejs";

const HIAPI = "https://api.hiapi.ai/v1/tasks";

export async function GET(
  _req: Request,
  { params }: { params: { id: string } },
) {
  const res = await fetch(`${HIAPI}/${params.id}`, {
    headers: { "Authorization": `Bearer ${process.env.HIAPI_API_KEY}` },
    cache: "no-store",
  });

  if (!res.ok) {
    return NextResponse.json({ error: await res.text() }, { status: res.status });
  }

  const { data } = await res.json();
  // status ∈ { pending, running, success, fail }
  return NextResponse.json({
    status: data.status,
    url: data.output?.[0]?.url ?? null,
    error: data.error ?? null,
  });
}

このエンドポイントが、クライアントが 3 秒ごとにポーリングする対象です。各呼び出しは高速(200ms 未満)かつステートレスです。


ステップ 4:クライアントコンポーネント

シンプルなフォーム、ローディングインジケーター付きの読み込み状態、そして結果キャンバスです。ポーリングは setInterval で設定し、終端状態に達するかコンポーネントがアンマウントされたらクリアします。

// app/generate/page.tsx
"use client";

import { useEffect, useRef, useState } from "react";

type Status = "idle" | "queued" | "running" | "done" | "error";

export default function GeneratePage() {
  const [prompt, setPrompt] = useState("");
  const [status, setStatus] = useState<Status>("idle");
  const [url, setUrl] = useState<string | null>(null);
  const [taskId, setTaskId] = useState<string | null>(null);
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);

  useEffect(() => () => {
    if (pollRef.current) clearInterval(pollRef.current);
  }, []);

  async function start() {
    setStatus("queued");
    setUrl(null);
    const r = await fetch("/api/images", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ prompt, aspectRatio: "16:9" }),
    });
    if (!r.ok) return setStatus("error");
    const { taskId } = await r.json();
    setTaskId(taskId);
    setStatus("running");

    pollRef.current = setInterval(async () => {
      const p = await fetch(`/api/images/${taskId}`).then(x => x.json());
      if (p.status === "success") {
        setUrl(p.url);
        setStatus("done");
        clearInterval(pollRef.current!);
      } else if (p.status === "fail") {
        setStatus("error");
        clearInterval(pollRef.current!);
      }
    }, 3000);
  }

  return (
    <main className="mx-auto max-w-2xl p-8 space-y-6">
      <textarea
        value={prompt}
        onChange={e => setPrompt(e.target.value)}
        rows={3}
        className="w-full border rounded p-2"
        placeholder="A sunset over a misty pine forest, cinematic, 16:9"
      />
      <button
        onClick={start}
        disabled={!prompt || status === "queued" || status === "running"}
        className="px-4 py-2 rounded bg-black text-white disabled:opacity-50"
      >
        {status === "running" ? "Generating…" : "Generate"}
      </button>

      {/* 予約済みキャンバス——この正確なアスペクト比の div がなぜ重要かはステップ 6 を参照 */}
      <div className="relative w-full" style={{ aspectRatio: "16/9" }}>
        {status === "running" && (
          <div className="absolute inset-0 grid place-items-center bg-neutral-100 rounded">
            <span className="text-neutral-500">Waiting on task {taskId?.slice(0, 8)}…</span>
          </div>
        )}
        {url && (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={url}
            alt={prompt}
            className="absolute inset-0 w-full h-full object-cover rounded"
          />
        )}
      </div>
    </main>
  );
}

今のうちに把握しておき、後から段階的に追加していくとよい本番向けの改善点をいくつか挙げます:

  • 指数バックオフ(3 秒 → 5 秒 → 8 秒)により、生成が遅いときのリクエスト数を減らせます。
  • アイドルタイムアウト(例:240 秒後に諦める)により、死んだタスクへのゾンビポーリングを防げます。
  • 部分的な進捗テキスト——"running" と "pending"——を表示することで、ユーザーに何かが進行していることを確信させられます。

ステップ 5:結果の永続化——出力 URL は期限切れになる

モダンな画像 API を使う際の最もよくある間違いは、output[0].url をデータベースに保存すれば万事解決だと思うことです。これらの URL は署名付きで短命(通常は数分から数時間)であり、ユーザーがリロードすると 404 になります。

解決策は、自分のサーバー経由でバイトデータを永続ストレージへパイプすることです。同じ app/api/images/[id]/route.ts ハンドラーが適切な場所です——success レスポンスを初めて受け取ったときにのみ実行し、URL をクライアントに返す前に行います。

// `data.status === "success"` チェックの後に app/api/images/[id]/route.ts へ追加
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({
  region: "auto",
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

async function persist(taskId: string, sourceUrl: string): Promise<string> {
  const bytes = new Uint8Array(await (await fetch(sourceUrl)).arrayBuffer());
  const key = `generations/${taskId}.jpg`;
  await s3.send(new PutObjectCommand({
    Bucket: process.env.R2_BUCKET!,
    Key: key,
    Body: bytes,
    ContentType: "image/jpeg",
    CacheControl: "public, max-age=31536000, immutable",
  }));
  return `${process.env.R2_PUBLIC_BASE}/${key}`;
}

ステータスルートで、状態が success になったら persist() を使い、上流の署名付き URL ではなく永続化された URL をクライアントへ返します。クライアントは期限切れになる URL を一度も目にすることなく、明日リロードしても有効なままです。

自分のデータベース(Postgres、Supabase など)に永続化する場合は、署名付き URL ではなく永続化 URL を保存してください。


ステップ 6:CLS をなくす

Cumulative Layout Shift は、AI 画像アプリにおける Core Web Vitals の静かなる破壊者です。画像はページ読み込みから 60 秒以上経ってから到着します。サイズ未指定のコンテナ内で <img> としてレンダリングすると、結果が返るたびにその下のすべてのコンテンツが移動します。

信頼できるテクニックは 2 つあります:

CSS でアスペクト比を予約する。これが上記コンポーネントの aspectRatio: "16/9" スタイルが行っていることです。画像が読み込まれているかどうかに関係なく、コンテナはそのスペースを維持します。

リクエストのアスペクト比を一致させる。hiapi に aspectRatio: "16:9" を送り、コンテナに 16/9 を指定します。両者は一致していなければなりません。さもないと黒帯が入ったり、さらに悪ければ予約した形状と一致しなくなります。

Next.js の <Image> コンポーネントでも同じ原則が当てはまります:リクエストした比率に一致する width と height 属性を渡してください。親要素がすでに固定サイズのボックスでない限り、fill は使わないでください。

import Image from "next/image";

<Image
  src={persistedUrl}
  alt={prompt}
  width={1280}
  height={720}
  className="rounded"
  priority={false}
/>

ステップ 7:モデルの選択とコストの見積もり

hiapi 上の画像モデルは同じエンドポイント構造を共有しており、異なるのは model フィールドだけです。よく使われる選択肢と、デフォルト解像度での 1 枚あたりの価格:

モデル 価格 / 1K 画像 強み
qwen-image-2.0 $0.025 最安値。デフォルト 2K。日中韓のテキストレンダリングに強い
gpt-image-2 $0.03 アスペクト比が安定。タイポグラフィに強い
Nano-Banana $0.05 非常に高速(1〜2 秒)。キャラクターの一貫性が良い
flux-1.1-pro $0.05 芸術的な雰囲気を持つフォトリアル
Nano-Banana-2 $0.085(1K)/ $0.076(2K)/ $0.114(4K) ハイエンド品質、ネイティブ 4K

正直なところ、デフォルトの選択はタスクによって変わります。UI モックアップや見出しには gpt-image-2 が最も無難です。ネイティブ 4K のフォトリアルには Nano-Banana-2 を選びましょう。1 枚ごとに捨てても気にならないバッチ作業には qwen-image-2.0 か Nano-Banana を選びます。

10 万人ユーザーのアプリで、各セッションが毎日 1 枚の画像を生成する場合:

  • qwen-image-2.0:1 日あたり $2,500
  • Nano-Banana-2 1K:1 日あたり $8,500

上記のアーキテクチャは変わりません。変わるのはステップ 2 の model フィールドだけです。


ステップ 8:本番環境チェックリスト

リリース前に:

  1. ルートハンドラーにレート制限をかける——そうしないと 1 つの IP が 1 日で月間予算を食い尽くす可能性があります。Upstash、Cloudflare Rate Limiting、あるいは単純な Redis カウンターで十分です。
  2. サーバー側で転送する前にプロンプトの長さと内容を検証する。hiapi プラットフォームは一部の内容を拒否します。400 エラーのために往復レイテンシを払いたくはないでしょう。
  3. ユーザーごとの 1 日あたりのクォータを設定する——userId と YYYY-MM-DD ごとに生成回数を記録し、その日の 11 回目には親切なメッセージで拒否します。
  4. レスポンスの前に永続化する——結果 URL が永続ストレージを経由せずにクライアントへ到達することのないようにしてください。
  5. 結果コンテナのサイズをリクエストしたアスペクト比に一致させて事前設定する。このステップを飛ばすかどうかが、CLS が 0 か 0.4 かの違いになります。
  6. 永続化ステップをバックグラウンドに移すことで、クライアントへの応答をサブ秒にできます:上流 URL を即座に返し、キュー内のタスクで R2 へのミラーリングとデータベース行のパッチを行うようにします。

最後の点にはもう一言付け加える価値があります:ほとんどのアプリでは必要ありません。インラインの永続化はコードをシンプルに保ち、追加の 1〜2 秒は 60 秒の待機の後に発生するため、その時点でユーザーはすでにローディングインジケーターを見ています。


次のステップ

これで、約 150 行の TypeScript による、完全で安全、かつ CLS セーフな hiapi 画像 API の Next.js 統合が手に入りました。ここから先は、同じ POST /v1/tasks パターンが動画モデル(映画的な素材には Seedance 2.0)、画像から画像へのワークフロー(input 内に image_url を追加するだけ)、そしてマルチターン編集へと拡張できます——いずれもポーリングのアーキテクチャを変えることなく実現できます。

動作する出発点が欲しい場合は、hiapi のモデルページにこのエンドポイントに対応するすべてのモデルが一覧され、各モデルの入力構造の例が示されています。そのいずれかを上記の model フィールドに入れれば、同じコンポーネントが結果をレンダリングします。

永続的なAIアーティファクトで開発を始める

APIキーを取得して、タスクベースの1つのAPIで画像・動画・音声モデルを呼び出せます。

APIドキュメントを読む