Appearance
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
| Piece | Job |
|---|---|
Multipart form (FormData) | How the browser sends files |
| Multer | Parses multipart → req.file / req.files |
| Disk / object storage | The actual bytes |
| DB row | Who 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/multerSchema (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
originalnamealone - 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 downloadres.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 URLBlob 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,
-- ł.