言語を切り替える
テーマを切り替える

Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践

ユーザーが「アバターアップロード」ボタンをクリックし、10MB の写真を選びました。プログレスバーは 30% まで進んで止まりました。40秒後、ブラウザにエラーが表示されました:「Request Entity Too Large」。

私は Vercel のデプロイログを見つめ、見慣れた「4MB body size limit」エラーを確認し、心の中で Next.js の API 制限を呪うのはこれで3回目でした。正直なところ、ファイルアップロード機能を最初に作ったときは、API Route を書けばそれで終わりだと思っていました。しかし現実は教えてくれました:ユーザーがアップロードするファイルはますます大きくなり、サーバーのメモリは逼迫し、アップロード速度はパソコンを叩き壊したくなるほど遅い。

その後、よりエレガントなソリューションを見つけました——署名付き URL によるクラウドストレージへの直接アップロードです。ユーザーのファイルはもはやサーバーを経由せず、直接 S3 や Qiniu Cloud(七牛雲)に転送されます。速度は3倍になり、サーバーの負荷はゼロになり、ファイルサイズの上限は 4MB から一気に 5GB に跳ね上がりました。

この記事では、このソリューションの実装方法をステップバイステップで教えます。S3 と Qiniu Cloud の設定、署名付き URL の生成、アップロード進捗の処理、画像の最適化、そして私が踏んだすべての落とし穴を回避する方法を学びます。コードはすべて本番環境で使用可能な完全な例ですので、すぐに動かせます。

なぜ署名付き URL 直接アップロードを選ぶのか?

従来方式の3つの致命的な問題

まず、従来のファイルアップロードがどのように機能するか見てみましょう:ユーザーがファイルを選択 → Next.js サーバーにアップロード → サーバーが再度クラウドストレージに転送。理にかなっているように聞こえますが、実際には問題だらけです。

問題1:Next.js API の致命的な制限

Next.js の App Router はリクエストボディサイズに厳しい制限があります——デフォルトで 4MB です。Edge Runtime はさらに厳しく、わずか 1MB です。設定を変更して制限を緩和しようと思うかもしれませんが、Vercel などのプラットフォームでは変更できないことが多いです。たとえ 10MB や 50MB に変更できたとしても、ユーザーが高画質ビデオをアップロードすればすぐにパンクします。

問題2:サーバーが耐えられない

ファイルがサーバーを経由するということはどういうことでしょうか? メモリ使用量が倍増するということです。ユーザーが 100MB のファイルをアップロードすると、サーバーはまずその 100MB を受信し(メモリ占有)、次に S3 に転送します(さらにメモリ占有)。10人のユーザーが同時にアップロードしたら? 2GB メモリのインスタンスは即死します。

以前、画像コミュニティを運営していたとき、ピーク時にサーバーの CPU が 90% に達し、すべてファイル転送処理に使われていました。その後、直接アップロード方式に切り替えたところ、CPU 使用率は一気に 15% に低下しました。これは最適化ではありません、劇的な飛躍です。

問題3:速度が遅く、体験が悪い

ファイルがサーバーを経由するということは、余分な回り道をするということです。ユーザーが深センにいて、サーバーがシリコンバレーにあり、S3 がシンガポールにあるとします。アップロード経路は「深セン → シリコンバレー → シンガポール」となります。署名付き URL 直接アップロードなら? 「深セン → シンガポール」となり、経路は半分になり、速度は当然速くなります。

署名付き URL はどう機能するのか?

