Next.js・Replicate・Redisで作るAI画像キャプションアプリ開発ガイド
AIがますます身近な存在となる中、Replicateのような企業は機械学習モデルをプロジェクトへシームレスに組み込むことを容易にしています。
本記事では、ユーザーが画像をアップロードするとAIが生成したテキストキャプションを受け取れるWebアプリケーション「CaptionAI」の開発方法について解説します。このプロジェクトはVercelの公式テンプレートをベースに構築しており、開発の様子を収めた解説動画も公開されています。

使用する技術スタック
- Next.js 13(フロントエンドおよびバックエンド)
- Upstash Redis(レート制限)
- Replicate(機械学習API)
- Tailwind CSS(スタイリング)
- Vercel(デプロイ)
事前に必要なもの
- データベースを作成するためのUpstashアカウント
- 機械学習APIへアクセスするためのReplicateアカウント
Upstash Redisのセットアップ
Upstashアカウントを作成してログインしたら、「Redis」タブに移動し、データベースを作成します。


データベース作成後、「Details」タブを開き、下にスクロールしてREST APIセクションを見つけます。「.env」ボタンを選択し、表示された内容をコピーして安全な場所に保存してください。

Replicateのセットアップ
Replicateアカウントを作成してログインしたら、「Account」タブに移動し、APIトークンを安全な場所に保存します。
※注意:Replicateは無料で利用できますが、しばらく使うとクレジットカードの登録を求められます。料金は使用するモデルによって異なり、今回使用するsalesforce/blipモデルは1回あたり約0.00042ドルで実行できます。

プロジェクトのセットアップ
ゼロからプロジェクトを作成する代わりに、GitHubからリポジトリをクローンできます。
リポジトリをクローンしたら、.envファイルを作成します。.example.envファイルの内容を.envファイルにコピーし、前述の手順で保存した各種キー情報を追記しましょう。
完成形は以下のようになります。
// .env
REPLICATE_API_KEY="your_replicate_api_key_from_above"
// Optional, if you're doing rate limiting
UPSTASH_REDIS_REST_URL="your_upstash_redis_rest__url_from_above"
UPSTASH_REDIS_REST_TOKEN="your_upstash_redis_rest__token_from_above"
これらの情報を記載したら、ターミナルで以下のコマンドを実行すればプロジェクトを起動できます。
npm install
npm run dev
リポジトリの構成
以下はプロジェクトの主要なフォルダ構成です。赤丸で囲んだファイルは、画像アップロード、レート制限、BLIP機械学習APIの実装に関わる部分として本記事で詳しく解説します。

データフローの全体像
以下はデータの流れを示す概略図です。入力であるユーザーがアップロードした画像は、アップロードコンポーネントを経由してバックエンドへ送られ、BLIP機械学習APIで処理された後、その応答テキストがUI上に表示されます。

