Safe File Uploads with Claude Code: FormData, Validation, Preview, and S3
Build safe SaaS file uploads with Claude Code: File API, FormData, fetch, validation, preview, progress, and S3.
File upload is one of those SaaS features that looks small until you ship it. A profile image, invoice PDF, CSV import, identity document, chat attachment, or customer report all start with the same friendly button: choose a file. Behind that button, however, you have browser APIs, request encoding, server validation, storage design, cost, permissions, preview behavior, progress feedback, and security review.
Claude Code can help a lot here. It can create the React component, the API route, the validation helper, the storage adapter, and the README in one pass. But the prompt needs boundaries. If you ask only for “file upload”, you may get a demo that works on your laptop but stores original filenames, trusts browser MIME types, skips size limits, exposes public URLs too early, or invents a progress bar that is not tied to real upload progress.
This guide builds the feature in a reviewable order. First we separate the browser pieces: the File API, FormData, and the Fetch API. Then we implement a minimal upload, add React preview and progress, validate on the server, and decide when to move from local storage to S3, Cloud Storage, Azure Blob Storage, or Cloudflare R2.
For the storage side, pair this guide with Claude Code and AWS S3. For the security review mindset, read Claude Code security best practices.
Separate the browser responsibilities first
A beginner-friendly way to think about file upload is to split it into three browser responsibilities.
The first responsibility is the File API. It lets the browser represent files selected by the user. When someone uses an <input type="file"> field or drops a file into a page, you can inspect a File object. The useful fields are file.name, file.size, file.type, and file.lastModified. For images, you can create a temporary preview URL with URL.createObjectURL(file).
The second responsibility is FormData. FormData is the container that lets you send fields and files as multipart/form-data. You add the file with formData.append("file", file) and pass that object as the request body. One practical detail matters: when you send FormData with fetch, do not manually set the Content-Type header. The browser adds the multipart boundary. Hard-coding the header can break the request.
The third responsibility is fetch. Fetch sends the HTTP request. For a normal upload, it is clean and enough:
const formData = new FormData();
formData.append("file", file);
await fetch("/api/upload", {
method: "POST",
body: formData
});
The caveat is progress. Browser fetch is excellent for the request itself, but upload progress events are still not as straightforward as XMLHttpRequest.upload.onprogress. If the product truly needs a progress bar, I still ask Claude Code to use XMLHttpRequest for the upload step and explain why. If the file is small and progress is not important, fetch keeps the code simpler.
Minimal HTML and fetch implementation
Start with the smallest version before bringing in React, S3, or a design system. This version validates type and size in the browser, previews images, and uploads the selected file with FormData.
<form id="upload-form">
<input id="file-input" name="file" type="file" accept="image/png,image/jpeg,application/pdf" />
<button type="submit">Upload</button>
</form>
<img id="preview" alt="" style="max-width: 240px; display: none;" />
<p id="message"></p>
<script type="module">
const MAX_BYTES = 5 * 1024 * 1024;
const allowedTypes = new Set(["image/png", "image/jpeg", "application/pdf"]);
const form = document.querySelector("#upload-form");
const input = document.querySelector("#file-input");
const preview = document.querySelector("#preview");
const message = document.querySelector("#message");
input.addEventListener("change", () => {
const file = input.files?.[0];
preview.style.display = "none";
preview.removeAttribute("src");
message.textContent = "";
if (!file) return;
if (!allowedTypes.has(file.type)) {
message.textContent = "Only PNG, JPEG, and PDF files are allowed.";
input.value = "";
return;
}
if (file.size > MAX_BYTES) {
message.textContent = "The file must be 5MB or smaller.";
input.value = "";
return;
}
if (file.type.startsWith("image/")) {
preview.src = URL.createObjectURL(file);
preview.style.display = "block";
preview.onload = () => URL.revokeObjectURL(preview.src);
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = input.files?.[0];
if (!file) {
message.textContent = "Choose a file first.";
return;
}
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData
});
const result = await response.json();
message.textContent = response.ok ? `Saved: ${result.name}` : result.error;
});
</script>
This is intentionally small. It proves the browser flow: select a file, inspect it with the File API, show a preview, create FormData, and send it with fetch. The client-side checks improve the user experience, but they are not security. A malicious client can skip this JavaScript completely, so the server still has to validate everything.
React preview and real upload progress
In React, keep the states explicit: selected file, preview URL, progress, error, and saved filename. That makes the component easy for Claude Code to refactor later.
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "application/pdf"]);
type UploadResult = {
ok: true;
name: string;
size: number;
type: string;
};
export function FileUploadBox() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [uploadedName, setUploadedName] = useState<string | null>(null);
const canUpload = useMemo(() => selectedFile && !error, [selectedFile, error]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
setUploadedName(null);
setProgress(0);
setError(null);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
if (!file) {
setSelectedFile(null);
return;
}
if (!ALLOWED_TYPES.has(file.type)) {
setSelectedFile(null);
setError("Only PNG, JPEG, and PDF files are allowed.");
return;
}
if (file.size > MAX_BYTES) {
setSelectedFile(null);
setError("The file must be 5MB or smaller.");
return;
}
setSelectedFile(file);
if (file.type.startsWith("image/")) {
setPreviewUrl(URL.createObjectURL(file));
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedFile) return;
const formData = new FormData();
formData.append("file", selectedFile);
const result = await uploadWithProgress(formData, setProgress);
setUploadedName(result.name);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="file"
accept="image/png,image/jpeg,application/pdf"
onChange={handleFileChange}
/>
{previewUrl && <img src={previewUrl} alt="Selected file preview" width={240} />}
{selectedFile && <p>{selectedFile.name} / {Math.round(selectedFile.size / 1024)}KB</p>}
{error && <p role="alert">{error}</p>}
<progress value={progress} max={100}>{progress}%</progress>
<button type="submit" disabled={!canUpload}>Upload</button>
{uploadedName && <p>Saved: {uploadedName}</p>}
</form>
);
}
function uploadWithProgress(
formData: FormData,
onProgress: (progress: number) => void
) {
return new Promise<UploadResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable) return;
onProgress(Math.round((event.loaded / event.total) * 100));
});
xhr.addEventListener("load", () => {
const body = JSON.parse(xhr.responseText || "{}");
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
else reject(new Error(body.error ?? "Upload failed"));
});
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.send(formData);
});
}
The subtle but important point is honesty. If you use fetch, show a loading state. If you use XMLHttpRequest, show a real progress bar. Do not ask Claude Code to fake progress with a timer unless the UI text makes it clear that it is only an estimate.
Server-side validation that is strict enough to review
The server route below is a Next.js Route Handler. It stores the file in .local-uploads so the first version is easy to run. In production, replace the storage line with an S3, Cloud Storage, Azure Blob Storage, or R2 adapter.
// app/api/upload/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
["image/png", ".png"],
["image/jpeg", ".jpg"],
["application/pdf", ".pdf"]
]);
export async function POST(request: NextRequest) {
const formData = await request.formData();
const value = formData.get("file");
if (!(value instanceof File)) {
return NextResponse.json({ error: "Missing file." }, { status: 400 });
}
const expectedExt = ALLOWED_TYPES.get(value.type);
const originalExt = path.extname(value.name).toLowerCase();
if (!expectedExt) {
return NextResponse.json({ error: "File type is not allowed." }, { status: 400 });
}
if (value.size === 0 || value.size > MAX_BYTES) {
return NextResponse.json({ error: "File size must be between 1 byte and 5MB." }, { status: 400 });
}
if (expectedExt === ".jpg" && ![".jpg", ".jpeg"].includes(originalExt)) {
return NextResponse.json({ error: "JPEG extension is invalid." }, { status: 400 });
}
if (expectedExt !== ".jpg" && originalExt !== expectedExt) {
return NextResponse.json({ error: "MIME type and extension do not match." }, { status: 400 });
}
const bytes = Buffer.from(await value.arrayBuffer());
const safeExt = expectedExt === ".jpg" ? ".jpg" : expectedExt;
const storedName = `${randomUUID()}${safeExt}`;
const uploadDir = path.join(process.cwd(), ".local-uploads");
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, storedName), bytes, { flag: "wx" });
return NextResponse.json({
ok: true,
name: storedName,
size: value.size,
type: value.type
});
}
Review this route for five boundaries: MIME type, extension, size, storage name, and storage location. The original filename is not used as the saved filename. The public folder is not used as a dumping ground. Empty files are rejected. The code also rejects a mismatch between an allowed MIME type and an unexpected extension.
This still is not a complete malware defense. MIME type and extension checks are useful, but determined attackers can lie. For high-risk documents, add magic-number checks, image decoding, PDF inspection, antivirus scanning, authentication, per-user quotas, and audit logs. The goal of this route is to be a safe starting point, not the final compliance story.
When to move the file to S3 or Cloud Storage
Local storage is fine for learning, prototypes, and a temporary internal tool. It is not a good long-term choice for SaaS user files. Move to object storage when files are customer assets, when more than one server may handle requests, when you need lifecycle rules, when delivery volume grows, or when you want asynchronous image processing.
You do not have to jump to direct browser-to-S3 uploads on day one. For small files, the application server can receive the file, validate it, and then upload it to object storage. That keeps the first design easier to reason about. Direct upload with presigned URLs is useful when file size, traffic, or server bandwidth becomes a real concern.
Here is the kind of prompt I use when moving the local route to S3:
Convert the existing Next.js local file upload to S3-backed storage.
Current state: /api/upload receives FormData and already validates MIME type, extension, and max size.
Constraints: Do not use the original filename as the S3 key. Store as uploads/yyyy/mm/dd/{uuid}.ext.
Constraints: Allowed types are image/png, image/jpeg, and application/pdf. Max size is 5MB.
Constraints: Keep the bucket private. The upload API should return a stored file ID, not a public URL.
Deliverables: app/api/upload/route.ts, lib/storage/s3.ts, tests for failure cases, and README env vars.
Verification: Explain tests for oversized files, extension mismatch, missing auth, and successful upload.
The prompt is specific because storage is where “works” and “safe” diverge. Claude Code needs to know that the bucket must stay private, the key must be generated by the server, and public delivery must be a separate decision.
Three practical use cases
Use case one is profile images. The user experience depends on preview, cropping, size feedback, and a quick retry. The safest beginner version allows only PNG, JPEG, and WebP. SVG may look like an image, but it can carry script-like risks depending on how it is served and embedded, so do not allow it casually.
Use case two is CSV import for an admin screen. The upload itself is only the entry point. The real work is validating columns, row counts, encoding, duplicate records, rollback behavior, and error reports. Ask Claude Code for a two-step flow: upload and preview first, then confirm import. That avoids turning a bad CSV into permanent database changes.
Use case three is invoices, contracts, and private PDFs. These files should not become public URLs. Store them privately, associate them with the authenticated customer or organization, and issue short-lived download URLs only after an authorization check. Add logs for who requested which file and when. The UX can be simple; the trust boundary cannot be.
A fourth common case is chat attachments. Here the hard parts are progress, cancellation, storage cost, moderation, and expiration. Decide how long attachments live before writing code. Otherwise the storage bucket slowly becomes an unbounded archive.
Failure cases to avoid
The first failure is treating the accept attribute as security. It is a file picker hint, not a server-side policy. A custom client can send anything. Always validate on the server.
The second failure is saving the original filename. Original names are useful metadata, but poor storage keys. They can contain spaces, duplicate names, unexpected characters, or path-like strings. Store the display name in the database and generate the real object key with a UUID.
The third failure is returning a public URL too early. There is a difference between “stored” and “published”. Profile images may be public. Contracts, CSV imports, and reports usually are not. Keep those states separate in the data model.
The fourth failure is fake progress. A timer that climbs to 90% is not upload progress. It may feel fine in a demo, but it breaks trust on slow networks. Either show a generic loading state or use a transport that exposes upload progress.
The fifth failure is broad storage permission. If you ask Claude Code for S3 upload without constraints, review the IAM policy carefully. Start with PutObject on one prefix. Avoid public bucket policies and deletion permission until the product need is clear.
Copy-paste prompts for Claude Code
Use this for the first local version:
Add a safe file upload feature to this Next.js app.
Goal: SaaS admin screen that uploads one PNG, JPEG, or PDF at a time.
Client: Build a React component using the File API. Show selected filename, size, image preview, error, and uploading state.
Transport: POST FormData to /api/upload. If real progress is required, use XMLHttpRequest and explain why fetch is not used for progress.
Server: In app/api/upload/route.ts, receive FormData and validate MIME type, extension, max size 5MB, and empty files.
Storage: Do not use the original filename as the saved name. Save as UUID + extension under .local-uploads.
Forbidden: Do not save directly into public/. Do not claim extension checks are complete security. Do not make an S3 bucket public.
Verification: Explain tests for oversized files, extension mismatch, missing file, and successful upload.
References: MDN File API, FormData, and Fetch API.
Use this when you are ready for object storage:
Move the existing local upload feature to S3 storage.
Requirements: S3 key format uploads/yyyy/mm/dd/{uuid}.ext. Bucket remains private. API returns a file ID, not a public URL.
Validation: Server checks MIME type, extension, size, authenticated user, and plan quota.
Security: IAM starts with PutObject only for the uploads prefix. Do not add DeleteObject or public bucket policy.
UX: Images get preview before upload. PDFs show filename and size. Failed uploads can be retried.
Deliverables: storage abstraction, S3 implementation, upload API, React UI, tests, and README env vars.
Review notes: Leave TODOs for cost, signed URLs, audit logs, antivirus scanning, and lifecycle rules.
Masa verification note
When I tested this workflow, the biggest quality jump came from asking Claude Code to separate “simple fetch upload” and “real progress upload.” If I asked for everything at once, the generated UI sometimes showed a nice progress bar without a reliable upload-progress source. Once I said “use fetch for the simple path and XMLHttpRequest when progress is required,” the code became much easier to review.
The second useful habit was starting with local storage. S3, CORS, IAM, presigned URLs, object keys, and React state are too many variables for a beginner in one step. Local storage lets you verify the File API, FormData, validation, and preview first. Then you can replace only the storage adapter.
Summary
Safe file upload is not just an input field. It is a boundary between the user’s device, your application server, your storage system, and your access-control model. Claude Code can implement that boundary quickly if the prompt names the constraints clearly: File API on the client, FormData transport, server-side MIME and extension checks, size limits, generated storage keys, honest progress, and private object storage when the product grows.
If you want this adapted to a real repository, the Claude Code training and consultation can cover upload flows, S3 storage, signed URLs, validation tests, and review checklists. The free PDF and learning materials are also a good first step if you want to try the pattern before booking a session.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.