FormData とは、フォームの入力値やファイルを「名前と値の組」として集め、multipart/form-data 形式でサーバーへ送るための JavaScript のオブジェクトです。 fetch(url, { method: 'POST', body: formData }) と渡すだけで、ファイルを含むデータを送信できます。

この記事では、送る側(ブラウザ・Node.js)と受け取る側(Express + multer、PHP)の両方のコードを用意し、実際に動かした結果をそのまま載せています。特につまずきやすい 「Content-Type を自分で付けると Multipart: Boundary not found になる」現象を再現し、なぜそうなるのかを送信される本文の中身から説明します。複数ファイル・Blob の送信、進捗バーの付け方、JSON への変換、押したボタンを含める new FormData(form, submitter) まで、コピペで試せる形でまとめました。

FormDataの早見表

FormData でよく使う操作は、この表でほぼ足ります。

やりたいこと 書き方 結果・補足
空で作る const fd = new FormData()
フォームから作る new FormData(formEl) name 属性のある入力欄がすべて入る
押したボタンも含める new FormData(formEl, event.submitter) 「下書き」「公開」などの区別に
値を追加 fd.append('tags', '新着') 同じ名前で何度でも追加できる
値を上書き fd.set('tags', '在庫あり') 同じ名前の値を1つにまとめて置き換え
1つ取得 fd.get('tags') 最初の1つ。無ければ null
すべて取得 fd.getAll('tags') 配列で返る
あるか確認 fd.has('name') true / false
削除 fd.delete('name') 同じ名前をすべて削除
全件を回す for (const [k, v] of fd) entries() keys() values() も使える
ファイルを追加 fd.append('photo', file) <input type="file"> の files[0] など
Blob をファイルとして追加 fd.append('memo', blob, 'memo.txt') 第3引数がファイル名
fetch で送信 fetch(url, { method: 'POST', body: fd }) Content-Type は付けない
JSON に変換 Object.fromEntries(fd) 同じ名前が複数あると最後の1つだけ
URL エンコードで送る new URLSearchParams(fd) ファイルは送れない
進捗を表示 XMLHttpRequest の xhr.upload.onprogress fetch には送信進捗のイベントがない

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「FormDataの使い方|fetchで送信・ファイルアップロードをコピペ解説」

動作環境

この記事のコードは次の環境で実行しています。

項目 バージョン
Node.js 22.22.2(FormData・fetch・Blob は組み込み)
express 5.2.1
multer 2.4.0
PHP 8.4.21(php -S の組み込みサーバー)
ブラウザ確認 Playwright + Chromium 141.0.7390.37

FormData と fetch は、現在の主要ブラウザと Node.js 18 以降で標準で使えます。new FormData(form, submitter) の第2引数は比較的新しい仕様のため、対応状況は MDN の互換性表で確認してください(この記事では Chromium 141 で動作を確認しました)。

FormDataのメソッド一覧と実行結果

まずはメソッドの動きを Node.js で確認します。 ブラウザでもまったく同じ結果になります。

// methods.mjs
const fd = new FormData();
fd.append('name', '佐藤');
fd.append('tags', '新着');
fd.append('tags', 'おすすめ');
console.log('get(tags)    :', fd.get('tags'));
console.log('getAll(tags) :', fd.getAll('tags'));
console.log('has(name)    :', fd.has('name'));
console.log('get(none)    :', fd.get('none'));
fd.set('tags', '在庫あり');
console.log('set 後 getAll:', fd.getAll('tags'));
fd.append('count', 3);
console.log('数値を入れると:', typeof fd.get('count'), fd.get('count'));
fd.delete('name');
console.log('delete 後 has:', fd.has('name'));
fd.append('memo', new Blob(['備品メモ'], { type: 'text/plain' }), 'memo.txt');
const f = fd.get('memo');
console.log('Blob を入れると:', f.constructor.name, f.name, f.type, f.size + 'byte');
for (const [k, v] of fd.entries()) console.log('entries:', k, typeof v === 'string' ? v : `[${v.constructor.name} ${v.name}]`);
console.log('keys  :', [...fd.keys()]);
console.log('values:', [...fd.values()].map(String));

