application/x-www-form-urlencoded は、キーと値を = と & でつなぎ、空白を +、英数字と *-._ 以外の文字を、既定では UTF-8 のバイト列として %XX に置き換えて送る Content-Type です。HTML フォームを JavaScript なしで送信したときの既定値でもあります。application/json は数値・真偽値・null・配列・オブジェクトの型を保ったまま送れる形式で、API 通信の標準になっています。この記事では、両者の書式を仕様に沿って説明したうえで、同じデータを両方の形式で送ったときに何が変わるかを Node.js・Express・Axios・PHP で実際に動かした結果で比べ、どちらを選ぶべきかの判断基準を示します。
まとめ:form-urlencodedとJSONの違いと選び方
form-urlencoded は文字列のキーと値の並びしか表せず、数値も真偽値も文字列で届きます。JSON は型とネストをそのまま運べます。
| 比較項目 | application/x-www-form-urlencoded | application/json |
|---|---|---|
| 本文の例 | name=taro&age=20 |
{"name":"taro","age":20} |
| 値の型 | 文字列のみ | 文字列・数値・真偽値・null・配列・オブジェクト |
| ネスト・配列 | 仕様なし(括弧記法は慣習) | 仕様で定義 |
| HTMLフォームの既定 | 既定値 | 送れない(JavaScriptが必要) |
| CORSのプリフライト | 条件を満たせば不要 | 別オリジンでは原則必要 |
| 主な用途 | HTMLフォーム・OAuth 2.0のトークン取得 | REST API・SPAからの通信 |
選び方は3行で済みます。自分で設計する API は JSON にする。HTML フォームの素の送信と OAuth 2.0 のトークンエンドポイントのように仕様が form-urlencoded を指定している相手には form-urlencoded で送る。ファイルを含むなら multipart/form-data を使う。
application/x-www-form-urlencodedの書式とエンコード規則
キーと値を=と&でつなぐ本文の形
form-urlencoded の本文は、URL のクエリ文字列と同じ見た目をしています。違いは置き場所で、GET ではこの文字列が URL の ? 以降に付き、POST ではリクエスト本文に入ります。
POST /login HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 50
user=taro&q=a+b%26c&jp=%E3%83%86%E3%82%B9%E3%83%88
書式を定めているのは WHATWG の URL Standard です。キーと値を = で結び、組同士を & で区切ります。同じキーを複数回書くことは許されていて、受信側がそれを配列として扱うかどうかは実装が決めます。
空白は+、記号と日本語はUTF-8の%XX
エンコード規則は URL Standard の application/x-www-form-urlencoded シリアライザで決まっています。既定では文字列をまず UTF-8 のバイト列にし、英数字と *-._ 以外のバイトを %XX にし、空白(0x20)だけは + にします。Node.js 26.5.0 の URLSearchParams で確かめると次のようになりました。
new URLSearchParams({ q: 'a b&c=d', jp: 'テスト', sym: "*-._~!'()" }).toString()
// q=a+b%26c%3Dd&jp=%E3%83%86%E3%82%B9%E3%83%88&sym=*-._%7E%21%27%28%29
encodeURIComponent('a b&c=d') // a%20b%26c%3Dd
encodeURIComponent("*-._~!'()") // *-._~!'()
ここに落とし穴が2つあります。1つ目は、encodeURIComponent が form-urlencoded のエンコーダではないことです。空白を %20 にし、~!'() をそのまま残します。受信側の多くはどちらも同じ値に戻せますが、本文の文字列そのものを比較するテストや、受信した生の本文でハッシュを計算する処理では別物として扱われます。form-urlencoded の本文は URLSearchParams で組み立てるのが確実です。
2つ目はデコード側です。new URLSearchParams('q=a+b%2Bc').get('q') は a b+c を返し、+ を空白に戻します。一方 decodeURIComponent('a+b') は a+b のままです。form-urlencoded の本文を decodeURIComponent で自前分解すると、空白が + のまま残ります。値に + そのもの(電話番号の国番号など)を入れたいときは、送信側で %2B にする必要があります。
HTMLフォームの既定値とmultipart/form-data・text/plain
HTML Standard では、<form> の enctype 属性が取れる値は application/x-www-form-urlencoded・multipart/form-data・text/plain の3つで、省略時と不正な値のときの既定は application/x-www-form-urlencoded です。application/json は含まれていないため、フォームから JSON を送るには JavaScript で fetch を書く必要があります。
form-urlencoded はファイルの中身を送れません。<input type="file"> を含むフォームは enctype="multipart/form-data" を指定します。multipart は項目ごとに境界文字列(boundary)で区切り、バイナリをそのまま載せます。Node.js の fetch に FormData を渡すと、Content-Type は multipart/form-data; boundary=----formdata-undici-… になりました。text/plain は仕様上エンコードが曖昧で、機械で読み戻す用途には使いません。
application/jsonの書式とデータ型
JSONが持つ6つの型とネスト
application/json は RFC 8259 が定めるテキスト形式で、値は文字列・数値・真偽値(true/false)・null・配列・オブジェクトのいずれかです。配列とオブジェクトは入れ子にできるので、注文と明細、ユーザーと住所のような階層をそのまま表せます。
{
"name": "taro",
"age": 20,
"active": true,
"tags": ["a", "b"],
"addr": { "city": "Tokyo" },
"note": null
}
数値の精度には注意が要ります。RFC 8259 は数値の範囲を実装に委ねており、JavaScript の JSON.parse は IEEE 754 の倍精度で読むため、絶対値が253−1を超える整数 ID は、値によって丸めが発生し、正確な保持を保証できません。Node.js 26.5.0 では JSON.parse('9007199254740993') が 9007199254740992 を返しました。大きな ID は文字列で送るのが安全です。型の詳細と TypeScript での扱いはJSONの型定義とデータ型一覧で解説しています。コメントを書けない点についてはJSONのコメントアウトとJSONC・JSON5を参照してください。
charsetパラメータとapplication/*+json
RFC 8259 は、閉じたエコシステムの外で交換する JSON に UTF-8 を要求しており、application/json の登録に charset パラメータは定義されていません。application/json; charset=utf-8 と付けても準拠した受信側には効果がありません。付いていても害はないので、既存の API が付けていれば外す必要もありません。
application/ld+json(JSON-LD)や application/problem+json(RFC 9457 のエラー応答)のように、+json で終わる型は「中身は JSON で、その上に独自の意味づけがある」ことを示します。受信側のパーサーが application/json と完全一致でしか判定していないと、これらを読み飛ばします。Express の express.json() も既定の type は application/json なので、+json を受けるなら type オプションで追加します。
同じデータを両形式で送った結果の比較
型とネストの違いは、同じオブジェクトを両形式で送ると一目で分かります。Axios 1.20.0 から Express 5.2.1 のサーバーへ、{ name: 'taro', age: 20, flag: true, n: null, tags: ['a','b'], addr: { city: 'Tokyo' } } を送った結果です。
| 値 | JSONで届いた値 | form-urlencodedの本文 | Express 5の既定で届いた値 |
|---|---|---|---|
| age: 20 | 20(数値) | age=20 |
“20”(文字列) |
| flag: true | true(真偽値) | flag=true |
“true”(文字列) |
| n: null | null | 項目ごと消える | キー自体が無い |
| tags: [‘a’,’b’] | [“a”,”b”] | tags%5B%5D=a&tags%5B%5D=b |
“tags[]”: [“a”,”b”] |
| addr.city | {“city”:”Tokyo”} | addr%5Bcity%5D=Tokyo |
“addr[city]”: “Tokyo” |
form-urlencoded 側で起きていることは3つです。数値と真偽値は文字列になり、受信側で Number() や === 'true' の変換を書かない限り "20" + 1 が "201" になります。null は Axios のシリアライザが項目ごと落とすため、「値を null で上書きする」更新 API を form-urlencoded で作ると、未送信と null の区別がつきません。配列とネストは [] を使った括弧記法になりますが、これは PHP や qs ライブラリ由来の慣習で、URL Standard には定義がありません。受信側が括弧記法を解釈しなければ、"tags[]" という名前のキーとして届きます。
この表の「Express 5の既定」の列がまさにその状態です。ネストを送る必要が出た時点で JSON に切り替えるのが、変換コードを両端に書き続けるより確実です。
送信側の書き方:fetch・Axios・curl・Python
fetchのbody型とContent-Typeの自動設定
ブラウザと Node.js の fetch は、body に渡した値の種類から、Content-Typeが未指定の場合にその値を自動設定します。Node.js 26.5.0 で実測した値は次のとおりです。
// URLSearchParams → application/x-www-form-urlencoded;charset=UTF-8
await fetch(url, { method: 'POST', body: new URLSearchParams({ a: '1' }) });
// 文字列 → text/plain;charset=UTF-8(JSON文字列でも application/json にはならない)
await fetch(url, { method: 'POST', body: JSON.stringify({ a: 1 }) });
// JSONとして送るならヘッダーを明示する
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ a: 1 }),
});
2つ目のパターンはよくある不具合の原因です。ヘッダーを付け忘れると text/plain で届き、Express 5 の express.json() は処理を飛ばすため req.body は undefined になります。サーバーのコードを疑う前に、ブラウザの開発者ツールで実際の Content-Type を確かめてください。通信をそのまま再現する手順はChrome検証ツールのCopy as cURLにまとめています。
Axiosの入力値・ヘッダーと送信形式の切り替え
Axios 1.20.0 は、プレーンなオブジェクトを渡すと JSON に変換して application/json で送ります。URLSearchParams を渡すと application/x-www-form-urlencoded;charset=utf-8 で送ります。
import axios from 'axios';
// JSONで送る(既定)
await axios.post('/api/users', { name: 'taro', age: 20 });
// form-urlencodedで送る(方法1:URLSearchParams)
await axios.post('/login', new URLSearchParams({ user: 'taro', pass: 'secret' }));
// form-urlencodedで送る(方法2:ヘッダー指定でオブジェクトを自動変換)
await axios.post('/login', { user: 'taro', pass: 'secret' }, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
方法2は手軽ですが、前章の表のとおり配列は tags[]=、ネストは addr[city]= に変換され、null は消えます。送り先が括弧記法を読めるか分からないときは、方法1でフラットなキーだけを組み立てる方が結果を予測できます。
curlとPythonのrequestsでの指定
curl 8.7.1 の -d は Content-Type を application/x-www-form-urlencoded にしますが、値をエンコードしません。-d 'q=a b' は空白が生のまま q=a b として届きました。値に空白や & を含むなら --data-urlencode を使います。JSON は --json(curl 7.82.0 以降)で、Content-Type と Accept の両方が application/json になります。
curl -d 'name=taro' https://example.com/login
curl --data-urlencode 'q=a b&c' https://example.com/search # q=a+b%26c で届く
curl --json '{"name":"taro"}' https://example.com/api/users
Python の requests 2.32.5 では、data= に辞書を渡すと form-urlencoded、json= に渡すと application/json になります。リストは tags=a&tags=b と同じキーの繰り返しで送られ、括弧は付きません。標準ライブラリの urllib.parse.urlencode でリストを送るときは doseq=True が必要で、付け忘れると tags=%5B%27a%27%2C+%27b%27%5D(Python のリスト表記の文字列)が1つの値として届きます。
import requests
requests.post(url, data={'q': 'a b', 'tags': ['a', 'b']}) # q=a+b&tags=a&tags=b
requests.post(url, json={'q': 'a b', 'n': None}) # {"q": "a b", "n": null}
GUI で試すなら、Postman の Body タブで x-www-form-urlencoded と raw(JSON)を切り替えます(Postmanとは?APIテスト自動化とNewmanでのCI組み込み)。
受信側の読み方:Express・PHP
Express 5のexpress.urlencodedとextendedの既定値
Express では、形式ごとに別のミドルウェアが本文を読みます。express.urlencoded() が form-urlencoded、express.json() が JSON です。どちらも Content-Type が一致しないリクエストは素通りさせ、Express 5 ではその場合 req.body が undefined のままです。
const express = require('express');
const app = express();
app.use(express.urlencoded()); // Express 5 の既定は extended: false
app.use(express.json());
app.post('/echo', (req, res) => res.json(req.body));
app.listen(3000, '127.0.0.1');
Express 4 から移行するときに影響するのが extended の既定値です。Express 5(body-parser 2 系)では false になり、Node.js の querystring 相当で解析します。user[name]=taro&tags[]=a&tags[]=b を送ると、既定では {"user[name]":"taro","tags[]":["a","b"]} と括弧がキー名に残りました。extended: true にすると qs ライブラリで解析され、{"user":{"name":"taro"},"tags":["a","b"]} とネストに戻ります。Express 5 への移行全体はExpress.jsとは?Express 5対応のバックエンド入門で解説しています。
extended: true にはネストの深さの上限があり、body-parser 2.3.0 の既定は 32 段です。33 段のキーを送ると 400 エラー「The input exceeded the depth」で拒否されました。深い構造を form-urlencoded で受ける設計そのものに無理があるので、ネストを受ける API は JSON で受けてスキーマで検証する方が扱いやすくなります。
PHPの$_POSTの対応形式とJSON本文の読み取り
PHP の $_POST が埋まるのは、Content-Type が application/x-www-form-urlencoded か multipart/form-data のときだけです。PHP 8.5.8 の組み込みサーバーで確かめると、form-urlencoded の b[]=x&b[]=y&c[d]=e は $_POST で配列とネストに展開され、JSON を送ると $_POST は空配列でした。JSON は本文を直接読みます。
<?php
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
exit;
}
フロントエンドを Axios に替えた途端に $_POST が空になるのは、Axios の既定が JSON だからです。
仕様で形式が決まっている場面:CORSとOAuth 2.0
application/jsonのCORSプリフライト条件
ブラウザから別オリジンへ POST するとき、Fetch Standard は Content-Type が application/x-www-form-urlencoded・multipart/form-data・text/plain のいずれかで、ほかのヘッダーもセーフリストの条件(値の長さや使える文字など)に収まる場合に限り、プリフライトなしで本送信します。form-urlencoded でも Authorization ヘッダーを付ければプリフライトの対象です。
application/json はこの3つに入らないため、別オリジンへ送ると、ブラウザは有効なプリフライトのキャッシュがない限り、本送信の前に OPTIONS メソッドで許可を確認します。サーバーは Access-Control-Allow-Headers で Content-Type を許可する必要があります(資格情報を送らないリクエストなら * でも可)。
この違いには2つの意味があります。1つは性能で、JSON の API はキャッシュされるまでリクエストが2往復になります。Access-Control-Max-Age でプリフライト結果のキャッシュ時間を延ばせます。もう1つは安全性で、form-urlencoded はプリフライトなしで別サイトから送れるため、Cookie 認証の API が form-urlencoded を受け付けると、SameSite 属性や CSRF トークンで塞いでいない限り CSRF の攻撃面になります。「JSON しか受け付けない」ことが結果的に防御になっている API で、互換性のために form-urlencoded も受けるよう変えると、その防御が外れます。プリフライトの仕組みとサーバー設定はCORSとは?仕組み・プリフライト・サーバー設定例で詳しく解説しています。
OAuth 2.0のトークンエンドポイントはform-urlencoded必須
OAuth 2.0(RFC 6749 4.1.3節)は、クライアントがトークンエンドポイントへ送るパラメータを application/x-www-form-urlencoded 形式・UTF-8 で送ると定めています。認可コードをアクセストークンに交換するリクエストを JSON で送ると、RFC 6749が定めるリクエスト形式から外れるため、受け付けられる保証はありません。具体的なエラー応答やJSON対応の有無は、認可サーバーの実装によります。
await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://app.example.com/callback',
client_id: 'my-client',
}),
});
上のコードは送信形式を示す例です。クライアント認証が必要な場合は認可サーバー指定の認証方法を追加し、PKCEを使用した認可要求に対応する場合はcode_verifierも送ります。応答は application/json で返ってきます。送信は form-urlencoded、応答は JSON という非対称は仕様どおりの動作です。
使い分けの判断基準と失敗パターン
新しく API を設計するなら、JSON を選んで問題はありません。form-urlencoded を選ぶ理由があるのは、次のように相手や環境が形式を決めている場合に限られます。
- JavaScript を使わない HTML フォームの送信先(既定の enctype)
- OAuth 2.0 のトークンエンドポイントなど、仕様が form-urlencoded を指定している API
- Webhook の送信元や古い決済・SMS ゲートウェイなど、送信形式を変えられない外部サービスからの受信
- 本文を
$_POSTで読む前提の既存 PHP アプリ
逆に、form-urlencoded を選ぶべきでない場面もはっきりしています。値に数値と null の区別が必要な更新 API、配列やネストを持つデータ、ファイルを含む送信の3つです。最初の2つは JSON、ファイルは multipart/form-data を使います。「フォームから送るから form-urlencoded」と決めてしまい、後から明細行や添付ファイルが増えて括弧記法の変換と別エンドポイントが積み上がるのが、典型的な失敗パターンです。
両方を受ける API を作る場合は、Content-Type ごとに受け口を分け、型の変換とバリデーションを1か所に集めます。form-urlencoded で届いた “20” を数値に直す処理を各ハンドラに散らすと、変換漏れがそのまま不具合になります。
よくある質問
application/x-www-form-urlencodedとは何ですか?
リクエスト本文を key=value&key2=value2 の形で送る Content-Type です。空白は +、英数字と *-._ 以外の文字は、既定では UTF-8 のバイト列を %XX にして表します。HTML フォームの enctype の既定値です。
x-www-form-urlencodedとJSONの違いは何ですか?
form-urlencoded は文字列のキーと値しか表せず、ネストや配列の仕様もありません。JSON は数値・真偽値・null・配列・オブジェクトを型のまま送れます。また、ブラウザから別オリジンへContent-Typeをapplication/jsonとして送る場合はプリフライトの対象になりますが、適合するプリフライトキャッシュがあればOPTIONSの再送は省略されます。
application/jsonとは何ですか?
JSON(RFC 8259)で書かれたデータであることを示す Content-Type です。閉じたエコシステムの外で交換するJSONには UTF-8 が要求され、application/jsonのcharset パラメータは定義されていません。
Content-Typeにapplication/x-www-form-urlencodedを指定するにはどうしますか?
fetch と Axios では、本文に URLSearchParams を渡せば自動で設定されます。curl は -d か --data-urlencode、Python の requests は data= に辞書を渡すと設定されます。ヘッダーを手で書く場合は Content-Type: application/x-www-form-urlencoded です。
form-urlencodedのデータをJSONに変換するには?
JavaScript では Object.fromEntries(new URLSearchParams(body)) でオブジェクトにし、JSON.stringify で JSON にできます。ただし同じキーが複数ある場合は最後の値しか残らず、値はすべて文字列のままです。配列や数値に戻すには、キーごとに getAll() や Number() で変換する処理が必要です。