最近、Firebase Functions + Remix (Express アダプタ) の構成でフォーム送信を扱う機会がありました。 ところが await request.formData() や await request.json() が 常に空オブジェクトを返す という壁に激突…。
本記事では、
- どこでハマったのか
- なぜ発生するのか
- どう解決したのか
を中心に、詰まりポイントと解決策 を紹介します。次回同じ沼にハマらないための備忘録としてどうぞ。
1. 何が起きていたのか – 症状編
フォームを送信しても action 側で null
/ {}
しか取れない
Cloud Functions のログを見ると req.body
にはちゃんと値が入っている
ローカル開発(Remix dev サーバー)では問題なし
つまり「Functions にデプロイした途端にデータが消える」状態でした。
2. 原因 – Firebase の body-parser が先にストリームを読む
Firebase Functions(HTTPS トリガー)ではリクエストを受け取ると同時に 独自の body-parser がストリームを消費して req.body に展開 します。
一方 Remix-Express アダプタは「まだ読まれていない IncomingMessage」を前提に Request を生成するため、既に読み終わったストリームをラップ → 空になる というわけです。
ポイントは “ストリームは一度しか読めない” という Node.js の原則でした。
3. 解決策その① – context に body を渡す(最小構成)
読み込まれた req.body/req.rawBody を getLoadContext() で Remix に渡してしまう 方法です。依存追加ゼロで最速。
// functions/index.js
import { createRequestHandler } from "@remix-run/express";
import express from "express";
import { onRequest } from "firebase-functions/v2/https";
const app = express();
app.use(express.static("build/client"));
let handler; // 初回リクエストで初期化するための変数
async function getHandler() {
if (!handler) {
// 初回リクエスト時に Remix ビルド成果物を読み込む
const viteBuild = await import("./server/index.js");
handler = createRequestHandler({
build: viteBuild,
mode: "production",
getLoadContext(req, res) {
return {
body: req.body ?? null, // JSON, urlencoded
rawBody: req.rawBody ?? null, // Buffer
headers: req.headers,
};
},
});
}
return handler;
}
app.all("*", async (req, res, next) => {
try {
const requestHandler = await getHandler();
requestHandler(req, res, next);
} catch (error) {
next(error);
}
});
export const serverFunction = onRequest({ region: "asia-northeast1" }, app);
action
での受け取り
import type { ActionFunction } from "@remix-run/node";
export const action: ActionFunction = async ({ request, context }) => {
// Firebase Functions or その他環境で POST データを取得
let data: Record<string, unknown>;
if (context.body) {
data = context.body; // JSON / urlencoded
} else if (context.rawBody) {
data = JSON.parse(context.rawBody.toString());
} else {
data = request.headers
.get("content-type")
?.includes("application/json")
? await request.json()
: Object.fromEntries(await request.formData());
}
// --- 必要なフィールドだけ抽出 ---
const { email, name } = data as Record<string, unknown>;
const toStr = (v: unknown) =>
typeof v === "string" ? v : v == null ? "" : String(v);
const payload = {
email: toStr(email),
name: toStr(name),
};
console.log(payload); // { email: "...", name: "..." }
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" },
});
};
型エラー({} を Record<string, unknown> に代入できない)の対処
AppLoadContext に型を追加するか、createRequestHandler
4. 解決策その② – 専用アダプタを使う(メンテ重視)
remix-google-cloud-functions パッケージなどは 内部で req.rawBody を再ストリーム化 してくれるため、Remix 側のコードを 一切変更せず に request.formData() が使えるようになります。
pnpm add remix-google-cloud-functions @google-cloud/functions-framework
import { createRequestHandler } from "remix-google-cloud-functions";
import functions from "firebase-functions/v2/https";
export const web = functions.onRequest(
createRequestHandler({
build: require("./build"),
}),
);
依存は増えますが「チームでの保守を考えるとアダプタに寄せたい」場合はこちらが便利です。
5. データ抽出 Tips – multipart/form-data の扱い
Object.fromEntries(await request.formData()) で作ると値は string か File になります。 ファイルアップロードが絡むケースでは File Handling をどうするか決めておきましょう。Stripe Webhook など 生のボディが必要 な API も context.rawBody があれば安心です。
最後に
原因は「Firebase Functions が body を先に読んでいる」だけ
最小解は context に req.body を渡す
長期運用なら 専用アダプタ でストリーム再生成がラク
型エラーは AppLoadContext 拡張 or ジェネリック で解決
Remix×Functions の組み合わせは軽量でリーズナブルですが、ストリームの罠に注意すれば快適に動かせます。この記事が誰かの時間を節約できれば幸いです。良いサーバーレスライフを!