Redisインスタンスの作成
プロジェクト内では、必要なときにいつでも参照できるよう、Upstash Redisクライアントをセットアップします。
// `/utils/redis.ts`
import { Redis } from "@upstash/redis";
const redis =
!!process.env.UPSTASH_REDIS_REST_URL && !!process.env.UPSTASH_REDIS_REST_TOKEN
? new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
})
: undefined;
export default redis;
このコードスニペットでは、「@upstash/redis」パッケージからRedisモジュールをインポートし、新しいRedisインスタンスを作成しています。インスタンスは、UPSTASH_REDIS_REST_URLとUPSTASH_REDIS_REST_TOKENという2つの環境変数の有無に基づいて条件付きで生成されます。
両方の変数が定義されている場合は、指定されたURLとトークンを使って新しいRedisインスタンスが作成されます。どちらか一方でも未定義の場合は、redis変数にはundefinedが代入されます。最後に、redis変数をモジュールからエクスポートし、アプリケーションの他の部分で利用できるようにします。
画像のアップロード処理
// `/pages/captions.tsx`
const uploader = Uploader({
apiKey: !!process.env.NEXT_PUBLIC_UPLOAD_API_KEY
? process.env.NEXT_PUBLIC_UPLOAD_API_KEY
: "free",
});
const options = {
maxFileCount: 1,
mimeTypes: ["image/jpeg", "image/png", "image/jpg"],
editor: { images: { crop: false } },
styles: {
colors: {
primary: "#5a5cd1", // Primary buttons & links
error: "#d23f4d", // Error messages
shade100: "#fff", // Standard text
shade200: "#fffe", // Secondary button text
shade300: "#fffd", // Secondary button text (hover)
shade400: "#fffc", // Welcome text
shade500: "#fff9", // Modal close button
shade600: "#fff7", // Border
shade700: "#fff2", // Progress indicator background
shade800: "#fff1", // File item background
shade900: "#ffff", // Various (draggable crop buttons, etc.)
},
},
onValidate: async (file: File): Promise<undefined | string> => {
let isSafe = false;
try {
isSafe = await NSFWPredictor.isSafeImg(file);
if (!isSafe) va.track("NSFW Image blocked");
} catch (error) {
console.error("NSFW predictor threw an error", error);
}
return isSafe
? undefined
: "Detected a NSFW image which is not allowed. If this was a mistake, please contact me at hosna.qasmei@gmail.com";
},
};
このコードは、アップローダーコンポーネントの設定オプションを定義しています。アップローダーはUploader()関数で生成され、オプションはオブジェクトとして渡されます。
最初の設定項目はapiKeyで、アップローダーサービスとの認証に使用されます。apiKeyの値は、環境変数NEXT_PUBLIC_UPLOAD_API_KEYが設定されているかどうかで決まります。設定されていればその値が使われ、そうでなければ「free」という値が使われます。
optionsオブジェクトには、アップローダーに関するさまざまな設定が含まれています。
- maxFileCount: 一度にアップロードできるファイル数の上限を1に設定します。
- mimeTypes: アップロード可能なMIMEタイプを「image/jpeg」「image/png」「image/jpg」に限定します。
- editor: 画像エディターのオプションを設定します。ここではcropをfalseにすることで無効化しています。
- styles: アップローダーUIのカスタムスタイルを定義します。
- onValidate: アップロード前に各ファイルを検証する関数を定義します。この例ではNSFWPredictorを使って画像が安全かどうかを判定し、不適切な画像の場合はエラーメッセージを返します。
// `/pages/captions.tsx` continued
const Home: NextPage = () => {
const [originalPhoto, setOriginalPhoto] = useState<string | null>(null);
const [caption, setCaption] = useState<string | null>(null);
const [buttonText, setButtonText] = useState("Copy");
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const copyToClipboard = () => {
navigator.clipboard.writeText(caption!);
setButtonText("Copied!"); // set the button text to "Copied!" when text is copied
setTimeout(() => {
setButtonText("Copy"); // set the button text back to "Copy" after 2 seconds
}, 2000);
};
const UploadDropZone = () => (
<UploadDropzone
uploader={uploader}
options={options}
onUpdate={(file) => {
if (file.length !== 0) {
setOriginalPhoto(file[0].fileUrl.replace("raw", "thumbnail"));
generateCaption(file[0].fileUrl.replace("raw", "thumbnail"));
}
}}
width="670px"
height="250px"
/>
);
async function generateCaption( fileUrl: string )
{
await new Promise((resolve) => setTimeout(resolve, 500));
setLoading(true);
const res = await fetch("/api/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ imageUrl: fileUrl }),
});
let newCaption = await res.json();
if (res.status !== 200) {
setError(newCaption);
} else {
setCaption(newCaption);
}
setLoading(false);
}
...
useStateフックを使って、複数のステート変数が定義されています。
originalPhoto: アップロードされた画像のURLを表す文字列です。caption: 生成されたキャプションを格納する文字列です。buttonText: コピーボタンに表示されるテキストを表す文字列です。loading: 現在データ取得中かどうかを示す真偽値です。error: キャプション生成中にエラーが発生した場合のエラーメッセージを格納する文字列です。
コンポーネントにはcopyToClipboardという関数があり、navigator.clipboard.writeTextメソッドを使ってcaption変数の内容をクリップボードにコピーします。コピーが完了すると、buttonText変数が2秒間「Copied!」に切り替わり、その後「Copy」に戻ります。
さらに、UploadDropZoneというサブコンポーネントがあり、指定したuploaderとoptionsを使ってUploadDropzoneコンポーネントを描画します。onUpdateコールバックは、アップロードされた画像のURLと生成されたキャプションでoriginalPhotoやcaptionなどの変数を更新するために使われます。
最後に、generateCaptionという非同期関数があります。引数fileUrlはアップロードされた画像のURLで、fetchを使って/api/generateエンドポイントにPOSTリクエストを送り、fileUrlをJSONペイロードとして渡します。レスポンスはJSONとしてパースされ、成功していればcaption変数に、失敗していればerror変数に結果がセットされます。loading変数も更新され、リクエストの進行状況が反映されます。また、setTimeoutによる500ミリ秒の遅延を設けることで、APIのレート制限に抵触しないようにしています。
レート制限
// `/pages/api/generate.ts`
import redis from "../../utils/redis";
import requestIp from "request-ip";
import { Ratelimit } from "@upstash/ratelimit";
import type { NextApiRequest, NextApiResponse } from "next";
type Data = string;
interface ExtendedNextApiRequest extends NextApiRequest {
body: {
imageUrl: string;
};
}
// Create a new ratelimiter, that allows 3 requests every 15 minutes
const ratelimit = redis
? new Ratelimit({
redis: redis,
limiter: Ratelimit.fixedWindow(5, "1440 m"),
analytics: true,
})
: undefined;
...
このコードでは、Next.jsでAPIエンドポイントを作成するために必要なモジュールと型定義に加え、Upstash Redisのデータベースクライアントと「@upstash/ratelimit」というレート制限ライブラリをインポートしています。
ratelimit定数はRatelimitクラスの新しいインスタンスを生成し、1440分(24時間)ごとに5リクエストまで許可する固定ウィンドウ方式のレートリミッターを作成します。redisプロパティをRatelimitコンストラクターに渡すことで、アプリケーションの複数インスタンス間でもレート制限が機能します。redisがundefinedの場合(Redisデータベースが設定されていない場合など)は、ratelimitもundefinedになり、Redisが利用できない環境ではレート制限が適用されません。
// `/pages/api/generate.ts` continued
export default async function handler(
req: ExtendedNextApiRequest,
res: NextApiResponse<Data>
) {
// Rate Limiter Code
if (ratelimit) {
const identifier = requestIp.getClientIp(req);
const result = await ratelimit.limit(identifier!);
res.setHeader("X-RateLimit-Limit", result.limit);
res.setHeader("X-RateLimit-Remaining", result.remaining);
if (!result.success) {
res
.status(429)
.json("Too many uploads in 1 day. Please try again after 24 hours.");
return;
}
}
...
このコードブロックは、クライアントがAPIに対して実行できるリクエストの頻度を制限するAPIハンドラー関数の一部です。まずレートリミッターインスタンスが利用可能かどうかを確認し、利用可能であればrequest-ipパッケージを使ってクライアントのIPアドレスを抽出し、ratelimit.limitメソッドに渡します。このメソッドは、指定された時間枠内の残りリクエスト数と、リクエストが成功したかどうかを含むオブジェクトを返します。
リクエストが成功した場合は、X-RateLimit-LimitヘッダーとX-RateLimit-Remainingヘッダーがレスポンスに設定されます。リクエスト上限を超えていた場合は、429ステータスコードとエラーメッセージがレスポンスとして返され、以降の処理を実行せずに関数を終了します。
BLIP機械学習API
// `/pages/api/generate.ts` continued
const imageUrl = req.body.imageUrl;
let startResponse = await fetch("https://api.replicate.com/v1/predictions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Token " + process.env.REPLICATE_API_KEY,
},
body: JSON.stringify({
version:
"2e1dddc8621f72155f24cf2e0adbde548458d3cab9f00c0139eea840d0ac4746",
input: {
image: imageUrl,
task: "image_captioning",
},
}),
});
...
この部分のコードでは、リクエストボディからimageUrlを取り出し、「https://api.replicate.com/v1/predictions」エンドポイントにPOSTリクエストを送信して、image_captioningタスクによる画像キャプションを取得します。リクエストには認証用のReplicate APIキーを含むAuthorizationヘッダーが付与され、Content-Typeヘッダーは「application/json」に設定されています。APIからのレスポンスはJSONとしてパースされ、jsonStartResponseオブジェクトからendpointUrlが抽出されます。
モデルのバージョン番号は、使用したいモデルを選択することで確認できます。