簡単に言えば、署名付き URL はクラウドストレージサービスが発行する「一時通行証」です。フローは以下の通りです:

  1. ユーザーがアップロードをクリックすると、フロントエンドが Next.js サーバーにリクエストします:「ファイルをアップロードしたい」
  2. サーバーが S3 に連絡します:「60秒間有効なアップロードリンクを生成してくれ」
  3. S3 が暗号化された URL を返します(例:https://xxx.s3.amazonaws.com/file.jpg?signature=xxxx&expires=1234567890
  4. フロントエンドはこの URL を受け取り、PUT リクエストでファイルを直接 S3 に送信します。サーバーは経由しません。
  5. アップロード完了後、S3 はファイルの最終アドレスを返します

この「一時通行証」の素晴らしい点は、時間制限がある(60秒後に自動失効)、権限が最小化されている(そのファイルのアップロードしかできない)、キーを露出する必要がない(フロントエンドは AWS Secret Key を取得できない)ことです。

技術的利点の比較

2つの方式の違いを表にまとめました、一目瞭然です:

側面従来アップロード(サーバー経由)署名付き URL 直接アップロード
ファイルサイズ制限4MB (Vercel/Netlify)5GB (S3 単一アップロード)
サーバーメモリ使用量高 (ファイルサイズ×2)ゼロ
サーバーCPU使用量高 (転送処理)極低 (URL 生成のみ)
アップロード速度遅い (余分なホップ)速い (CDN 直結)
同時実行能力サーバー構成に依存無限 (クラウドストレージが負担)
セキュリティ一部認証情報の露出が必要一時的認証、自動失効
来源: AWS公式ドキュメント

AWS 公式ドキュメントには、単一の署名付き URL アップロードで最大 5GB のファイルをサポートすると明記されています。さらに大きなファイルが必要な場合は、マルチパートアップロード(Multipart Upload)を使用でき、理論的には上限はありません。

S3 vs Qiniu Cloud、どちらを選ぶべきか?

技術的な原理は説明しましたが、実際の問題に直面します:S3 と Qiniu Cloud、どちらを使うべきか? 私は両方使ったことがあるので、それぞれの特徴を分析します。

価格:定額パッケージ vs 従量課金

Qiniu Cloud は「定額パッケージ」路線です。無料枠はかなり良心的で、月間 10GB ストレージ + 10GB ダウンロードトラフィックがあり、個人の小規模プロジェクトには十分です。超過分は段階的価格設定で、ストレージは 0.148元(CNY)/GB/月、CDN トラフィックは 0.29元(CNY)/GB です。

計算してみましょう:アプリに1000人のユーザーがいて、1人あたり10枚の画像(平均2MB)をアップロードし、合計20GBのストレージを使用。月間ダウンロードトラフィックを100GBと仮定します。Qiniu Cloud のコスト:

  • ストレージ:(20GB - 10GB無料) × 0.148元 = 1.48元
  • トラフィック:(100GB - 10GB無料) × 0.29元 = 26.1元
  • 月額合計:27.58元

AWS S3 は完全な従量課金です。無料枠はありません(新規アカウントは1年間少量の無料枠あり)が、単価は柔軟です。us-east-1 リージョンを例にすると:ストレージ $0.023/GB/月、トラフィック $0.09/GB。

同じシナリオでの S3 のコスト:

  • ストレージ:20GB × $0.023 × 7(為替レート) ≈ 3.22元
  • トラフィック:100GB × $0.09 × 7 ≈ 63元
  • 月額合計:66.22元

一見 S3 が倍の価格に見えますが、S3 のトラフィックは CloudFront CDN で最適化でき、グローバルノードのアクセス速度がより均一であることを忘れないでください。

国内アクセス速度:これが重要

ユーザーが主に中国国内にいる場合、Qiniu Cloud の CDN ノードは分布が密であり、アクセス速度が明らかに速いです。実測では、深センのユーザーが Qiniu Cloud の画像にアクセスすると平均遅延は 30-50ms ですが、AWS S3(東京ノードでも)へのアクセスは 120-180ms かかります。

差はどこにあるのか? Qiniu Cloud は中国国内で登録(ICP登録)されており、国内の CDN ノードを使用できるのに対し、AWS S3 のノードはほとんどが海外にあり、データが国境を越えるためです。海外市場をターゲットにするなら S3 が圧倒的に有利ですが、中国国内を主戦場にするなら Qiniu Cloud が現実的です。

ドキュメントとエコシステム:英語 vs 中国語

AWS のドキュメントは非常に包括的ですが、すべて英語で専門用語が多く、初心者は混乱しやすいです。Qiniu Cloud のドキュメントは中国語で明確に書かれており、コード例も豊富です。

エコシステムに関しては、S3 の圧勝です。Next.js、Vercel、各種オープンソースライブラリにおける S3 のサポートは一級市民です。Qiniu Cloud のコミュニティは比較的小さく、問題が発生した場合は自分で手探りする必要があるかもしれません。

私の選択アドバイス

決定木を作ってみます:

  • 以下なら S3 を選択

    • 国際的な製品を作り、ユーザーが世界中にいる
    • 既に AWS の他のサービス(Lambda、RDS など)を使用している
    • 強力な機能が必要(例:Lambda でアップロード画像を自動処理)
    • 予算が十分で、エコシステムと安定性を重視する
  • 以下なら Qiniu Cloud を選択

    • ユーザーの 90% が中国国内にいる
    • スタートアップチームで予算が限られており、節約したい
    • 中国語の技術サポートが必要で、英語のドキュメントを読みたくない
    • CDN アクセラレーションに高い要求がある

私自身のプロジェクトでは? 主に中国国内市場向けなら Qiniu Cloud を使い、海外ユーザーがいれば S3 を使います。両方設定しても競合しません。

S3 署名付き URL 実践(App Router)

能書きはこれくらいにして、コードを見ていきましょう。全プロセスを4つのステップに分けます:環境準備、サーバーサイドAPI、クライアントコンポーネント、画像処理。

ステップ1:環境準備

AWS 公式パッケージを2つインストールします:

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

そして .env.local に AWS 認証情報を追加します:

AWS_REGION=ap-southeast-1  # ユーザーに最も近いリージョンを選択
AWS_ACCESS_KEY_ID=あなたのAccessKey
AWS_SECRET_ACCESS_KEY=あなたのSecretKey
AWS_S3_BUCKET_NAME=my-app-uploads

これらの値はどこから取得? AWS コンソールにログインし、IAM ユーザーを作成して最小権限(指定 Bucket へのアップロードのみ)を付与し、Access Key をメモします。Bucket は S3 コンソールで作成し、リージョンを選ぶだけです。

CORS 設定を忘れずに! S3 Bucket 設定に入り、CORS ルールを追加します:

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT", "POST"],
    "AllowedOrigins": ["http://localhost:3000", "https://あなたのドメイン.com"],
    "ExposeHeaders": ["ETag"]
  }
]

