Repository navigation
File upload
Summary: StartER handles multipart file uploads through multer, configured by a reusable factory. Uploaded files live outside the document root and are served through Express static middleware. CSRF protection and contract tests apply to upload routes without special casing.
What you'll learn:
- Add a file upload route to an Express module
- Use
createUploader()anddeleteUploadedFile()from the upload helper - Integrate file uploads in the React frontend via
MeContext - Write contract tests for multipart requests
An upload request goes through five steps:
-
Client submission: the React frontend sends a
POSTrequest with aFormDatabody and anX-CSRF-Tokenheader. -
Multipart parsing: the request passes through
multermiddleware, which validates the MIME type, enforces the size limit, and writes the file to disk with a UUID filename. -
Action handler: the action reads
req.file.filename, removes any previous file, and persists the new relative URL to the database. -
JSON response: the endpoint returns
201 Createdwith the new file URL. -
Static serving: Express resolves
/uploads/...requests against./data/uploads/viaexpress.static().
| Method | Route | Status | Body |
|---|---|---|---|
POST |
/api/users/me/avatar |
201 |
{ avatar_url: string } |
DELETE |
/api/users/me/avatar |
204 |
(empty) |
Accepted types: image/jpeg, image/png, image/webp, image/gif. Maximum size: 2 MB.
createUploader(options) from src/express/helpers/upload.ts returns a configured multer.Multer instance ready to use with Express. It follows the same factory pattern as createValidator() and createParamConverter().
import { createUploader } from "../../helpers/upload";
const avatarUploader = createUploader({
subfolder: "avatars", // required: sub-folder under ./data/uploads/
maxSizeBytes: 2 * 1024 * 1024, // optional, this is the default value (2 MB)
allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/gif"], // optional, this is the default value
});deleteUploadedFile(relativeUrl?: string | null) removes a file from disk. It only acts on URLs starting with /uploads/ and does nothing if the file is absent, so it is always safe to call even when no file has been uploaded yet:
import { deleteUploadedFile } from "../../helpers/upload";
deleteUploadedFile(req.me.avatar_url); // safe when avatar_url is nullSee src/express/helpers/upload.ts and multer documentation.
The uploader is instantiated in the route file. It is an HTTP infrastructure concern rather than a validation rule, so it belongs alongside the route definitions, not in the validator:
import { createUploader } from "../../helpers/upload";
const avatarUploader = createUploader({
subfolder: "avatars",
maxSizeBytes: 2 * 1024 * 1024,
});
router
.route("/api/users/me/avatar")
.all(authActions.verifyAccessToken)
.post(avatarUploader.single("avatar"), userActions.uploadMeAvatar)
.delete(userActions.deleteMeAvatar);The action handlers delegate file deletion to deleteUploadedFile and URL persistence to the repository:
const uploadMeAvatar: RequestHandler = (req, res) => {
if (!req.file) {
res.status(400).json({ message: "No file attached" });
return;
}
const oldAvatarUrl = req.me.avatar_url;
const newAvatarUrl = `/uploads/avatars/${req.file.filename}`;
userRepository.updateAvatar(req.me.id, newAvatarUrl);
deleteUploadedFile(oldAvatarUrl);
res.status(201).json({ avatar_url: newAvatarUrl });
};
const deleteMeAvatar: RequestHandler = (req, res) => {
const oldAvatarUrl = req.me.avatar_url;
userRepository.updateAvatar(req.me.id, null);
deleteUploadedFile(oldAvatarUrl);
res.sendStatus(204);
};Important
The string passed to single() is the expected multipart field name. It must match the key used in formData.append() on the client and the name attribute on the file input. A mismatch silently leaves req.file undefined without throwing an error.
Note
CSRF tokens are sent in the X-CSRF-Token header by mutate(), not in the form body. The CSRF middleware validates the header before multer processes the multipart body, so upload routes require no special CSRF handling.
Uploaded files are served from server.ts:
app.use("/uploads", express.static("./data/uploads"));A request to /uploads/avatars/550e8400-e29b-...webp maps to the local file ./data/uploads/avatars/550e8400-e29b-...webp.
Original browser-supplied filenames are never used. Characters like ../ in a filename could overwrite arbitrary files on disk. crypto.randomUUID() produces unique, unpredictable filenames that cannot escape the upload directory.
multer.diskStorage() strips extensions by default. Without an extension, express.static() cannot determine the MIME type and falls back to application/octet-stream. With X-Content-Type-Options: nosniff set by Helmet, browsers refuse to display images sent with that type. The MIME_TO_EXTENSION map ensures each stored file has the correct extension.
File extensions are trivially renamed. The fileFilter callback verifies the mimetype field reported during multipart parsing against an explicit allowlist. Rejected files are never written to disk.
Note
file.mimetype comes from the Content-Type attribute declared in the multipart section by the HTTP client, not from inspecting the file's actual bytes. A crafted request can declare any MIME type. For the current setup (Node.js static serving with X-Content-Type-Options: nosniff), the practical risk is low: stored files are served with extension-based types and browsers will not re-interpret them. If you add file types that carry execution risk when rendered inline (SVG, HTML, PDF), magic bytes validation via a library such as file-type becomes necessary.
Files go to ./data/uploads/ (excluded from Git) rather than inside src/. This prevents any uploaded file from being loaded as application code.
MeContext.tsx exposes updateMeAvatar(fileOrNull). Passing a File uploads a new avatar; passing null deletes it.
AvatarUploadForm validates the selected file on the client using Zod before attempting any network request, giving immediate inline feedback:
import { useId, useState } from "react";
import { z } from "zod";
import type { $ZodIssue as ZodIssue } from "zod/v4/core";
import { FormError, hasError } from "../FormError";
import { useMe } from "./MeContext";
const schema = z.object({
avatar: z
.file()
.min(1, "Image is required")
.max(2_000_000, "Image is too heavy")
.mime(
["image/jpeg", "image/png", "image/webp", "image/gif"],
"Invalid file type",
),
});
function AvatarUploadForm() {
const { user, updateMeAvatar } = useMe();
const fileInputId = useId();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [errors, setErrors] = useState<ZodIssue[]>([]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const parsed = schema.safeParse({ avatar: e.target.files?.[0] });
if (!parsed.success) {
setErrors(parsed.error.issues);
return;
}
setErrors([]);
setSelectedFile(parsed.data.avatar);
};
const handleUploadAction = async () => {
await updateMeAvatar(selectedFile);
setSelectedFile(null);
};
const handleDeleteAction = async () => {
await updateMeAvatar(null);
};
return (
<form action={handleUploadAction}>
<FormError
issues={errors}
name="avatar"
id={`${fileInputId}-error`}
aria-invalid={hasError(errors, "avatar")}
/>
<label htmlFor={fileInputId}>Choose a new image</label>
<input
id={fileInputId}
name="avatar"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={handleFileChange}
/>
<button type="submit" disabled={!selectedFile}>
Save Avatar
</button>
<button
type="submit"
formAction={handleDeleteAction}
disabled={!user?.avatar_url || selectedFile != null}
>
Remove Avatar
</button>
</form>
);
}mutate() in src/react/helpers/mutate.ts detects FormData payloads automatically. It omits Content-Type so the browser can set the multipart boundary, while still attaching the required X-CSRF-Token header.
Upload routes use the same contract system as all other endpoints. See Contracts and verification for the testing guide.
The attach field maps to supertest's .attach() method. dummyImageBuffer is a Buffer holding a minimal base64-encoded WEBP, declared once at the top of the contract file:
const dummyImageBuffer = Buffer.from(
"UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=",
"base64",
);upload_me_avatar: {
method: "post",
path: "/api/users/me/avatar",
cases: {
as_me: {
request: {
jwtPayload: { sub: standardUser.id },
attach: {
name: "avatar",
file: dummyImageBuffer,
options: { filename: "avatar.webp", contentType: "image/webp" },
},
},
response: {
status: 201,
body: {
avatar_url: expect.stringMatching(/^\/uploads\/avatars\/.*\.webp$/),
},
},
},
invalid_file_type: {
request: {
jwtPayload: { sub: standardUser.id },
attach: {
name: "avatar",
file: Buffer.from("plain text"),
options: { filename: "doc.txt", contentType: "text/plain" },
},
},
response: {
status: 400,
body: { message: expect.stringMatching(/Invalid file type/i) },
},
},
no_attached_file: {
request: { jwtPayload: { sub: standardUser.id } },
response: {
status: 400,
body: { message: expect.stringMatching(/No file attached/i) },
},
},
unauthorized: {
request: { jwtPayload: null },
response: { status: 401, body: {} },
},
},
},
delete_me_avatar: {
method: "delete",
path: "/api/users/me/avatar",
cases: {
as_me: {
request: { jwtPayload: { sub: standardUser.id } },
response: { status: 204, body: {} },
},
unauthorized: {
request: { jwtPayload: null },
response: { status: 401, body: {} },
},
},
},AI co-creation
Getting started
Explanations
How-To Guides
Reference
Digging deeper