実行結果(node methods.mjs、Node.js 22.22.2):

get(tags)    : 新着
getAll(tags) : [ '新着', 'おすすめ' ]
has(name)    : true
get(none)    : null
set 後 getAll: [ '在庫あり' ]
数値を入れると: string 3
delete 後 has: false
Blob を入れると: File memo.txt text/plain 12byte
entries: tags 在庫あり
entries: count 3
entries: memo [File memo.txt]
keys  : [ 'tags', 'count', 'memo' ]
values: [ '在庫あり', '3', '[object File]' ]

押さえておきたい動き

メソッド 動き 実行結果からわかること
append(名前, 値) 末尾に追加。同じ名前があっても残す tags が2つになる
set(名前, 値) 同じ名前をすべて消して1つにする ['在庫あり'] の1件に。位置は最初の tags の場所
get(名前) 最初の1つ 2つあっても '新着' だけ
getAll(名前) すべてを配列で チェックボックスや複数選択に使う
has(名前) 存在確認
delete(名前) 同じ名前をすべて削除
entries() / keys() / values() 反復用 for...of でそのまま回せる
  • 値は文字列か File に変換されます。 数値 3 を入れても、取り出すと文字列 '3' です。
  • Blob を入れると File として扱われます。 第3引数を省くとファイル名は blob になります。
  • console.log(fd) では中身が見えにくいので、確認には [...fd] や for...of を使うと便利です。

fetchでFormDataを送信する

FormData を body に渡すと、fetch が Content-Type: multipart/form-data; boundary=... を自動で付けてくれます。 ここが一番つまずきやすいポイントなので、受信サーバーを用意して確かめます。

受信側:Express 5 + multer 2 のサーバー

npm init -y
npm i express multer
// server.mjs(Express 5 + multer 2)
import express from 'express';
import multer from 'multer';

const app = express();
const upload = multer({
  dest: 'uploads/',                                   // 保存先フォルダ
  limits: { fileSize: 5 * 1024 * 1024, files: 5 },    // 1ファイル5MBまで、5個まで
});

app.use(express.static('public'));                    // 動作確認用のHTML
app.use(express.urlencoded({ extended: false }));     // URLSearchParams を受け取る

// 文字だけ(ファイルなし)の multipart
app.post('/api/member', upload.none(), (req, res) => {
  console.log('[server] content-type =', req.headers['content-type']);
  res.json({ body: req.body });
});

// ファイル1つ + 複数ファイル
app.post('/api/upload', upload.fields([
  { name: 'avatar', maxCount: 1 },
  { name: 'photos', maxCount: 4 },
]), (req, res) => {
  const files = Object.fromEntries(Object.entries(req.files ?? {}).map(([k, list]) =>
    [k, list.map((f) => ({ originalname: f.originalname, mimetype: f.mimetype, size: f.size }))]));
  res.json({ body: req.body, files });
});

// URLSearchParams(application/x-www-form-urlencoded)
app.post('/api/search', (req, res) => {
  res.json({ contentType: req.headers['content-type'], body: req.body });
});

// エラーを JSON で返す
app.use((err, req, res, next) => {
  res.status(400).json({ error: err.message, code: err.code ?? null });
});

app.listen(7200, () => console.log('http://localhost:7200'));
node server.mjs

multer の主な受け取り方は次のとおりです。

multer のメソッド 受け取るもの 結果の入る場所
upload.none() 文字の項目だけ req.body
upload.single('avatar') 1つのファイル req.file
upload.array('photos', 4) 同じ名前の複数ファイル req.files(配列)
upload.fields([...]) 名前ごとに複数 req.files.avatar など
upload.any() すべて req.files(配列)