これを設定しないと、ブラウザは CORS エラーを出します。なぜ知っているかというと、私がハマったからです。

ステップ2:サーバーサイドでの署名付き URL 生成

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

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { NextRequest, NextResponse } from 'next/server';

const s3Client = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

export async function POST(request: NextRequest) {
  try {
    const { fileName, fileType } = await request.json();

    // セキュリティチェック: 画像のみ許可
    if (!fileType.startsWith('image/')) {
      return NextResponse.json(
        { error: '画像形式のみサポートしています' },
        { status: 400 }
      );
    }

    // 一意なファイル名を生成し、上書きを防止
    const key = `uploads/${Date.now()}-${fileName}`;

    const command = new PutObjectCommand({
      Bucket: process.env.AWS_S3_BUCKET_NAME!,
      Key: key,
      ContentType: fileType,
    });

    // 60秒間有効な署名付き URL を生成
    const uploadUrl = await getSignedUrl(s3Client, command, {
      expiresIn: 60,
    });

    // アップロード用 URL と最終ファイルアドレスを返す
    const fileUrl = `https://${process.env.AWS_S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;

    return NextResponse.json({ uploadUrl, fileUrl });
  } catch (error) {
    console.error('署名付き URL 生成失敗:', error);
    return NextResponse.json(
      { error: 'サーバーエラー' },
      { status: 500 }
    );
  }
}

このコードの核心ロジック:

  1. ファイル名とタイプを受信
  2. 画像かどうかチェック(実行可能ファイルのアップロード防止)
  3. タイムスタンプ+元のファイル名で一意な key を生成
  4. getSignedUrl を呼び出して一時 URL を生成
  5. アップロード URL と最終アクセスアドレスを返す

expiresIn: 60 に注意してください。60秒後にこの URL は無効になります。300(5分)に変更することもできますが、長すぎるのは推奨しません。安全第一です。

ステップ3:クライアントアップロードコンポーネント

components/FileUpload.tsx を作成します:

'use client';

import { useState } from 'react';

export default function FileUpload() {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [fileUrl, setFileUrl] = useState('');

  const handleUpload = async () => {
    if (!file) return;

    setUploading(true);
    setProgress(0);

    try {
      // 1. サーバーに署名付き URL をリクエスト
      const response = await fetch('/api/upload', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          fileName: file.name,
          fileType: file.type,
        }),
      });

      const { uploadUrl, fileUrl: finalUrl } = await response.json();

      // 2. XMLHttpRequest でアップロードし、進捗を監視
      await new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        xhr.upload.addEventListener('progress', (e) => {
          if (e.lengthComputable) {
            const percent = Math.round((e.loaded / e.total) * 100);
            setProgress(percent);
          }
        });

        xhr.addEventListener('load', () => {
          if (xhr.status === 200) {
            resolve(xhr.response);
          } else {
            reject(new Error('アップロード失敗'));
          }
        });

        xhr.addEventListener('error', () => reject(new Error('ネットワークエラー')));

        xhr.open('PUT', uploadUrl);
        xhr.setRequestHeader('Content-Type', file.type);
        xhr.send(file);
      });

      setFileUrl(finalUrl);
      alert('アップロード成功!');
    } catch (error) {
      console.error(error);
      alert('アップロード失敗、再試行してください');
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="max-w-md mx-auto p-6">
      <input
        type="file"
        accept="image/*"
        onChange={(e) => setFile(e.files?.[0] || null)}
        className="block w-full text-sm"
      />

      <button
        onClick={handleUpload}
        disabled={!file || uploading}
        className="mt-4 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        {uploading ? `アップロード中 ${progress}%` : 'アップロード開始'}
      </button>

      {uploading && (
        <div className="mt-4 w-full bg-gray-200 rounded h-2">
          <div
            className="bg-blue-600 h-2 rounded transition-all"
            style={{ width: `${progress}%` }}
          />
        </div>
      )}

      {fileUrl && (
        <div className="mt-4">
          <p className="text-sm text-gray-600">アップロード成功!</p>
          <img src={fileUrl} alt="アップロードされた画像" className="mt-2 max-w-full" />
        </div>
      )}
    </div>
  );
}

なぜ fetch ではなく XMLHttpRequest を使うのか? fetch API はアップロード進捗の監視をサポートしていないからです。この API が古臭く見えるのは知っていますが、このシナリオでは最適な選択です。

UX の詳細:

  • プログレスバーでパーセンテージをリアルタイム表示
  • アップロード中はボタンを無効化し、重複クリックを防止
  • アップロード完了後に画像プレビューを自動表示

ステップ4:画像処理と最適化

アップロード後の画像は通常圧縮が必要です。2つの案があります:

案1:クライアント側で事前圧縮(こちらを推奨)

ライブラリをインストール:

npm install browser-image-compression

アップロード前に圧縮ロジックを追加:

import imageCompression from 'browser-image-compression';

const handleUpload = async () => {
  if (!file) return;

  // 画像圧縮
  const options = {
    maxSizeMB: 1,          // 最大1MB
    maxWidthOrHeight: 1920, // 最大幅高1920px
    useWebWorker: true,     // Web Worker 使用、メインスレッドをブロックしない
  };

  const compressedFile = await imageCompression(file, options);

  // 以降、file の代わりに compressedFile をアップロード
  // ...
};

メリットは? アップロード時間の短縮、S3 ストレージコストの節約、CDN トラフィック費用の削減です。実測では、iPhone で撮った 5MB の写真が圧縮後 500KB になり、肉眼では画質の差がわかりません。

案2:サーバー側で自動処理

S3 の Lambda トリガーを使用します。ファイルが Bucket にアップロードされるたびに Lambda 関数をトリガーし、サムネイル生成、圧縮、透かし追加などを自動的に行います。この方法は強力ですが、設定が複雑で、ある程度 AWS の経験がある開発者向けです。

Qiniu Cloud 統合ソリューション

Qiniu Cloud(七牛雲)の実装アプローチは S3 と似ていますが、API が少し異なります。重要なステップをざっと確認し、違いを重点的に説明します。

Qiniu Cloud の設定

まず Qiniu Cloud 公式サイトでアカウント登録し、オブジェクトストレージ空間(Bucket)を作成します。以下の情報をメモしてください:

  • AccessKeySecretKey(個人センター - キー管理で)
  • Bucket名
  • CDNドメイン(Qiniu Cloud はテストドメインを割り当てますが、本番環境では独自ドメインをバインドする必要があります)

Qiniu Cloud の Node.js SDK をインストール:

npm install qiniu

.env.local に設定を追加:

QINIU_ACCESS_KEY=あなたのAccessKey
QINIU_SECRET_KEY=あなたのSecretKey
QINIU_BUCKET=あなたのBucket名
QINIU_DOMAIN=あなたのCDNドメイン

サーバーサイドでのアップロード Token 生成

Qiniu Cloud では署名付き URL とは呼ばず、**アップロードトークン(Token)**と呼びますが、原理は同じです。

app/api/qiniu-upload/route.ts を作成:

import qiniu from 'qiniu';
import { NextRequest, NextResponse } from 'next/server';

const mac = new qiniu.auth.digest.Mac(
  process.env.QINIU_ACCESS_KEY!,
  process.env.QINIU_SECRET_KEY!
);

export async function POST(request: NextRequest) {
  try {
    const { fileName } = await request.json();

    // 一意なファイル名を生成
    const key = `uploads/${Date.now()}-${fileName}`;

    const options = {
      scope: `${process.env.QINIU_BUCKET}:${key}`,
      expires: 3600, // Token有効期間1時間
      returnBody: JSON.stringify({
        key: '$(key)',
        hash: '$(etag)',
        url: `https://${process.env.QINIU_DOMAIN}/$(key)`,
      }),
    };

    const putPolicy = new qiniu.rs.PutPolicy(options);
    const uploadToken = putPolicy.uploadToken(mac);

    return NextResponse.json({
      token: uploadToken,
      key: key,
      domain: process.env.QINIU_DOMAIN,
    });
  } catch (error) {
    console.error('Qiniu Cloud Token 生成失敗:', error);
    return NextResponse.json({ error: 'サーバーエラー' }, { status: 500 });
  }
}

