Avatars are the first upload almost every product adds, and they are a decent test of an upload story, because they touch every part of it: a picker, a transformation, a URL in a database, and a file to clean up when it is replaced.
Here is the whole thing.
1. Decide the shape once
An avatar is square, small, and always the same format. That is a template:
curl -X POST https://filemon.io/api/templates \
-H "Authorization: Bearer $FILEMON_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Avatar", "accepts": "image", "width": 400, "height": 400, "format": "webp"}'
{ "name": "Avatar", "id": "m1o900k8", "code": "k8" }accepts: "image" means the template refuses a PDF, which matters because the file input's accept attribute is a suggestion the browser makes to the file picker, not a rule anyone has to follow.
2. Take the file on your own server
The browser posts to you, not to the storage service, so your API key stays out of the bundle:
app.post("/profile/avatar", async (req, res) => {
const user = await currentUser(req);
const body = new FormData();
body.append("file", req.file);
const upload = await fetch("https://filemon.io/api/m1o900k8", {
method: "POST",
body,
headers: { Authorization: `Bearer ${process.env.FILEMON_KEY}` },
});
if (!upload.ok) {
const { error } = await upload.json();
return res.status(400).json({ error });
}
const { id } = await upload.json();
const previous = user.avatar;
await db.users.update(user.id, { avatar: id });
res.json({ id });
// The old one is nobody's now
if (previous) {
fetch(`https://filemon.io/api/${previous}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${process.env.FILEMON_KEY}` },
});
}
});Two details worth copying. Store the id, not the URL, because the id is what every URL is built from and it does not contain a hostname. And delete the previous avatar, because otherwise every profile edit leaves a file behind that you pay to store and nobody will ever look at.
3. Render it
const avatar = (id) =>
id ? `https://filemon.io/api/${id}` : "/default-avatar.svg";
<img src={avatar(user.avatar)} width="80" height="80" alt="" />;Keep the default as a local SVG. It costs nothing, it never fails, and it means a missing avatar is not a broken image.
The parts people skip
Cropping. A 400x400 template covers the picture, so a tall photo loses its edges rather than getting squashed. If you want the user to choose what is kept, crop in the browser before uploading, with a canvas, and send the result. The template still guarantees the final size.
The square that is not square. People upload panoramas as avatars. With transform-on-download you find out at render time; with a template the file was already made square when it arrived, so the layout cannot break later.
The upload that fails. Wrong type, too big, out of credits. The response carries a plain error message, and the code above passes it straight to the user rather than logging it and showing "something went wrong".
That is the whole flow: one template, one route, one column in your database.