Recently I had a chance to handle form submissions using a Firebase Functions + Remix (Express adapter) setup. But I hit a wall where await request.formData() and await request.json() always returned an empty object.
In this article, I'll focus on:
- Where I got stuck
- Why it happens
- How I solved it
This is meant as a memo of the sticking points and the solution, so I don't fall into the same pit next time.
1. What Was Happening – The Symptoms
Submitting the form only ever produced null / {} on the action side.
Looking at the Cloud Functions logs, req.body clearly had the correct value.
In local development (the Remix dev server), there was no issue at all.
In other words, the data was disappearing the moment it was deployed to Functions.
2. The Cause – Firebase's Body-Parser Reads the Stream First
Firebase Functions (HTTPS triggers) consume the request stream with their own body-parser as soon as the request comes in, and expand it into req.body.
The Remix-Express adapter, on the other hand, generates a Request assuming an IncomingMessage that hasn't been read yet — so it ends up wrapping a stream that's already been consumed, which results in an empty body.
The key issue was the Node.js principle that a stream can only be read once.
3. Solution #1 – Pass the Body Through Context (Minimal Setup)
This approach passes the already-parsed req.body / req.rawBody to Remix via getLoadContext(). It requires zero additional dependencies and is the fastest to implement.
// 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; // Variable to initialize on the first request
async function getHandler() {
if (!handler) {
// Load the Remix build output on the first request
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);
Receiving it in action:
import type { ActionFunction } from "@remix-run/node";
export const action: ActionFunction = async ({ request, context }) => {
// Get POST data whether on Firebase Functions or another environment
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());
}
// --- Extract only the fields we need ---
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" },
});
};
Dealing with the type error (can't assign {} to Record<string, unknown>): Adding a type to AppLoadContext, or specifying a generic with createRequestHandler<MyContext>(), cleans this up nicely.
4. Solution #2 – Use a Dedicated Adapter (For Maintainability)
Packages like remix-google-cloud-functions internally re-stream req.rawBody, which lets you use request.formData() without changing any code on the Remix side.
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"),
}),
);
This adds a dependency, but it's the more convenient option if you'd rather lean on the adapter with long-term team maintenance in mind.
5. Tips for Extracting Data – Handling multipart/form-data
When you build it with Object.fromEntries(await request.formData()), the values will be either string or File. If file uploads are involved, decide in advance how you'll handle file handling. For APIs that need the raw body, such as Stripe webhooks, having context.rawBody available is reassuring.
In Closing
The cause was simply that Firebase Functions reads the body first.
The minimal fix is to pass req.body through context.
For long-term operation, a dedicated adapter makes stream regeneration much easier.
Type errors can be resolved by extending AppLoadContext or using a generic.
The Remix × Functions combination is lightweight and cost-effective, but as long as you watch out for the stream trap, it runs comfortably. I hope this article saves someone some time. Happy serverless coding!