S3 との違い:

  • S3 は完全な URL を返すが、Qiniu Cloud は Token を返す
  • S3 は 60秒期限が多いが、Qiniu Cloud は 3600秒(1時間)が一般的
  • Qiniu Cloud の returnBody はアップロード成功後に返すデータ構造を定義する

クライアントから Qiniu Cloud へのアップロード

Qiniu Cloud は公式 JS SDK の使用を推奨していますが、私は直接 FormData を使う方が軽量で好きです。

'use client';

import { useState } from 'react';

export default function QiniuUpload() {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [fileUrl, setFileUrl] = useState('');

  const handleUpload = async () => {
    if (!file) return;

    setUploading(true);

    try {
      // 1. アップロード Token 取得
      const response = await fetch('/api/qiniu-upload', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ fileName: file.name }),
      });

      const { token, key, domain } = await response.json();

      // 2. Qiniu Cloud へアップロード
      const formData = new FormData();
      formData.append('file', file);
      formData.append('token', token);
      formData.append('key', key);

      const uploadResponse = await fetch('https://upload.qiniup.com', {
        method: 'POST',
        body: formData,
      });

      const result = await uploadResponse.json();
      setFileUrl(`https://${domain}/${result.key}`);
      alert('アップロード成功!');
    } catch (error) {
      console.error(error);
      alert('アップロード失敗');
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="max-w-md mx-auto p-6">
      <input
        type="file"
        accept="image/*"
        onChange={(e) => setFile(e.files?.[0] || null)}
        className="block w-full text-sm"
      />

      <button
        onClick={handleUpload}
        disabled={!file || uploading}
        className="mt-4 px-4 py-2 bg-green-600 text-white rounded disabled:opacity-50"
      >
        {uploading ? 'アップロード中...' : 'Qiniu Cloud へアップロード'}
      </button>

      {fileUrl && (
        <div className="mt-4">
          <p className="text-sm text-gray-600">アップロード成功!</p>
          <img src={fileUrl} alt="アップロードされた画像" className="mt-2 max-w-full" />
        </div>
      )}
    </div>
  );
}

