Three places a file could go, and only one is right
The repository. Never. Uploaded files are user data; they would bloat every clone and be visible to anyone who can read the repo.
The app’s own disk. The model’s first answer, fs.writeFile("./uploads/photo.jpg"), because it works on the laptop. On a serverless host the disk is part of the process, and the process is recycled. The file exists for minutes or hours and then the process is gone and so is it. On a persistent host it survives until you redeploy, at which point the new build starts from the repository and the folder is empty. Either way, it is temporary and looks permanent for exactly long enough to ship.
The database, as a blob. Works, and a table with a thousand 3 MB rows is a 3 GB table; every backup, every full scan, and every SELECT * that touches it drags the bytes along. Free tiers have storage limits in the low gigabytes. Keep it for tiny things (an avatar under 50 KB, perhaps) and nothing larger.
Object storage. A service whose only job is to hold files and hand them back by address. The database stores the address. This is what every production app does.
Object storage, and the free ones
Amazon S3 defined the shape; almost everything else speaks its protocol. Cloudflare R2 gives 10 GB free and charges nothing for downloads, which is the cost that surprises people on S3. Supabase Storage bundles 1 GB with its free database. Backblaze B2 gives 10 GB free. Any of them, with any S3-compatible client library, and the code is the same.
The unit is a bucket with keys (paths, in effect) and a file is put there once and read many times.
Two ways to upload
Through your server. The browser sends the file to your route, your route sends it to the bucket. Simple, and every byte passes through your function, which on serverless has a body size limit, 4.5 MB on Vercel’s default, and a time limit. Fine for avatars; wrong for a 200 MB video.
Directly, with a signed URL. Your server generates a one-time upload address that permits exactly one key, one size limit, one content type, valid for a few minutes. The browser uploads straight to the bucket. Your server never touches the bytes, only the permission:
const url = await getSignedUrl(s3, new PutObjectCommand({
Bucket: "uploads",
Key: `${session.userId}/${randomUUID()}.jpg`,
ContentType: "image/jpeg",
ContentLength: size, // refused if the browser sends more
}), { expiresIn: 300 });Note the key: the user’s id is in the path, generated by the server, so a user cannot choose to write over someone else’s file. The filename the browser offered is not used for anything but display; it is user input and it may contain ../ or a script tag.
Serving them back
Public files (product images) get a public URL from the bucket, ideally behind the provider’s CDN. Private files (a customer’s invoice) get a signed download URL, generated by your server after it has checked that this session is allowed to see that file. Making a bucket public because “the URLs are unguessable” is protection by obscurity; a leaked link is a permanent leak.
Images want resizing
A phone photo is 4,000 pixels wide and 5 MB. Nobody’s screen needs that in a 200-pixel avatar. Resize on the way in with a library such as sharp (one call, produces a few sizes) or on the way out with an image service; hosts and Cloudflare both offer one. An app that serves original photos in a list of fifty users sends 250 MB to load one page, and the person on a phone in a train will not wait.
The limits to set
A maximum size, checked on the server (the browser check is a courtesy, not a control). A list of allowed types, checked by reading the file’s first bytes rather than its extension: a file named photo.jpg can contain anything, and the security block returns to why that matters. A per-user quota, if storage costs you anything.
What the free path looks like end to end
R2 or Supabase Storage for the bucket, a signed upload URL from a route that checks the session, sharp for a thumbnail, the key stored in a column of your own table with user_id beside it, and a signed download URL for anything private. Every piece of that is free at a small app’s scale, and the model can write all of it once you have named the pieces.
Every lesson here is open. Your progress is recorded once you pass the Building with AI exam.
Check your understanding
Profile photos are saved with fs.writeFile to an uploads folder inside the project. Locally they persist. On the deployed site they show for a while and then return 404. Why?
Pick the one you would defend. Your answer stays private.
Stuck on something?Ask Beatriz, your AI tutor
