Step 9
Step 9 — Image Upload and Optimization
0 views
Step 9 — Image Upload and Optimization
Through step 8 your forms received text. Now it is the image the user uploads. Images are heavy, their formats vary, and taking them as is is dangerous. Receive → validate → transform → store — you build these four steps by hand.
Receiving the upload
Take the file with <input type="file"> and send it to a server route.
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) upload(file);
}}
/>
accept="image/*" narrows the file picker to images only. But this is only a convenience — the server must re-validate whatever arrives.
async function upload(file: File) {
const body = new FormData();
body.append("image", file);
await fetch("/api/upload", { method: "POST", body });
}
Validation — the most important step
The image transform library (sharp) is native code, so it decodes the received file directly. If you do not filter malicious files, the server is at risk. Check three things.
1) Real format (MIME sniffing). A file named photo.jpg is no guarantee it is really a JPEG. The client-sent Content-Type can be forged too. Determine the real format from the bytes at the start of the file.
import { fileTypeFromBuffer } from "file-type";
const type = await fileTypeFromBuffer(buffer);
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (!type || !allowed.includes(type.mime)) {
throw new Error("Format not allowed");
}
2) Size limits. Before reading the body, reject oversized requests by Content-Length, and at the decode stage use limitInputPixels to block an image bomb (a decompression bomb where a small file expands into billions of pixels).
3) Do not accept SVG. SVG is XML, so it can embed <script> and is an XSS vector. For image uploads it falls outside the whitelist (jpeg/png/webp), so the validation above rejects it naturally.
Transforming with sharp
Normalize the buffer that passed validation into a standard shape.
import sharp from "sharp";
const output = await sharp(buffer, { limitInputPixels: 24_000_000 })
.rotate() // apply EXIF rotation
.resize(1600, 1600, { fit: "inside", withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();
Read it line by line.
.rotate()— applies the EXIForientationto fix portrait/landscape. Drop it and phone photos lie sideways..resize(1600, 1600, { fit: "inside", withoutEnlargement: true })— fits inside a 1600×1600 box while keeping the ratio, and does not enlarge a small original..webp({ quality: 80 })— converts to WebP. For photos, quality 75~85 is barely distinguishable to the eye.
sharp depends on native bindings, so this route must run on the Node runtime (the Edge runtime will not work).
Size presets
Each screen needs a different size. Using one file for both a list thumbnail and a large detail-page image leaves both awkward. Define per-purpose presets.
const PRESETS = {
thumb: { w: 200, h: 200, quality: 70 },
card: { w: 600, h: 600, quality: 78 },
detail: { w: 1600, h: 1600, quality: 82 },
};
async function makeVariant(buffer: Buffer, p: { w: number; h: number; quality: number }) {
return sharp(buffer)
.rotate()
.resize(p.w, p.h, { fit: "inside", withoutEnlargement: true })
.webp({ quality: p.quality })
.toBuffer();
}
If one upload produces the three variants thumb, card, and detail, each screen just picks the one that fits it.
Storing
Upload the transformed result to storage. Here the filename must be a random value the server generates. Using the user-supplied name as is risks path traversal and overwrites.
import { randomUUID } from "crypto";
const key = `images/${randomUUID()}.webp`;
await storage.put(key, output); // the storage SDK's upload call
If you need to show the original filename, keep it only in a metadata column in the DB — never in the storage path.
Try it
Add an <input type="file"> to the step 8 form. When a file is picked, show a preview (URL.createObjectURL), and disable the submit button if it is over 5 MB. With no server, doing just the preview and size check on the client is enough.
Deeper
Next
So far you received text and the image and built a living screen. What held all that code up behind the scenes is TypeScript. The final Step 10 — TypeScript in Depth (strict, narrowing, generics) looks at that tool head-on — the safety net of strict, type narrowing, receiving external data as unknown, generics, and utility types.