送信側:Content-Type を付けない場合と付けた場合

// client.mjs
const base = 'http://localhost:7200';
const show = async (label, res) => console.log(label, res.status, JSON.stringify(await res.json()));

// 1. 正しい送り方(Content-Type を付けない)
const fd = new FormData();
fd.append('name', '佐藤');
fd.append('tags', '新着');
fd.append('tags', 'おすすめ');
await show('[OK]  ', await fetch(`${base}/api/member`, { method: 'POST', body: fd }));

// 2. Content-Type を手で付ける
await show('[NG]  ', await fetch(`${base}/api/member`, {
  method: 'POST',
  headers: { 'Content-Type': 'multipart/form-data' },
  body: fd,
}));

// 3. 実際に送られるヘッダーと本文を覗く
const req = new Request(`${base}/api/member`, { method: 'POST', body: fd });
console.log('自動で付く Content-Type:', req.headers.get('content-type'));
console.log((await req.text()).replace(/\r\n/g, '⏎\n'));

実行結果(node client.mjs):

[OK]   200 {"body":{"name":"佐藤","tags":["新着","おすすめ"]}}
[NG]   400 {"error":"Multipart: Boundary not found","code":null}
自動で付く Content-Type: multipart/form-data; boundary=----formdata-undici-051908849075
------formdata-undici-051908849075⏎
Content-Disposition: form-data; name="name"⏎
⏎
佐藤⏎
------formdata-undici-051908849075⏎
Content-Disposition: form-data; name="tags"⏎
⏎
新着⏎
------formdata-undici-051908849075⏎
Content-Disposition: form-data; name="tags"⏎
⏎
おすすめ⏎
------formdata-undici-051908849075--⏎

(⏎ は改行 \r\n の位置を見やすくするために置き換えたものです。boundary の数字は実行のたびに変わります。)

なぜ Content-Type を自分で付けてはいけないのか

multipart/form-data の本文は、boundary(境界線)と呼ばれる文字列で項目ごとに区切られています。 上の実行結果の ------formdata-undici-... がその境界線です。サーバーは Content-Type ヘッダーの boundary=... を読んで、本文をどこで区切るかを知ります。

sequenceDiagram
    participant B as fetch(送信側)
    participant S as サーバー(multer / PHP)
    B->>B: FormData から本文を作り、boundary を決める
    B->>S: Content-Type: multipart/form-data; boundary=XXXX
    S->>S: boundary=XXXX で本文を区切って読む
    Note over B,S: 手で "multipart/form-data" だけを付けると boundary が伝わらない
    S-->>B: 400 Multipart: Boundary not found

fetch は本文を作るときに boundary を決め、それを Content-Type に書き込みます。 自分で 'multipart/form-data' と書くと、その値が優先されて boundary が抜けるため、サーバーは本文を区切れません。FormData を送るときは headers に Content-Type を書かない、これだけ覚えておけば大丈夫です。Authorization など他のヘッダーは付けてかまいません。

ブラウザ(Chromium 141)で同じことをしても、結果は同じでした。

手動CT(ブラウザ): 400 {"error":"Multipart: Boundary not found","code":null}

ファイル・複数ファイル・Blobを送る

ファイルは append(名前, File または Blob, ファイル名) で追加します。 同じ名前で複数回 append すると、複数ファイルになります。

// client2.mjs(ファイル送信の部分)
import { openAsBlob, writeFileSync } from 'node:fs';
const base = 'http://localhost:7200';
const show = async (label, res) => console.log(label, res.status, JSON.stringify(await res.json()));

writeFileSync('avatar.png', Buffer.alloc(2048, 1));      // 2KB のダミー画像
const fd = new FormData();
fd.append('title', '備品の写真');
fd.append('avatar', await openAsBlob('avatar.png', { type: 'image/png' }), 'avatar.png');
for (const name of ['desk.jpg', 'chair.jpg']) {
  fd.append('photos', new Blob(['dummy-' + name], { type: 'image/jpeg' }), name);
}
// JSON をファイルとして送る
fd.append('photos', new Blob([JSON.stringify({ id: 1, stock: 3 })], { type: 'application/json' }), 'stock.json');
await show('[files]', await fetch(`${base}/api/upload`, { method: 'POST', body: fd }));