Qiniu Cloud のアップロードエンドポイントは固定で https://upload.qiniup.com です。ユーザーが主に中国華東地域にいる場合は https://upload-z0.qiniup.com を使うとさらに速くなります。

画像処理

Qiniu Cloud の画像処理は S3 よりもはるかに便利で、Lambda は不要、URL の後ろにパラメータを追加するだけです。

例えば、原画像 URL が https://xxx.com/image.jpg で、幅300pxのサムネイルを生成したい場合:

https://xxx.com/image.jpg?imageView2/2/w/300

100KB 以内に圧縮したい場合:

https://xxx.com/image.jpg?imageMogr2/strip/quality/75

これはデータ処理(fop)と呼ばれ、Qiniu Cloud は数十種類の画像操作をサポートし、組み合わせると非常に強力です。S3 で同じ機能を実現するには、Lambda を設定するかサードパーティサービスを使う必要があり、手間がかかります。

S3 ソリューションとのコード比較

ステップS3Qiniu Cloud
サーバーサイド SDK@aws-sdk/client-s3qiniu
認証方式署名付き URLアップロード Token
アップロードエンドポイントBucket 自身の URLupload.qiniup.com
アップロード方式PUT リクエスト + ファイルストリームFormData フォーム
画像処理Lambda またはサードパーティURL パラメータ (fop)

