Fastify 5 file uploads with @fastify/multipart: working versions (tested 2026-10-15)
Accept multipart file uploads in Fastify 5 with @fastify/multipart, reading fields and files, hashing content, rejecting oversized files with 413, tested with inject() and a native FormData body.
passed4 passed · 0 failed2026-10-15 17:10 UTC- Status
- passed
- Last passed
- 2026-10-15 17:10 UTC
- Tests
- 4 / 4
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38775053802
The code
Exactly the files that passed. Your agent can pull them with get_recipe("fastify-multipart-upload") (install), or see them on GitHub.
{
"name": "fastify-multipart-upload",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"fastify": "5.12.7",
"@fastify/multipart": "10.1.4"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15"
}
}
import Fastify from "fastify";
import multipart from "@fastify/multipart";
import { createHash } from "node:crypto";
export async function buildApp(maxBytes = 1024) {
const app = Fastify();
await app.register(multipart, { limits: { fileSize: maxBytes, files: 3 } });
app.post("/upload", async (req, reply) => {
const fields: Record<string, string> = {};
const files: Array<{ field: string; filename: string; mimetype: string; size: number; sha256: string }> = [];
for await (const part of req.parts()) {
if (part.type === "file") {
const buf = await part.toBuffer(); // throws RequestFileTooLargeError (413) past the limit
files.push({
field: part.fieldname,
filename: part.filename,
mimetype: part.mimetype,
size: buf.length,
sha256: createHash("sha256").update(buf).digest("hex"),
});
} else {
fields[part.fieldname] = String(part.value);
}
}
if (files.length === 0) return reply.code(400).send({ error: "no file" });
return reply.code(201).send({ fields, files });
});
await app.ready();
return app;
}
/** Encode a FormData with the platform's own multipart encoder (no extra deps). */
export async function encodeForm(form: FormData) {
const res = new Response(form);
return { payload: Buffer.from(await res.arrayBuffer()), headers: { "content-type": res.headers.get("content-type")! } };
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { buildApp, encodeForm } from "../src/app.ts";
test("uploads a file with fields", async () => {
const app = await buildApp();
const form = new FormData();
form.append("note", "quarterly report");
form.append("file", new Blob(["hello,world\n1,2\n"], { type: "text/csv" }), "data.csv");
const res = await app.inject({ method: "POST", url: "/upload", ...(await encodeForm(form)) });
assert.equal(res.statusCode, 201);
const body = res.json();
assert.deepEqual(body.fields, { note: "quarterly report" });
assert.equal(body.files.length, 1);
assert.equal(body.files[0].filename, "data.csv");
assert.equal(body.files[0].mimetype, "text/csv");
assert.equal(body.files[0].size, 16);
assert.equal(body.files[0].sha256, createHash("sha256").update("hello,world\n1,2\n").digest("hex"));
await app.close();
});
test("multiple files", async () => {
const app = await buildApp();
const form = new FormData();
form.append("a", new Blob(["A"]), "a.txt");
form.append("b", new Blob(["BB"]), "b.txt");
const res = await app.inject({ method: "POST", url: "/upload", ...(await encodeForm(form)) });
assert.equal(res.statusCode, 201);
assert.deepEqual(res.json().files.map((f: { size: number }) => f.size), [1, 2]);
await app.close();
});
test("oversized files are rejected with 413", async () => {
const app = await buildApp(10);
const form = new FormData();
form.append("file", new Blob(["x".repeat(50)]), "big.bin");
const res = await app.inject({ method: "POST", url: "/upload", ...(await encodeForm(form)) });
assert.equal(res.statusCode, 413);
await app.close();
});
test("non-multipart requests are refused", async () => {
const app = await buildApp();
const res = await app.inject({ method: "POST", url: "/upload", payload: { file: "nope" } });
assert.equal(res.statusCode, 406);
await app.close();
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Fastify 5 + @fastify/jwt protected routes
Sign and verify JWTs in Fastify 5 with @fastify/jwt and an authenticate preHandler that guards routes.
Fastify 5 + Zod 4 type provider for typed routes
Use fastify-type-provider-zod so Zod 4 schemas validate requests, serialize responses and type handlers in Fastify 5.
Fastify 5 OpenAPI docs generated with @fastify/swagger
Generate an OpenAPI 3 document from Fastify 5 route schemas with @fastify/swagger, using shared $ref schemas, tags and security, then serve and check it with inject().
Fastify 5 routes with JSON Schema validation
Fastify 5 route validation and response serialization with JSON Schema, tested via app.inject().