// 上限超え(photos は maxCount 4)
const many = new FormData();
for (let i = 0; i < 5; i++) many.append('photos', new Blob(['x']), `p${i}.jpg`);
await show('[limit]', await fetch(`${base}/api/upload`, { method: 'POST', body: many }));

// サイズ超え(上限5MBに6MB)
const big = new FormData();
big.append('avatar', new Blob([Buffer.alloc(6 * 1024 * 1024)]), 'big.png');
await show('[size] ', await fetch(`${base}/api/upload`, { method: 'POST', body: big }));

実行結果:

[files] 200 {"body":{"title":"備品の写真"},"files":{"avatar":[{"originalname":"avatar.png","mimetype":"image/png","size":2048}],"photos":[{"originalname":"desk.jpg","mimetype":"image/jpeg","size":14},{"originalname":"chair.jpg","mimetype":"image/jpeg","size":15},{"originalname":"stock.json","mimetype":"application/json","size":18}]}}
[limit] 400 {"error":"Unexpected file field","code":"LIMIT_UNEXPECTED_FILE"}
[size]  400 {"error":"File too large","code":"LIMIT_FILE_SIZE"}

ポイント

  • Node.js では fs.openAsBlob()(Node 19.8 以降)でファイルを Blob として開けます。 ブラウザでは input.files の File をそのまま append します。
  • multer の maxCount を超えると LIMIT_UNEXPECTED_FILE、fileSize を超えると LIMIT_FILE_SIZE になります。エラーハンドラーで JSON にして返すと、画面側でメッセージを出しやすくなります。
  • 送られてくる mimetype やファイル名は送信側が自由に決められる値です。拡張子・中身の確認、保存名の付け直しなどの対策は、別記事「ファイルアップロードのセキュリティ対策」で詳しく解説しています。

ブラウザの input type="file" から送る

<input type="file" id="photos" multiple accept="image/*">
<button id="send">送信</button>
<script>
  document.getElementById('send').addEventListener('click', async () => {
    const input = document.getElementById('photos');
    const fd = new FormData();
    for (const file of input.files) fd.append('photos', file);   // 複数ファイル
    const res = await fetch('/api/upload', { method: 'POST', body: fd });
    console.log(await res.json());
  });
</script>

input.files は FileList という反復可能な一覧なので、for...of で1つずつ append できます。

フォームから作る:new FormData(form, submitter)

<form> 要素を渡すと、name 属性のある入力欄がまとめて FormData に入ります。 第2引数に押されたボタン(submitter)を渡すと、そのボタンの name と value も含まれます。

動作確認用のHTML(public/index.html)

<!DOCTYPE html>
<html lang="ja">
<head><meta charset="utf-8"><title>備品登録</title></head>
<body>
  <form id="item-form" action="/api/upload" method="post" enctype="multipart/form-data">
    <input name="title" value="オフィスチェア">
    <label><input type="checkbox" name="tags" value="新着" checked>新着</label>
    <label><input type="checkbox" name="tags" value="おすすめ" checked>おすすめ</label>
    <label><input type="checkbox" name="tags" value="在庫少">在庫少</label>
    <input type="text" name="memo" value="無効な欄" disabled>
    <input type="file" name="photos" multiple accept="image/*">
    <button type="submit" name="status" value="draft">下書き保存</button>
    <button type="submit" name="status" value="publish">公開する</button>
  </form>
  <progress id="bar" max="100" value="0"></progress>
  <pre id="out"></pre>
  <script>
    const form = document.getElementById('item-form');
    const out = document.getElementById('out');

    form.addEventListener('submit', async (event) => {
      event.preventDefault();                                   // 画面遷移を止める
      const fd = new FormData(form, event.submitter);           // 押したボタンも含める
      const res = await fetch(form.action, { method: 'POST', body: fd });
      out.textContent = JSON.stringify(await res.json());
    });

    // 進捗つき送信(XMLHttpRequest)
    window.uploadWithProgress = (fd) => new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      const log = [];
      xhr.open('POST', '/api/upload');
      xhr.upload.addEventListener('progress', (e) => {
        if (e.lengthComputable) {
          const pct = Math.round((e.loaded / e.total) * 100);
          document.getElementById('bar').value = pct;
          log.push(pct);
        }
      });
      xhr.addEventListener('load', () => resolve({ status: xhr.status, log }));
      xhr.addEventListener('error', reject);
      xhr.send(fd);
    });
  </script>
