Skip to content

File uploads & CRUD ​

JSON notes were the easy CRUD. Files are binary + metadata: multer catches the upload, you store the blob on disk (or S3), and keep a DB row so list/delete still feel like an API.

Mental model ​

PieceJob
Multipart form (FormData)How the browser sends files
MulterParses multipart → req.file / req.files
Disk / object storageThe actual bytes
DB rowWho owns it, original name, mime, size, path

FE map:

ts
const fd = new FormData()
fd.append('file', fileInput.files[0])
await fetch('/api/files', {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}` },
  body: fd, // no Content-Type - browser sets multipart boundary
})

Don't set Content-Type: application/json here - you'll break the boundary.

Install ​

bash
npm install multer
npm install -D @types/multer

Schema (SQL or Prisma) ​

sql
CREATE TABLE IF NOT EXISTS files (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER NOT NULL,
  original_name TEXT NOT NULL,
  stored_name TEXT NOT NULL,
  mime_type TEXT NOT NULL,
  size INTEGER NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY (user_id) REFERENCES users(id)
);

stored_name is a random filename on disk so users can't overwrite each other with ../../../etc/passwd.pdf energy.

Multer setup ​

ts
// src/uploads.ts
import fs from 'node:fs'
import path from 'node:path'
import multer from 'multer'
import { randomBytes } from 'node:crypto'

export const uploadsDir = path.resolve('uploads')
fs.mkdirSync(uploadsDir, { recursive: true })

const storage = multer.diskStorage({
  destination: (_req, _file, cb) => cb(null, uploadsDir),
  filename: (_req, file, cb) => {
    const ext = path.extname(file.originalname).slice(0, 10)
    cb(null, `${randomBytes(16).toString('hex')}${ext}`)
  },
})

export const upload = multer({
  storage,
  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
  fileFilter: (_req, file, cb) => {
    const ok = ['image/png', 'image/jpeg', 'application/pdf'].includes(file.mimetype)
    cb(ok ? null : new Error('unsupported file type'), ok)
  },
})

Comments for the code above:

  • Size + mime allowlists - don't trust originalname alone
  • Random stored names - path traversal harder
  • Errors from multer should hit your error middleware (map to 400)

Routes - Create, Read, Delete (Update is rare) ​

ts
// src/routes/files.ts
import { Router } from 'express'
import path from 'node:path'
import fs from 'node:fs'
import { requireAuth, type AuthedRequest } from '../middleware/auth.js'
import { upload, uploadsDir } from '../uploads.js'
import { createFileRow, deleteFileRow, findFile, listFilesForUser } from '../db.js'
import { HttpError } from '../errors.js'

export const filesRouter = Router()
filesRouter.use(requireAuth)

// Create
filesRouter.post('/', upload.single('file'), (req: AuthedRequest, res, next) => {
  try {
    if (!req.file) throw new HttpError(400, 'file required')
    const row = createFileRow({
      userId: req.user!.id,
      originalName: req.file.originalname,
      storedName: req.file.filename,
      mimeType: req.file.mimetype,
      size: req.file.size,
    })
    res.status(201).json({ file: row })
  } catch (err) {
    next(err)
  }
})

// List (metadata)
filesRouter.get('/', (req: AuthedRequest, res) => {
  res.json({ files: listFilesForUser(req.user!.id) })
})

// Read / download one
filesRouter.get('/:id', (req: AuthedRequest, res, next) => {
  try {
    const file = findFile(Number(req.params.id))
    if (!file) throw new HttpError(404, 'not found')
    if (file.user_id !== req.user!.id) throw new HttpError(403, 'forbidden')

    const abs = path.join(uploadsDir, file.stored_name)
    res.download(abs, file.original_name) // sets Content-Disposition
  } catch (err) {
    next(err)
  }
})

// Delete
filesRouter.delete('/:id', (req: AuthedRequest, res, next) => {
  try {
    const file = findFile(Number(req.params.id))
    if (!file) throw new HttpError(404, 'not found')
    if (file.user_id !== req.user!.id) throw new HttpError(403, 'forbidden')

    const abs = path.join(uploadsDir, file.stored_name)
    fs.unlink(abs, () => {}) // best-effort disk cleanup
    deleteFileRow(file.id)
    res.status(204).end()
  } catch (err) {
    next(err)
  }
})

Mount:

ts
app.use('/api/files', filesRouter)

"Update" for files ​

Usually you replace: delete old + upload new, or upload a new version row. PATCH on binary blobs is awkward - don't force REST purity.

Serving vs downloading ​

  • res.download - attachment download
  • res.sendFile - inline (images in <img src="…"> if you also expose a signed/authed URL)

For private user files, prefer authed download routes over a public /uploads static mount. Static express.static('uploads') is fine for public assets - not for private docs.

SPA list UI sketch ​

ts
const { files } = await api('/api/files').then((r) => r.json())
// show name, size, delete button
// download: window.open with Authorization is annoying -
// prefer <a> that hits an authed blob fetch → object URL

Blob download pattern:

ts
const res = await api(`/api/files/${id}`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)

Production notes (short) ​

  • Disk on one server doesn't scale - use S3 / R2 / GCS + store the object key in DB
  • Virus scanning for user content if you're serious
  • Separate CDN domain for public media

Companion ​

examples/notes-api includes /api/files with multer + ownership checks. Uploaded bytes land in examples/notes-api/uploads/ (gitignored).

Next: push events to the browser without polling - WebSockets.

Best,

-- ł.

Notes-style Express tutorial for front-end folks