総じて、Qiniu Cloud の API は中国国内の開発者の習慣に合っており、ドキュメントも明確で、立ち上がりが早いです。S3 は機能が強力ですが、学習曲線は急です。

本番環境のベストプラクティス

コードが動くことと、本番環境で安定して動くことは別問題です。以下に私が踏んだ落とし穴と解決策を共有します。

セキュリティ:キーは絶対に漏らさない

最もやりがちなミス:AWS Secret Key をフロントエンドコードに書いてしまうこと。本当にこれをやってしまい、誰かにキーを使って大量のファイルをアップロードされ、数千ドルの請求書を受け取った人がいます。

正しいやり方

  1. キーはサーバー環境変数(.env.local)にのみ保存し、絶対に Git にコミットしない
  2. IAM ロールで権限を制限し、S3 アップロード権限のみを与え、削除や管理権限は与えない
  3. Bucket のライフサイクルポリシーを設定し、30日を超えた一時ファイルを自動削除してストレージ費用を抑制する

IAM 最小権限の例

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:PutObjectAcl"],
      "Resource": "arn:aws:s3:::your-bucket-name/uploads/*"
    }
  ]
}

このポリシーは uploads/ ディレクトリへのファイルアップロードのみを許可し、他の操作はすべて拒否します。

ファイル検証も欠かせません。サーバーで署名付き URL を生成する前に、ファイルタイプとサイズをチェックします:

// ホワイトリストポリシー、これらのタイプのみ許可
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_SIZE = 10 * 1024 * 1024; // 10MB

if (!ALLOWED_TYPES.includes(fileType)) {
  return NextResponse.json({ error: '非対応のファイル形式です' }, { status: 400 });
}

if (fileSize > MAX_SIZE) {
  return NextResponse.json({ error: 'ファイルが大きすぎます' }, { status: 400 });
}

余裕があれば、VirusTotal などのウイルススキャン API を統合し、ユーザーによる悪意あるファイルのアップロードを防ぐこともできます。

パフォーマンス:クライアント圧縮 + 遅延読み込み

前述のクライアント画像圧縮について強調しておきますが、本番環境では必須であり、オプションではありません。

理由は単純です:ユーザーが携帯電話で撮った写真は平気で 5-10MB あり、直接アップロードするのは時間とトラフィックの無駄です。1MB 以内に圧縮すれば、アップロード速度は5倍になり、ストレージコストは80%削減され、ダウンロードトラフィックも節約できます。三方よしです。

// 推奨圧縮設定
const compressOptions = {
  maxSizeMB: 1,
  maxWidthOrHeight: 1920,
  useWebWorker: true,
  fileType: 'image/webp', // WebP 形式を優先、サイズが小さい
};

画像表示時は、Next.js の Image コンポーネントを使用し、自動遅延読み込みとレスポンシブ最適化を行います:

import Image from 'next/image';

<Image
  src={fileUrl}
  alt="ユーザーがアップロードした画像"
  width={800}
  height={600}
  loading="lazy"
  placeholder="blur"
  blurDataURL="data:image/..." // ぼかしプレースホルダーを提供
/>

これにより、ユーザーが画像位置までスクロールしたときにのみ読み込まれ、ファーストビューの読み込み速度が大幅に向上します。

ユーザー体験:アップロードキュー + 再開機能

アプリが複数ファイルアップロードをサポートする場合、同時実行数を制御するキューマネージャーが必要です。10枚の画像を同時にアップロードするとブラウザがフリーズし、体験が悪化します。

同時実行制限

