はじめに
Next.js App Router で管理画面を作っていると、フォーム送信や軽い更新処理は Server Actions に寄せたくなります。
API の URL や認証ヘッダーをブラウザに持たせずに済むし、revalidatePath や redirect とも相性がいいからです。
ただし、ファイルアップロードまで同じノリで Server Actions に渡すと、サイズが大きくなった瞬間に苦しくなります。
この記事では、CSV ファイルを Base64 化して Server Action に渡していた実装を、S3 Presigned URL への直接アップロード方式に変えたときの設計メモを書きます。業務固有の話は省き、Web アプリでよくある「大きめの CSV をアップロードして、サーバー側で後処理したい」ケースとして一般化しています。
最初の実装
最初はこういう流れでした。
Browser
-> CSV を FileReader で読む
-> Base64 文字列にする
-> Server Action に渡す
-> API サーバーへ JSON body で送る
-> API サーバーが Base64 decode
-> S3 に保存
-> プレビューや変換処理を実行
軽いファイルならこれでも動きます。
'use server'
export async function uploadCsvAction(input: {
fileName: string
contentBase64: string
}) {
await apiClient.uploadCsv({
fileName: input.fileName,
contentBase64: input.contentBase64,
})
}
ただ、CSV が数 MB を超え始めると急に問題が出ます。
起きた問題
代表的には次のような問題です。
- Server Action の body size limit に引っかかる
- Base64 化でサイズが約 33% 増える
- React Flight のシリアライズ層を大きな文字列が通る
- Next.js と API サーバーの両方で大きな JSON body を扱う
- プログレス表示がしづらい
- 同時アップロード時のメモリ使用量が読みづらい
serverActions.bodySizeLimit を引き上げれば一部は回避できます。
// next.config.ts
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
}
しかし、これは根本解決ではありません。
たとえば 300MB の CSV を扱いたい場合、Base64 化だけで約 400MB になります。それをブラウザ、Next.js、API サーバーが JSON として扱う構成は、かなり無理があります。制限値を上げるほど、今度はメモリ消費やタイムアウトが問題になります。
方針: ファイル本体をアプリサーバーに通さない
最終的には、ファイル本体を Server Action や API サーバーに通さない構成にしました。
Browser
-> Server Action: アップロード用 URL を要求
-> API Server: S3 Presigned URL を発行
<- Browser: Presigned URL を受け取る
-> S3: ファイルを直接 PUT
-> Server Action: アップロード済み S3 key の処理を要求
-> API Server: S3 から読み、プレビューや変換処理を実行
ポイントは、Server Action を捨てるのではなく、役割を変えることです。
- Server Action が扱うのはメタデータだけ
- ファイル本体はブラウザから S3 に直接 PUT
- 後処理は S3 key を渡してサーバー側で実行
API: Presigned URL を発行する
API サーバー側では、ファイル名、Content-Type、サイズなどを検証してから Presigned URL を返します。
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const s3 = new S3Client({ region: 'ap-northeast-1' })
export async function createUploadUrl(input: {
fileName: string
contentType: string
fileSize: number
}) {
if (input.fileSize > 300 * 1024 * 1024) {
throw new Error('file is too large')
}
const key = `uploads/${crypto.randomUUID()}/${input.fileName}`
const command = new PutObjectCommand({
Bucket: process.env.UPLOAD_BUCKET!,
Key: key,
ContentType: input.contentType,
})
const uploadUrl = await getSignedUrl(s3, command, {
expiresIn: 60 * 5,
})
return { key, uploadUrl }
}
ここで重要なのは、Presigned URL を発行する前にサーバー側で上限や拡張子を検証することです。URL を発行したあとはブラウザが S3 に直接 PUT できるので、発行時点で「許可してよいアップロードか」を判断しておきます。
Server Action: URL 取得と後処理だけ担当する
Server Action は薄くできます。
'use server'
export async function getCsvUploadUrl(input: {
fileName: string
contentType: string
fileSize: number
}) {
return apiClient.createUploadUrl(input)
}
export async function processUploadedCsv(input: {
key: string
}) {
return apiClient.processUploadedCsv(input)
}
この時点で、Server Action に流れる payload は数百 byte です。CSV 本体は通りません。
ブラウザ: XHR で progress を取る
fetch だけでも PUT はできますが、アップロード進捗を扱うなら XHR がまだ便利です。
export function uploadToS3WithProgress(args: {
url: string
file: File
onProgress?: (percent: number) => void
}) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return
const percent = Math.round((event.loaded / event.total) * 100)
args.onProgress?.(percent)
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else reject(new Error(`upload failed: ${xhr.status}`))
}
xhr.onerror = () => reject(new Error('upload failed'))
xhr.open('PUT', args.url)
xhr.setRequestHeader('Content-Type', args.file.type || 'text/csv')
xhr.send(args.file)
})
}
画面側の流れはこうなります。
async function onSubmit(file: File) {
const { uploadUrl, key } = await getCsvUploadUrl({
fileName: file.name,
contentType: file.type || 'text/csv',
fileSize: file.size,
})
await uploadToS3WithProgress({
url: uploadUrl,
file,
onProgress: setProgress,
})
const result = await processUploadedCsv({ key })
setPreview(result.previewRows)
}
S3 CORS 設定を忘れない
ブラウザから S3 に直接 PUT するので、S3 bucket の CORS 設定が必要です。
[
{
"AllowedOrigins": ["https://example.com", "http://localhost:3000"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type"],
"ExposeHeaders": ["etag"],
"MaxAgeSeconds": 3000
}
]
本番では AllowedOrigins: ["*"] にしない方がいいです。開発環境と本番環境で origin を分けて管理します。
後処理は S3 から読む
アップロード後の処理では、API サーバーが S3 key を受け取り、S3 から読みます。
import { GetObjectCommand } from '@aws-sdk/client-s3'
export async function processUploadedCsv(key: string) {
const object = await s3.send(new GetObjectCommand({
Bucket: process.env.UPLOAD_BUCKET!,
Key: key,
}))
if (!object.Body) throw new Error('empty object')
// 大きいファイルなら、全体を Buffer に載せずストリームで処理する
// 例: 一時ファイルに pipe して 後続処理 に渡す
}
大きなファイルを扱うなら、ここでも全体をメモリに載せない方が安全です。S3 object stream を一時ファイルへ pipe し、そのファイルを後続処理に渡す設計にすると、メモリ使用量を安定させやすくなります。
この方式のメリット
一番大きいのは、制限の壁が減ることです。
Before:
Browser -> React Flight -> Server Action -> API JSON body -> S3
After:
Browser -> S3
Browser -> Server Action -> API はメタデータのみ
結果として、次のようなメリットがありました。
- Base64 化が不要
- Server Action の body size limit に依存しない
- API サーバーの JSON body limit に依存しない
- アプリサーバーのメモリ使用量を抑えやすい
- アップロード progress を出しやすい
- S3 のスケーラビリティに寄せられる
注意点
Presigned URL 方式にも注意点はあります。
- URL の有効期限は短くする
- key はサーバー側で生成する
- Content-Type やサイズ上限は発行前に検証する
- S3 CORS を必要最小限にする
- アップロード後の key を使った処理でも認可チェックする
- 未処理の一時ファイルを掃除する lifecycle rule を入れる
特に「Presigned URL を持っている人は、その URL の範囲ではアップロードできる」という性質を忘れないことが重要です。URL を発行する API 側で、誰がどの prefix にアップロードしてよいかを必ず縛ります。
まとめ
Server Actions は便利ですが、大きなファイル本体を渡す場所ではありません。
軽いフォーム送信やメタデータ更新は Server Actions に寄せ、ファイル本体は S3 Presigned URL で直接アップロードする。これだけで、body size、Base64 膨張、メモリ消費、progress 表示の問題をかなり整理できます。
「Server Actions を使うか、使わないか」ではなく、「Server Actions に何を通すか」を設計するのが大事でした。