「API」タブを選択します。

下にスクロールすると、赤枠で囲まれたバージョン番号が表示されます。青枠で囲まれた部分は、利用可能な入力パラメーターです。
// `/pages/api/generate.ts` continued
let jsonStartResponse = await startResponse.json();
let endpointUrl = jsonStartResponse.urls.get;
// GET request to get the status of the image restoration process & return the result when it's ready
let caption: string | null = null;
while (!caption) {
// Loop in 1s intervals until the alt text is ready
console.log("polling for result...");
let finalResponse = await fetch(endpointUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: "Token " + process.env.REPLICATE_API_KEY,
},
});
let jsonFinalResponse = await finalResponse.json();
if (jsonFinalResponse.status === "succeeded") {
caption = jsonFinalResponse.output;
} else if (jsonFinalResponse.status === "failed") {
break;
} else {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
res.status(200).json(caption ? caption : "Failed to generate caption");
}
次に、whileループを使ってendpointUrlを1秒間隔でポーリングし、キャプションが準備できるまで待ちます。ループ内では同じAuthorizationヘッダーとContent-Typeヘッダーを付けてendpointUrlにGETリクエストを送信し、レスポンスもJSONとしてパースします。jsonFinalResponseオブジェクトのstatusが「succeeded」であれば、outputプロパティからキャプションを取り出します。「failed」であればループを抜けます。それ以外のステータスの場合は、setTimeoutで1秒待機してから再度ポーリングを行います。
最後に、キャプションがnullでなければステータスコード200とともにJSONレスポンスとして返却され、nullの場合は「Failed to generate caption」というメッセージがステータスコード200で返されます。
まとめ
このプロジェクトを通じて、画像アップロード機能の実装、レート制限の導入、そして機械学習APIの統合について貴重な経験を得ることができました。これらの技術への理解を深めることで、今後より高度なプロジェクトを構築する際にも役立つはずです。
-
Redis ZRANGEコマンド徹底解説 – ソート済みセットから指定ランク範囲の要素を昇順で取得する方法
このチュートリアルでは、RedisのZRANGEコマンドを使って、スコアが昇順に並んだソート済みセット(Sorted Set)から、指定したランク範囲内の1つまたは複数の要素を取得する方法を詳しく解説します。 ZRANGEコマンドとは ZRANGEコマンドは、指定したキーに保存されているソート済みセットの中から、指定した範囲に含まれる1つ以上の要素を返します。ソート済みセット内の要素はスコアの昇順に並んでおり、同じスコアを持つ要素同士は辞書順(レキシコグラフィカル順)で比較されます。 範囲はstart(開始位置・範囲に含む)とend(終了位置・範囲に含む)のオフセットによって定義されます。これ
-
パフォーマンス向上の鍵:LangChain・Redis・QStashで実現するオンデマンドLLMキャッシング
アプリケーションによっては、ユーザーからのリクエストのたびにLLMへ問い合わせることが現実的でないケースがあります。プロンプトをAPIに送信し、レスポンスを待つ一連の処理はどうしても時間がかかりがちです。PDFや音声ファイルからデータを抽出してLLMに渡すといった、LangChainを使った複雑なタスクになると、この遅延がユーザーエクスペリエンスに与える影響はさらに深刻になります。 ストリーミングも多くの場面で有効な解決策ですが、ユーザーがアプリケーションにアクセスする前の時点で処理を完了させておく方が都合が良いこともあります。そうすることで、キャッシュ済みのレスポンスをほぼ瞬時にユーザーへ提