async function uploadQueue(files: File[], maxConcurrent = 3) {
  const results = [];
  for (let i = 0; i < files.length; i += maxConcurrent) {
    const batch = files.slice(i, i + maxConcurrent);
    const batchResults = await Promise.all(batch.map(uploadFile));
    results.push(...batchResults);
  }
  return results;
}

中断再開の考え方:

  1. 大容量ファイルを分割する(各5MB)
  2. 各断片をアップロードする際、進捗を localStorage に記録する
  3. アップロードが失敗するかユーザーがページをリロードした場合、進捗を読み込み、中断点から再開する

S3 の Multipart Upload API は分割アップロードをネイティブサポートしており、Qiniu Cloud にも同様の機能があります。実装は複雑なので、ここでは参考リンクを提示します:AWS Multipart Upload ドキュメント

エラーメッセージは明確に:

catch (error) {
  let message = 'アップロード失敗、再試行してください';

  if (error.message.includes('NetworkError')) {
    message = 'ネットワークが不安定です。接続を確認してください';
  } else if (error.message.includes('403')) {
    message = 'アップロード認証の有効期限が切れました。ページを更新してください';
  } else if (error.message.includes('Too large')) {
    message = 'ファイルが大きすぎます。10MB以下のファイルを選択してください';
  }

  setErrorMessage(message);
}

単に「アップロード失敗」と表示するのではなく、なぜ失敗したのか、どうすれば解決できるのかをユーザーに伝えます。

監視:ログ記録 + アラート設定

本番環境では必ずログを記録し、問題のトラブルシューティングを容易にします。

サーバーサイドログ

// 署名付き URL 生成時に記録
console.log(`[Upload] User: ${userId}, File: ${fileName}, Size: ${fileSize}`);

// アップロード失敗時に詳細エラーを記録
console.error(`[Upload Error]`, {
  user: userId,
  file: fileName,
  error: error.message,
  stack: error.stack,
});

Vercel では、これらのログは自動的にログシステムに送信されます。AWS を使用する場合は CloudWatch の設定を推奨します:

  • S3 アップロード失敗率を監視
  • アラート設定:失敗率が 5% を超えたらメール通知
  • Bucket ストレージサイズを監視し、費用の暴走を防ぐ

フロントエンド監視は Sentry を使用して、アップロード関連のエラーを自動キャッチできます:

import * as Sentry from '@sentry/nextjs';

try {
  await uploadFile(file);
} catch (error) {
  Sentry.captureException(error, {
    tags: { feature: 'file-upload' },
    extra: { fileName, fileSize },
  });
  throw error;
}

これにより、どれくらいのユーザーがアップロード問題に遭遇しているか、どの種類のエラーが最も一般的かがわかり、的確な最適化が可能になります。

よくある質問のトラブルシューティング

このセクションでは、私が遭遇した高頻度の問題をリストアップします。これで落とし穴の 90% はカバーできるはずです。

問題1:CORS エラー — “No ‘Access-Control-Allow-Origin’”

現象:ブラウザコンソールに赤いエラーが表示され、アップロードリクエストがブロックされる。

原因:S3 Bucket の CORS 設定が見つからないか正しくない。多くの人が Bucket 作成後に CORS 設定を忘れ、ブラウザのセキュリティポリシーによってクロスオリジンリクエストが拒否されます。

解決策

  1. S3 コンソールに入り、Bucket を選択
  2. “Permissions” → “CORS configuration” をクリック
  3. 以下の設定を貼り付ける:
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST"],
    "AllowedOrigins": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

本番環境では "*" を使用せず、["https://yourapp.com"] のように実際のドメインに変更してください。

Qiniu Cloud も同様に CORS 設定が必要で、Bucket 設定に「CORS設定」入口があり、操作は似ています。

問題2:署名付き URL 無効 — 403 Forbidden

現象:アップロード時に 403 エラーが返され、“Access Denied” または “Request has expired” と表示される。

原因

  1. URL の有効期限切れ(生成からアップロード開始まで 60秒を超えた)
  2. IAM ユーザーに s3:PutObject 権限がない
  3. アップロードしたファイルタイプ(ContentType)と、URL 生成時に指定したContentType が一致しない(これはよくあるミスです!)

解決策

  • 有効期限を 300秒(5分)に延ばす
  • IAM ポリシーを確認する
  • 重要:クライアントの xhr.setRequestHeader('Content-Type', file.type) とサーバーの PutObjectCommandContentType が完全に一致していることを確認してください。