</body>
</html>

Playwright で実ブラウザから確認

このページを Express(express.static('public'))で配信し、Playwright で Chromium を動かして確認しました。

// browser_test.mjs(抜粋)
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:7200/');

// 1. new FormData(form) と submitter の違い
console.log('form のみ     :', await page.evaluate(() =>
  JSON.stringify([...new FormData(document.getElementById('item-form'))]
    .map(([k, v]) => [k, typeof v === 'string' ? v : `File(${v.name || '空'})`]))));
console.log('submitter あり:', await page.evaluate(() => {
  const form = document.getElementById('item-form');
  const btn = form.querySelector('button[value="publish"]');
  return JSON.stringify([...new FormData(form, btn)].filter(([k]) => k !== 'photos'));
}));

// 2. ファイルを選んで「公開する」ボタンで送信
await page.setInputFiles('input[name=photos]', [
  { name: 'desk.png', mimeType: 'image/png', buffer: Buffer.alloc(1000, 1) },
  { name: 'chair.png', mimeType: 'image/png', buffer: Buffer.alloc(2000, 1) },
]);
await page.click('button[value="publish"]');
await page.waitForFunction(() => document.getElementById('out').textContent !== '');
console.log('送信結果      :', await page.textContent('#out'));
await browser.close();

実行結果(Chromium 141.0.7390.37):

form のみ     : [["title","オフィスチェア"],["tags","新着"],["tags","おすすめ"],["photos","File(空)"]]
submitter あり: [["title","オフィスチェア"],["tags","新着"],["tags","おすすめ"],["status","publish"]]
送信結果      : {"body":{"title":"オフィスチェア","tags":["新着","おすすめ"],"status":"publish"},"files":{"photos":[{"originalname":"desk.png","mimetype":"image/png","size":1000},{"originalname":"chair.png","mimetype":"image/png","size":2000}]}}

フォームから作るときのルール

入力欄 FormData に入るか
name 属性のある input / select / textarea 入る
name 属性がない 入らない
チェックの入ったチェックボックス 入る(同じ name で複数なら複数件)
チェックの外れたチェックボックス(「在庫少」) 入らない
disabled の欄(「memo」) 入らない(送りたいなら readonly を使う)
ファイル未選択の type="file" 空の File(名前なし)が入る
submit ボタン 第2引数に submitter を渡したときだけ入る

event.submitter は submit イベントで「どのボタンが押されたか」を表します。 「下書き保存」と「公開する」を1つのフォームで分けたいとき、サーバー側で status の値を見て処理を切り替えられます。

名前の付け方の注意:name="action" はフォームの action を隠す

検証の途中で、ボタンの name を action にしていたところ、fetch(form.action, ...) が 404 になりました。調べると、form.action が URL ではなく [object RadioNodeList] になっていました。

form.action                → [object RadioNodeList]
form.getAttribute('action') → /api/upload

フォームの中の要素の name は、form.name名 でアクセスできるしくみのため、action や method・submit といった名前を付けると、フォーム自体のプロパティより優先されます。 入力欄の name には status や mode などの別の名前を使うか、URL は form.getAttribute('action') で取り出しましょう。

