Uploading a file is a solved problem. Uploading a file from inside an editoris not, because the image has to appear in the document immediately, at a position the user chose, and then quietly become a real URL — or visibly fail, without leaving a broken document behind.
Most of the difficulty is in the states between “user dropped a file” and “done”.
First: do not store base64 in your content
The path of least resistance is to read the file with FileReader, inline it as a data: URI, and save. It works instantly, needs no endpoint, and is wrong for anything real.
- Base64 inflates the payload by about a third, and it lands in the same row as your text. A document with six screenshots becomes a multi-megabyte database field.
- Nothing can cache the image separately from the document.
- Every diff of that row is enormous, every backup carries it, and every query that selects the column pays for it.
- You cannot resize, re-encode, virus-scan or CDN it later without rewriting stored content.
Data URIs are fine for a 1 KB inline icon or a temporary preview. They are not a storage strategy. Upload to object storage, store a URL.
The upload handler contract
A good editor hands you the file and gets out of the way. The shape is a callback you implement, which receives the file and returns a URL:
file_upload_handler: function (file, callback, optionalIndex, optionalFiles) {
const form = new FormData();
form.append("file", file);
fetch("/api/uploads", { method: "POST", body: form })
.then(r => {
if (!r.ok) throw new Error("Upload failed: " + r.status);
return r.json();
})
.then(data => callback(data.url)) // the editor swaps in the real URL
.catch(err => callback(null, err.message)); // and the failure path matters just as much
}The important property is that you own the transport. Your endpoint, your auth headers, your storage. An editor that only uploads to the vendor’s service is one you cannot deploy into a private network, and it makes your content availability depend on their uptime.
Note the fourth parameter. When a user pastes or drops several files at once you get called per file, and the index and full list let you show “3 of 7” rather than seven indistinguishable spinners.
The states people forget
A single upload has at least five outcomes, and the demo only covers one:
- In flight. A placeholder must occupy the correct position with roughly the right dimensions, or the text reflows when the image lands and the user loses their place.
- Succeeded. Replace the placeholder in place — not at the caret, which has moved.
- Failed. Remove the placeholder and say why, near the image, not in a toast that has already gone.
- Cancelled. The user hit undo, deleted the placeholder, or navigated away mid-upload. Abort the request and do not insert a URL into a document that no longer has a slot for it.
- Saved before it finished. Autosave fires while three uploads are in flight. What gets stored?
That last one is the one that bites. If autosave serialises a document containing placeholder markup, you have persisted a reference to something that does not exist. Either block save while uploads are pending, or strip pending placeholders on serialise. Decide deliberately — the default is usually to store the broken state.
Paste and drag-drop are the common path
Most images never touch a file picker. Users take a screenshot and press Ctrl+V, or drag from a folder or another browser tab. Handle all three, and note they behave differently:
- Pasted screenshots have no filename. The clipboard gives you an image blob called
image.pngor nothing at all. Generate a stable name server-side; do not trust the client’s. - Dragging from another web page may give you a URL rather than a file. If you fetch that URL server-side to re-host it, you have built an SSRF vector — see below.
- Pasting from Word can produce images referenced as local
file://paths that your browser cannot read at all. They must degrade visibly, not vanish.
Screenshots are also big. A retina screenshot of a full window is routinely 5–12 MB of PNG for something displayed at 600px. Downscaling client-side before upload — draw to a canvas at a sane maximum dimension, re-encode — is usually worth it, and lets you reject genuinely oversized files before spending the bandwidth.
Orphaned files: the slow leak
A user uploads an image, then deletes it from the document, then saves. The blob is now unreferenced and will stay in your bucket forever. Multiply by every draft anyone ever abandoned.
Three workable approaches, in increasing order of effort:
- Do nothing, and accept it. Storage is cheap. Legitimate for low volume, but it interacts badly with GDPR deletion requests, because you cannot enumerate what belongs to whom.
- Reference counting on save. When a document is saved, diff the image URLs it contains against what it previously contained and delete the difference. Simple, and wrong if the same image is used in two documents — so count references rather than deleting on first absence.
- Upload to a quarantine prefix, promote on save. Uploads land in
tmp/with a short lifecycle rule; saving a document copies or moves the referenced ones topermanent/. Abandoned drafts expire by themselves. This is the most robust and the most work.
Whichever you pick, decide it before launch. Retrofitting reference counting onto a bucket with 400,000 unattributed objects is genuinely unpleasant.
What belongs on the server
Client-side checks are for user experience. Every one of these must also exist server-side, because the client can be bypassed entirely:
- Validate the content, not the extension or the declared type. Both are attacker-controlled. Sniff magic bytes, and prefer re-encoding the image — decoding and re-emitting a PNG strips anything that was not pixels, including a polyglot payload.
- Never serve uploads from your application’s origin if you can avoid it. A stored file that renders as HTML in your origin is stored XSS with extra steps. Use a separate domain, or at minimum
Content-Disposition: attachmentand a strictContent-TypewithX-Content-Type-Options: nosniff. - Authorise the upload, and authorise the read. Uploads scoped to a document the user can actually edit; reads scoped so a guessable URL is not a data leak.
- Do not fetch remote URLs server-side without an allowlist.“Re-host this image from a URL” is a request for your server to make arbitrary outbound requests, including to
169.254.169.254and your internal network. If you must, resolve the host first and reject private ranges — and re-check after redirects. - Cap size and dimensions. A 40,000×40,000 PNG is a few hundred KB compressed and will exhaust memory on decode.
One accessibility detail worth building in
The moment to ask for alt text is when the image is inserted, because that is the only moment the author remembers what it shows. Retrofitting alt text later means someone guessing from a thumbnail.
Make the field present and easy, but be careful about forcing it: a required field trains people to type “image”, which is worse than empty because empty alt is the standard signal for decorative, skip this. Offer a genuine “this image is decorative” checkbox so the distinction is deliberate rather than inferred from laziness. An accessibility checker can then flag the images that are neither described nor marked decorative.
Alt text also has to survive everywhere the document goes — HTML, .docx, and a tagged PDF, where it becomes the /Alt on a Figure element. If your export pipeline drops it, the work the author did is thrown away silently at the last step.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.