FAQ

署名付きURLの安全性は?キーが漏洩する可能性はありますか?
非常に安全です。署名付きURLはサーバー上でAWS Secret Keyを使用して生成されますが、生成されたURL自体にはキー情報は含まれていません。URLには一時的な署名のみが含まれており、有効期限(例:60秒)が過ぎると自動的に無効になります。フロントエンドはキーに一切触れないため、キー漏洩のリスクはありません。
S3とQiniu Cloud、個人開発者はどちらを選ぶべきですか?
ユーザーが主に中国国内にいる場合や、予算に敏感な個人開発者には、Qiniu Cloud(七牛雲)を推奨します。毎月10GBの無料ストレージとトラフィックがあり、国内CDNの速度も速く、ICP登録済みドメインのサポートもあります。ユーザーが海外にいる、またはAWSエコシステムが必要な場合はS3が適しています。
アップロード完了後に、どうやってデータベースの更新をトリガーすればいいですか?
一番確実なのは、アップロード成功後にフロントエンドから再度Next.jsのAPIを呼び出し、ファイルURLを保存することです(例:`POST /api/user/avatar`)。より高度な方法として、S3のLambdaトリガーを設定し、ファイルアップロード時に自動的にDB更新用Webhookを呼び出すこともできますが、個人プロジェクトでは前者の方法が簡単で十分です。
大容量ファイル(1GB以上)をアップロードする場合の注意点は?
単一のPUTリクエストでのアップロードは失敗しやすいため、マルチパートアップロード(Multipart Upload)を使用してください。ファイルを5MBごとのチャンクに分割し、並列アップロードします。また、中断再開機能を実装し、ネットワーク切断時に最初からやり直さなくて済むようにするのがベストプラクティスです。
アップロードされた画像がブラウザで表示されず、ダウンロードされてしまうのはなぜ?
これはContentTypeの設定ミスです。S3にアップロードする際、`ContentType: 'image/jpeg'`などを明示的に指定しないと、デフォルトで`application/octet-stream`になり、ブラウザは画像としてプレビューせずダウンロードとして処理します。署名付きURL生成時に正しいMIMEタイプを指定してください。

Next.js S3 署名付き URL アップロードの実装手順

Next.js App Router で AWS S3 へのセキュアで高速なファイルアップロード機能を実装する完全ガイド

⏱️ Estimated time: 1 hr

  1. 1

    Step1: AWS S3 環境設定

    AWSコンソールでS3 Bucketを作成し、CORS設定を追加してフロントエンドからのクロスオリジンアクセス(PUT/POST)を許可します。IAMユーザーを作成し、そのBucketへの`s3:PutObject`権限のみを付与して、AccessKeyとSecretKeyを取得します。
  2. 2

    Step2: サーバーサイド API ルート作成

    `app/api/upload/route.ts`を作成します。`@aws-sdk/s3-request-presigner`を使用して、ファイル名とタイプを受け取り、S3への一時的なアップロード権限を持つ署名付きURL(有効期限60秒)を生成して返します。ここではファイルタイプとサイズの検証も行います。
  3. 3

    Step3: クライアントサイドアップロード実装

    Reactコンポーネントでファイル入力を受け付けます。まずAPIを呼び出してアップロードURLを取得し、次に`XMLHttpRequest`または`fetch`を使用して、そのURLに対して画像データを直接`PUT`します。これにより、Next.jsサーバーを経由せずにS3へ直行します。
  4. 4

    Step4: パフォーマンスと体験の最適化

    `browser-image-compression`を使用してアップロード前に画像をクライアント側で圧縮し、サイズを削減します。XMLHttpRequestのprogressイベントをリッスンして、リアルタイムのプログレスバーを表示し、ユーザー体験を向上させます。
  5. 5

    Step5: セキュリティとエラー処理

    環境変数でキーを管理し、Gitにコミットしないようにします。アップロード失敗時のエラーメッセージを詳細化し(ネットワークエラー、期限切れなど)、Sentry等でログを監視します。Bucketのライフサイクルルールを設定し、古い一時ファイルを自動削除します。

10 min read · 公開日: 2026年1月7日 · 更新日: 2026年1月22日

コメント

GitHubアカウントでログインしてコメントできます

関連記事