進捗バーを出したいときはXMLHttpRequest

fetch には送信(アップロード)の進捗を受け取るイベントがありません。 大きなファイルで進捗バーを出したいときは、XMLHttpRequest の xhr.upload の progress イベントを使います。上の HTML の uploadWithProgress() を、3MB のファイルで呼び出しました。

const fd = new FormData();
fd.append('avatar', new Blob([new Uint8Array(3 * 1024 * 1024)], { type: 'image/png' }), 'big.png');
const result = await window.uploadWithProgress(fd);

実行結果:

XHR 進捗      : {"status":200,"count":1,"first":100,"last":100}

同じマシン上のサーバーへの送信だったため一瞬で終わり、progress イベントは100%の1回だけでした。ネットワーク越しに大きなファイルを送る場合は途中経過で何度も発生し、e.loaded / e.total で割合を計算できます(この記事では途中経過が複数回出る様子は確認していません)。

送信方法 送信の進捗 書きやすさ 向いている場面
fetch + FormData 取れない ◎ async/await で書ける ほとんどの送信
XMLHttpRequest + FormData xhr.upload.onprogress で取れる ○ イベントで書く 大きなファイルの進捗バー

FormDataをJSONに変換する

サーバーが JSON を受け取る API の場合は、FormData をオブジェクトに変換してから JSON.stringify します。

const form = new FormData();
form.append('name', '佐藤');
form.append('tags', '新着');
form.append('tags', 'おすすめ');

console.log('Object.fromEntries   :', JSON.stringify(Object.fromEntries(form)));

const toObject = (fd) => {
  const obj = {};
  for (const key of new Set(fd.keys())) {
    const all = fd.getAll(key);
    obj[key] = all.length > 1 ? all : all[0];
  }
  return obj;
};
console.log('getAll で配列を残す :', JSON.stringify(toObject(form)));

実行結果:

Object.fromEntries   : {"name":"佐藤","tags":"おすすめ"}
getAll で配列を残す : {"name":"佐藤","tags":["新着","おすすめ"]}

Object.fromEntries(fd) は同じ名前の値を上書きするため、tags は最後の「おすすめ」だけになります。 チェックボックスなど同じ名前が複数ある場合は、getAll() で配列にまとめる toObject() のような関数を使いましょう。JSON で送るときは headers: { 'Content-Type': 'application/json' } を付けます(こちらは付けるのが正解です)。fetch の基本的な使い方は別記事「JavaScript fetchの使い方」で解説しています。

URLSearchParamsとの違い

URLSearchParams は application/x-www-form-urlencoded(a=1&b=2 の形)で送るためのオブジェクトです。 文字だけのデータなら、こちらのほうが本文が小さくなります。

const params = new URLSearchParams(form);   // 上の form(name, tags×2)
console.log('URLSearchParams      :', params.toString());
await show('[urlenc]', await fetch(`${base}/api/search`, { method: 'POST', body: params }));

実行結果:

URLSearchParams      : name=%E4%BD%90%E8%97%A4&tags=%E6%96%B0%E7%9D%80&tags=%E3%81%8A%E3%81%99%E3%81%99%E3%82%81
[urlenc] 200 {"contentType":"application/x-www-form-urlencoded;charset=UTF-8","body":{"name":"佐藤","tags":["新着","おすすめ"]}}

ファイルを含む FormData を URLSearchParams に変換すると、ファイルは送られず文字列になってしまいます。

q=PC&file=%5Bobject+File%5D
項目 FormData URLSearchParams
Content-Type(自動) multipart/form-data; boundary=... application/x-www-form-urlencoded;charset=UTF-8
ファイル 送れる 送れない([object File] という文字になる)
本文の形 境界線で区切った複数パート a=1&b=2
受信(Express) multer などが必要 express.urlencoded()
受信(PHP) $_POST / $_FILES $_POST
GET のクエリに使う 不可 url + '?' + params で使える
向いている場面 ファイルを含む送信 検索条件など文字だけの送信

