albert.walickihire me
← all solutions
javascript

How to receive an uploaded image on the server?

Read an uploaded image in Node.js with request.formData() or multer in Express, save it safely, and avoid the usual security holes with uploads.

In frameworks built on the web Request object (Next.js App Router route handlers, Remix and React Router, Hono, Bun, Deno), call await request.formData() and get the file by the input's name. You get a File, which you can turn into a Buffer and write to disk.

This Next.js route handler receives the avatar field from a form like the one in how to upload an image with a form:

// app/api/avatar/route.js
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';

const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
const UPLOAD_DIR = path.join(process.cwd(), 'uploads');

export async function POST(request) {
  const formData = await request.formData().catch(() => null);
  const file = formData?.get('avatar');

  if (!(file instanceof File)) {
    return Response.json({ error: 'No file' }, { status: 400 });
  }
  if (file.size > MAX_SIZE) {
    return Response.json({ error: 'File too large' }, { status: 413 });
  }

  const buffer = Buffer.from(await file.arrayBuffer());

  let image;
  try {
    // Fails if the file isn't an image sharp can decode
    image = await sharp(buffer)
      .rotate()
      .resize(512, 512, { fit: 'cover' })
      .webp({ quality: 80 })
      .toBuffer();
  } catch {
    return Response.json({ error: 'Not an image' }, { status: 415 });
  }

  const name = `${crypto.randomUUID()}.webp`;
  await mkdir(UPLOAD_DIR, { recursive: true });
  await writeFile(path.join(UPLOAD_DIR, name), image);

  return Response.json({ name });
}

formData.get() returns null when the field is missing and a string when it's a normal text field, so the instanceof File check covers both. File is a global in current Node.js versions, and so is crypto, so neither needs an import. Reading the file works the same way in the other frameworks; only how you get the request changes. sharp is a native Node.js module, though, so it won't run on edge runtimes.

Express with multer

Express doesn't parse multipart bodies on its own. The usual choice is multer:

import express from 'express';
import multer from 'multer';

const app = express();
const upload = multer({
  dest: 'uploads/tmp/',
  limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});

app.post('/api/avatar', upload.single('avatar'), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file' });
  }

  // req.file: { originalname, mimetype, size, path, ... }
  res.json({ size: req.file.size });
});

// Errors from multer, such as a file over the size limit
app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    return res.status(400).json({ error: err.code });
  }
  next(err);
});

upload.single('avatar') reads one file from the avatar field and saves it to dest under a random name without an extension. req.file.path is where it ended up. From there, run it through sharp(req.file.path) like in the first example, save the result, and delete the temporary file. A file over limits.fileSize becomes a MulterError with the code LIMIT_FILE_SIZE.

Security

File uploads are a common source of security holes, so don't skip these.

  • Enforce a size limit. multer stops at limits.fileSize. request.formData() reads the whole body into memory before you can check file.size, so also limit the request size in front of your app, for example with client_max_body_size in Nginx or your hosting platform's limits.
  • Don't trust the file type or the name. file.type, file.name, mimetype and originalname are all sent by the client and can be anything. A name like ../../server.js can lead to path traversal, and a file named avatar.html served from your domain can run scripts. Generate your own name with crypto.randomUUID() and pick the extension yourself.
  • Check that it's really an image. Decoding it with sharp fails for anything that isn't a supported image. Re-encoding it, like the example does, also gives you a known format and size, and strips metadata such as the GPS location in a photo's EXIF data. .rotate() with no arguments applies the EXIF orientation first, so photos don't end up sideways.
  • Store files outside the public folder. Keep them in a private folder and serve them through a route that sets the Content-Type, or put them in object storage such as S3. In Next.js, files added to public after the build aren't served in production anyway. On serverless hosting the file system is read-only or temporary, so object storage is the only real option there.

For large files, consider skipping your server entirely: let the browser upload straight to object storage with a presigned URL your server creates.

more solutions
work with me

Got something that needs building?

Frontend builds, full-stack features in Django, design-system work. Available for work.

See my workGet in touch