Vercel Blob Signed URLs入門——Next.jsで一時ファイルURLを発行する
Vercel BlobのSigned URLsを使い、Next.js App RouterとFirestoreで安全な添付ファイル管理を作る方法を実践形式で解説します。

はじめに
経費精算、契約書管理、社内申請のようなWebアプリを作っていると、ファイル添付まわりで次の課題にぶつかります。
- ブラウザから大きめのPDFや画像をアップロードしたい
- ファイル本体はDBに入れず、メタデータだけFirestoreで管理したい
- 閲覧権限のあるユーザーにだけ短時間のダウンロードURLを渡したい
- 削除や差し替えで、古いURLや古いファイルが残る事故を避けたい
2026年6月2日、Vercelは Vercel BlobのSigned URLs を発表しました。公式発表では、put、get、head、delete の操作ごとに、単一パス・有効期限付きのURLを発行できると説明されています。URLは最大7日までの期限を持ち、GET 用に署名したURLを PUT に使い回すことはできません。
本記事では、架空の経費精算SaaS「KeihiBox」を題材に、Next.js App Router + Firestore + TypeScriptで、添付ファイルの一時URL発行フローを作ります。
何が便利になったのか
従来、Private Blobをユーザーへ配信する場合は、Next.jsのRoute Handlerで認証し、サーバー側で get() してレスポンスをストリームする設計が基本でした。これは厳密な認可制御には向いていますが、ファイルサイズやアクセス頻度によっては、アプリケーションサーバーを経由する負荷が気になります。
Signed URLsは、その中間に位置する選択肢です。サーバーは認証・認可を行ったうえで、短時間だけ有効なURLを発行します。ブラウザはそのURLに直接アクセスし、長期的な BLOB_READ_WRITE_TOKEN はサーバーから外に出しません。
設計上の使い分けは次の通りです。
| 用途 | Signed URLの操作 | 有効期限の目安 | Firestoreで持つ情報 |
|---|---|---|---|
| 領収書アップロード | put | 10〜15分 | 申請ID、パス、状態 |
| 添付ファイル閲覧 | get | 1〜5分 | 所有者、権限、ファイル名 |
| メタデータ確認 | head | 1分 | ETag、サイズ、更新時刻 |
| 誤アップロード削除 | delete | 1分 | 削除対象パス、ETag |
事前準備
公式ドキュメントでは、Vercel Blob SDKの利用に @vercel/blob を使います。Firebase Admin SDKはFirestoreへ添付ファイルのメタデータを保存するために使います。
yarn add @vercel/blob firebase-admin
Vercel Blobを使うにはBlob storeと読み書きトークンが必要です。同じVercelプロジェクトにBlob storeを接続している場合、SDKはプロジェクトに設定されたトークン環境変数を利用できます。別プロジェクトやVercel外で動かす場合は、トークンを環境変数として渡す設計にします。
Firestore側には、添付ファイルを次のように保存する想定にします。
// src/types/caseFile.ts
export type CaseFile = {
id: string;
caseId: string;
ownerId: string;
pathname: string;
filename: string;
contentType: string;
status: "uploading" | "ready" | "deleted";
createdAt: FirebaseFirestore.Timestamp;
updatedAt: FirebaseFirestore.Timestamp;
};
ここでは caseFiles/{fileId} に1ファイル1ドキュメントで保存します。Blobにはファイル本体、Firestoreには検索・権限判定・一覧表示に必要なメタデータだけを置く構成です。
ハンズオン1: アップロード用URLを発行する
まずは、ブラウザが直接Blobへアップロードするための put URLを発行します。重要なのは、URLを出す前に必ずログインユーザーと対象申請の権限を確認することです。
// src/app/api/case-files/presign-upload/route.ts
import { issueSignedToken, presignUrl } from "@vercel/blob";
import { NextResponse } from "next/server";
import { getFirestore, Timestamp } from "firebase-admin/firestore";
import { requireUser } from "@/lib/auth/requireUser";
type UploadRequest = {
caseId: string;
filename: string;
contentType: string;
};
export async function POST(request: Request) {
const user = await requireUser(request);
const body = (await request.json()) as UploadRequest;
if (!body.caseId || !body.filename) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const db = getFirestore();
const fileRef = db.collection("caseFiles").doc();
const pathname = `cases/${body.caseId}/${fileRef.id}-${body.filename}`;
await fileRef.set({
caseId: body.caseId,
ownerId: user.uid,
pathname,
filename: body.filename,
contentType: body.contentType,
status: "uploading",
createdAt: Timestamp.now(),
updatedAt: Timestamp.now(),
});
const token = await issueSignedToken({
operations: ["put"],
});
const { presignedUrl } = await presignUrl(token, {
pathname,
operation: "put",
validUntil: Date.now() + 15 * 60 * 1000,
});
return NextResponse.json({
fileId: fileRef.id,
uploadUrl: presignedUrl,
});
}
requireUser はアプリ側で用意する認証ヘルパーです。Firebase Authentication、Auth.js、独自セッションなど、採用している認証方式に合わせて実装します。ここで匿名ユーザーにURLを渡すと、誰でもBlobへ書き込める入口になるため注意が必要です。
ブラウザ側は、受け取ったURLへ PUT します。
// src/app/cases/[caseId]/UploadReceiptButton.tsx
"use client";
type Props = {
caseId: string;
};
export function UploadReceiptButton({ caseId }: Props) {
async function upload(file: File) {
const response = await fetch("/api/case-files/presign-upload", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
caseId,
filename: file.name,
contentType: file.type,
}),
});
const { fileId, uploadUrl } = (await response.json()) as {
fileId: string;
uploadUrl: string;
};
await fetch(uploadUrl, {
method: "PUT",
body: file,
});
await fetch(`/api/case-files/${fileId}/complete`, {
method: "POST",
});
}
return (
<input
type="file"
accept="image/*,application/pdf"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void upload(file);
}}
/>
);
}
アップロード完了後に status: "ready" へ変えるRoute Handlerも作ります。実務ではBlobの存在確認やサイズ制限、MIMEタイプ制限もここで追加します。
ハンズオン2: 閲覧用URLを短時間だけ返す
次に、添付ファイルを開くための get URLを発行します。Firestoreの ownerId や caseId を使って、現在のユーザーがそのファイルを見られるかを判定します。
// src/app/api/case-files/[fileId]/download/route.ts
import { issueSignedToken, presignUrl } from "@vercel/blob";
import { NextResponse } from "next/server";
import { getFirestore } from "firebase-admin/firestore";
import { requireUser } from "@/lib/auth/requireUser";
type Params = {
params: Promise<{ fileId: string }>;
};
export async function GET(request: Request, { params }: Params) {
const user = await requireUser(request);
const { fileId } = await params;
const snapshot = await getFirestore()
.collection("caseFiles")
.doc(fileId)
.get();
if (!snapshot.exists) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const file = snapshot.data() as {
ownerId: string;
pathname: string;
status: string;
};
if (file.ownerId !== user.uid || file.status !== "ready") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const token = await issueSignedToken({
operations: ["get"],
});
const { presignedUrl } = await presignUrl(token, {
pathname: file.pathname,
operation: "get",
validUntil: Date.now() + 5 * 60 * 1000,
});
return NextResponse.json({ downloadUrl: presignedUrl });
}
クライアント側では、一覧の「開く」ボタンでURLを取得してから新しいタブを開きます。
async function openFile(fileId: string) {
const response = await fetch(`/api/case-files/${fileId}/download`);
const { downloadUrl } = (await response.json()) as {
downloadUrl: string;
};
window.open(downloadUrl, "_blank", "noopener,noreferrer");
}
この設計なら、Firestoreの権限変更を反映しやすくなります。たとえば申請が差し戻されたら status を変える、退職者の ownerId を無効扱いにする、といったアプリ側のルールをURL発行前に挟めます。
ハンズオン3: 削除はETagを使って慎重に扱う
Vercelの発表では、delete URLに ifMatch を指定すると、署名時に意図したETagと現在のETagが一致する場合だけ削除できると説明されています。これは、ユーザーAが削除URLを取得した直後に、ユーザーBの差し替え処理で同じパスが更新されたような競合を避けるために使えます。
削除フローは次の順番にします。
- Firestoreで削除権限を確認する
- Blobのメタデータを確認し、必要ならETagを保持する
delete用Signed URLを短時間で発行する- ブラウザまたはサーバーから削除を実行する
- Firestoreの
statusをdeletedに更新する
削除は便利な反面、失敗時の復旧が難しい操作です。KeihiBoxのような業務アプリでは、物理削除をすぐ実行せず、まずFirestoreで deleted にして画面から隠し、一定期間後にGitHub Actionsのスケジュール実行でBlobを掃除する設計も実務的です。
# .github/workflows/blob-cleanup.yml
name: Blob cleanup
on:
schedule:
- cron: "0 18 * * *"
workflow_dispatch:
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn
- run: yarn install --frozen-lockfile
- run: yarn tsx scripts/delete-expired-case-files.ts
env:
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
GOOGLE_APPLICATION_CREDENTIALS_JSON: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS_JSON }}
CIやバッチでは、削除対象をFirestoreから取得し、Blob削除後に status と updatedAt を更新します。ユーザー操作のレスポンスを速く保ちながら、削除処理を監査しやすくできます。
実務での注意点
Signed URLsは便利ですが、万能ではありません。運用では次の点を最初に決めておくと安全です。
| 観点 | 推奨 |
|---|---|
| URLの有効期限 | 閲覧は1〜5分、アップロードは10〜15分程度から始める |
| パス設計 | cases/{caseId}/{fileId}-{filename} のように所有単位を含める |
| Firestore状態 | uploading を放置しないよう期限切れ掃除を入れる |
| 認可 | URL発行Route Handlerで必ず判定し、クライアント任せにしない |
| 監査 | 発行ログや削除ログをFirestoreまたはログ基盤へ残す |
また、Private Blobを厳密に認可したい場面では、従来通りRoute Handlerで get() して配信するほうが分かりやすい場合もあります。Signed URLsは「一時的に直接アクセスさせたい」場面の選択肢として捉えるのがよいです。
参考にした一次情報は次の通りです。
- Signed URLs are now available for Vercel Blob
- Vercel Blob SDK documentation
- Private Storage - Vercel Blob
- Firebase Admin SDK setup
まとめ
Vercel Blob Signed URLsの要点は次の通りです。
put、get、head、deleteごとに一時URLを発行できる- URLは単一パス・単一操作・有効期限つきなので、長期トークンをブラウザへ渡さずに済む
- Firestoreにはファイル本体ではなく、認可と一覧表示に必要なメタデータを保存する
- Next.js Route Handlerで認証・認可を行ってからSigned URLを返す
- 削除や未完了アップロードはGitHub Actionsの定期実行で棚卸しすると運用しやすい
まずは既存の添付ファイル機能のうち、「サーバーを経由しているが、実際には短時間の直接アクセスで十分」な箇所を1つ選び、閲覧用の get URLから試すのがおすすめです。