受信側:PHPで$_POSTと$_FILESを受け取る

PHP は multipart/form-data を自動で解析し、文字は $_POST、ファイルは $_FILES に入れてくれます。 複数の値を配列で受け取るには、name の末尾に [] を付けるのが PHP のルールです。

<?php
// upload.php(PHP 8.4)
header('Content-Type: application/json; charset=utf-8');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['error' => 'POST only']);
    exit;
}

$saved = [];
// photos[] で送られた複数ファイルは、name・tmp_name などが配列になる
$photos = $_FILES['photos'] ?? null;
if ($photos && is_array($photos['name'])) {
    foreach ($photos['name'] as $i => $originalName) {
        if ($photos['error'][$i] !== UPLOAD_ERR_OK) {
            $saved[] = ['name' => $originalName, 'error' => $photos['error'][$i]];
            continue;
        }
        $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
        $dest = __DIR__ . '/uploads/' . bin2hex(random_bytes(8)) . '.' . $ext;
        move_uploaded_file($photos['tmp_name'][$i], $dest);
        $saved[] = ['name' => $originalName, 'size' => $photos['size'][$i], 'type' => $photos['type'][$i]];
    }
}

echo json_encode([
    'content_type' => $_SERVER['CONTENT_TYPE'] ?? '',
    'post'  => $_POST,      // 文字の項目(tags[] は配列になる)
    'files' => $saved,
], JSON_UNESCAPED_UNICODE);
mkdir -p uploads
php -S 127.0.0.1:7201

送信側(Node.js):

// client_php.mjs
const url = 'http://127.0.0.1:7201/upload.php';
const fd = new FormData();
fd.append('title', '備品の写真');
fd.append('tags[]', '新着');
fd.append('tags[]', 'おすすめ');
fd.append('photos[]', new Blob(['desk'], { type: 'image/jpeg' }), 'desk.jpg');
fd.append('photos[]', new Blob(['chair!'], { type: 'image/jpeg' }), 'chair.jpg');
let res = await fetch(url, { method: 'POST', body: fd });
console.log('[PHP OK]', res.status, await res.text());

const fd2 = new FormData();
fd2.append('tags', '新着');
fd2.append('tags', 'おすすめ');
res = await fetch(url, { method: 'POST', body: fd2 });
console.log('[[] なし]', await res.text());

res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'multipart/form-data' }, body: fd });
console.log('[PHP 手動CT]', res.status, await res.text());

実行結果(PHP 8.4.21):

[PHP OK] 200 {"content_type":"multipart\/form-data; boundary=----formdata-undici-083595773420","post":{"title":"備品の写真","tags":["新着","おすすめ"]},"files":[{"name":"desk.jpg","size":4,"type":"image\/jpeg"},{"name":"chair.jpg","size":6,"type":"image\/jpeg"}]}
[[] なし] {"content_type":"multipart\/form-data; boundary=----formdata-undici-054972901550","post":{"tags":"おすすめ"},"files":[]}
[PHP 手動CT] 200 {"content_type":"multipart\/form-data","post":[],"files":[]}

php -S のログには次の警告が出ていました。

PHP Warning:  PHP Request Startup: Missing boundary in multipart/form-data POST data in Unknown on line 0

読み取れること

  • tags[] のように [] を付けると、PHP では配列で受け取れます。 [] なしだと最後の「おすすめ」だけになります(Express + multer は [] なしでも配列にまとめてくれました)。
  • Content-Type を手で付けると、PHP ではエラーにならずに $_POST も $_FILES も空になります。 ステータスは200のまま空のデータが届くので、気づきにくい点に注意しましょう。
  • アップロードできる大きさは php.ini の upload_max_filesize と post_max_size で決まります。
送る名前 Express + multer PHP
tags を2回 ["新着","おすすめ"](配列) "おすすめ"(最後の1つ)
tags[] を2回 tags という名前で ["新着","おすすめ"](配列) ["新着","おすすめ"](配列)
ファイル photos を複数 upload.array('photos') で req.files photos[] にすると $_FILES['photos']['name'][0] …

multer に tags[] を2回送ったときの実行結果は {"body":{"tags":["新着","おすすめ"]}} でした。name[] 形式で送れば、Express + multer と PHP のどちらでも同じ配列で受け取れるので、両方に対応したい画面では [] 付きの名前にそろえておくと便利です。

よくあるつまずきと直し方

症状 原因 直し方
Multipart: Boundary not found(multer) Content-Type を手で付けた headers から Content-Type を消す
PHP で $_POST / $_FILES が空 同上(ログに Missing boundary) 同上
req.body が空(Express) multipart を解析するミドルウェアがない upload.none() などの multer を付ける
LIMIT_UNEXPECTED_FILE multer で指定していない名前、または maxCount 超え fields / array の名前と数を合わせる
LIMIT_FILE_SIZE サイズ上限超え limits.fileSize を見直す、画面で事前に確認
チェックボックスの値が1つしか届かない get や Object.fromEntries を使った/PHP で [] なし getAll、PHP は name="tags[]"
disabled の欄が送られない disabled は送信対象外 readonly にする
押したボタンの値が入らない new FormData(form) だけ new FormData(form, event.submitter)
fetch(form.action) が404 name="action" の入力欄が action を隠している name を変える、form.getAttribute('action')
ファイルが [object File] という文字で届く URLSearchParams に変換した FormData のまま送る
console.log(fd) で中身が見えない FormData は中身を表示しない console.log([...fd])

よくある質問

Q. FormData を送るとき Content-Type はどう書けばよいですか? A. 何も書かないのが正解です。 fetch や XMLHttpRequest が boundary 付きの multipart/form-data; boundary=... を自動で設定します。手で書くと boundary が抜け、Express + multer では Multipart: Boundary not found、PHP では $_POST と $_FILES が空になることを、この記事で確認しました。

Q. FormData の中身を確認するには? A. console.log([...fd]) か for (const [k, v] of fd) console.log(k, v) で確認できます。同じ名前の値がいくつあるかは fd.getAll('名前') で調べられます。

Q. FormData と JSON、どちらで送るべきですか? A. ファイルを含むなら FormData、文字だけで入れ子のデータ構造を送るなら JSON が目安です。FormData は値がすべて文字列になるため、数値や true/false、入れ子のオブジェクトをそのまま送りたいときは JSON が向いています。

Q. アップロードの進捗を fetch で取れますか? A. 送信側の進捗を取るイベントは fetch にはありません。 進捗バーが必要なときは XMLHttpRequest の xhr.upload.addEventListener('progress', ...) を使います。

まとめ

  • FormData は名前と値の組を集めて multipart/form-data で送るオブジェクト。append(追加)と set(上書き)、get(最初の1つ)と getAll(全部)を使い分ける
  • fetch で送るときは Content-Type を書かない。手で書くと boundary が抜け、multer は400、PHP は空のデータになる
  • ファイルは append(名前, File/Blob, ファイル名)。同じ名前で複数回 append すれば複数ファイル
  • new FormData(form, event.submitter) で押したボタンの値も含められる。disabled の欄と外れたチェックボックスは入らない
  • 進捗バーは XMLHttpRequest、JSON 化は getAll で配列を残す
  • 受信側は Express なら multer、PHP なら $_POST / $_FILES。PHP で配列にするなら name[]
  • 文字だけなら URLSearchParams も選べる。ファイルは送れない

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「FormDataの使い方|fetchで送信・ファイルアップロードをコピペ解説」

株式会社RJCでは、FormDataを使ったデータ送信の仕組みを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?