Upload files

Having trouble? Follow the image upload troubleshooting guide for missing requests, 413 errors and images lost after saving.

You write the upload handler. It receives the file the user inserted, pasted or dropped, sends it to your server, and hands the editor back the URL to put in the document. The samples in the download include a working handler for ASP.NET Web Forms, MVC, ASP.NET Core and PHP, plus the client script below.

Configure a handler, or files are embedded in the content

With no handler configured, nothing is uploaded and the file still has to go somewhere, so it goes into the document:

  • An image is embedded as a base64 data:URL. The picture becomes part of the HTML you save, and base64 adds about a third: a 3 MB photo turns into roughly 4 MB of text in one field. This is the usual cause of a save that fails, truncates, or times out when writing to the database.
  • A document is inserted as a link carrying the file name, but without a working address: the editor points it at a temporary blob: URL, and the content sanitizer removes that, because blob: is not one of the schemes it allows. The reader gets a link that cannot be opened. Set fileUploadLocalObjectUrl: false to refuse the insert and tell the user instead.

With a handler, the content holds a short URL such as /imageuploads/20260918-a1b2c3.png. To check an existing document, look at the HTML you stored: src="data:image/... means the handler is not being used.

The Insert Gallery dialog’s Upload button looks only for the window.rte_file_upload_handler global. If you set only the per-editor file_upload_handler option, the gallery shows no Upload button.

The short way: give the editor a URL

If your server already accepts a file and answers with its URL, you do not need to write any JavaScript at all:

Code example
var config = {};

// The whole setup: the editor POSTs the file here and uses the URL you answer with.
config.file_upload_url = "/imageupload.ashx";

var editor = new RichTextEditor("#div_editor", config);

The editor POSTs the file as multipart/form-dataand reads the URL out of the response. It understands the shapes the other editors’ endpoints already answer with, so a server written for TinyMCE or CKEditor works here unchanged:

Response bodyWritten for
{"location": "/img/x.png"}TinyMCE
{"url": "/img/x.png"}CKEditor simple upload adapter
{"urls": {"default": "..."}}CKEditor, multiple sizes
READY:/img/x.pngthe sample handlers below
{"error": {"message": "..."}} or ERROR:messageeither, reported as a failure

A response in none of those shapes is treated as a failed upload rather than inserted, so an endpoint that answers OK cannot become an image source.

Code example
config.file_upload_field = "file";          // form field name; CKEditor's endpoints use "upload"
config.file_upload_base_path = "https://cdn.example.com";  // prepended to a relative URL
config.file_upload_credentials = true;      // send cookies on a cross-origin upload
config.file_upload_reuse_filename = true;   // keep the original name (default: a unique one)
config.file_upload_headers = { "X-CSRF-Token": token };

Each uploaded file is given a unique name by default, because two people uploading image.pngto the same endpoint must not overwrite each other. Retries, cancellation and the progress event all work the same as with your own handler. Write a handler instead when you need to resize before sending, upload to S3 directly, or show your own dialog — it takes precedence whenever both are set.

Syntax

Code example
// One global handler for every editor on the page.
// The Insert Gallery dialog's Upload button uses this one.
window.rte_file_upload_handler = function (file, callback, optionalIndex, optionalFiles) { ... };

// Or per editor, for the image dialog, paste and drag-and-drop.
var config = {};
config.file_upload_handler = function (file, callback, optionalIndex, optionalFiles) { ... };
var editor = new RichTextEditor("#div_editor", config);
file

Type: File — the browser’s File object, with name, size and type. Send it with XMLHttpRequest or fetch.

callback

Type: Function callback(url, errorMessage)— call it exactly once. On success pass the stored URL; on failure pass null and a message, which the editor shows to the user. An upload the user cancels is a failure too.

optionalIndex, optionalFiles

When several files are inserted at once, the handler is called for each one: optionalFiles is the whole set and optionalIndexis the position of this file, so progress can be reported as “2 of 5”.

The sample handlers answer in plain text: READY:<url> with status 200, or ERROR:<message> with status 500. The client script below turns those into the two callback forms.

Cancelling an upload

Return an object with an abort() method (or a bare abort function) from the handler, and the editor can stop a request that is still running:

Code example
config.file_upload_handler = function (file, callback) {
    var xhr = new XMLHttpRequest();
    xhr.open("POST", "/imageupload.ashx");
    // ... send the file, then callback(url) or callback(null, message)
    xhr.send(formdata);

    // Returning this is what lets the editor stop the request.
    return { abort: function () { xhr.abort(); } };
};

editor.cancelUploads();          // abort everything in flight; returns the count
editor.getPendingUploadCount();  // how many are still running

cancelUploads() and destroy()abort in-flight requests when the handler provides an abort function. If the target image has been removed, a late result is discarded; removing an image does not itself guarantee an immediate network abort. A cancelled upload’s late callback is ignored, so a slow response cannot put a deleted image back. An uploadcancel event fires with the file and the reason. Returning nothing still works: the upload simply cannot be aborted.

Images already embedded in the content

The handler is called for files the user inserts, pastes or drops. Content that arrives another way — set with setHTMLCode, pasted from a web page or another editor, or restored from a draft — keeps its base64 data: URL, so those images are saved inside the HTML and your handler never sees them. Call uploadEmbeddedImages before saving:

For a native form, use the IDs below and replace the action with your document-save endpoint. Configure the upload handler before creating the editor. This example waits for uploads, refuses unresolved local images and preserves native validation and the submit button.

Code example
<form id="article-form" action="/your-document-save-endpoint" method="post">
  <!-- Include your framework's CSRF token field here. -->
  <div id="div_editor"></div>
  <input id="content" name="content" type="hidden">
  <p id="upload-save-status" role="status" aria-live="polite"></p>
  <button type="submit">Save document</button>
</form>
Code example
var form = document.getElementById("article-form");
var content = document.getElementById("content");
var saveStatus = document.getElementById("upload-save-status");
var scanning = false, approvedHTML = null;

form.addEventListener("submit", function (event) {
    var current = editor.getHTMLCode();
    if (approvedHTML !== null) {
        var approved = approvedHTML;
        approvedHTML = null;
        if (current !== approved || editor.getPendingUploadCount() > 0) {
            event.preventDefault();
            saveStatus.textContent = "Content changed. Wait for uploads, then save again.";
            return;
        }
        content.value = current; // Fresh snapshot in the native submit event.
        return;
    }
    event.preventDefault();
    if (scanning || editor.getPendingUploadCount() > 0) {
        saveStatus.textContent = "Images are still uploading. Wait, then save again.";
        return;
    }
    scanning = true;
    var submitter = event.submitter;
    saveStatus.textContent = "Checking embedded images...";
    try {
        editor.uploadEmbeddedImages(function (summary) {
            scanning = false;
            var localImage = Array.prototype.some.call(
                editor.getDocument().querySelectorAll("img"), function (image) {
                    return /^(data:|blob:|file:)/i.test((image.getAttribute("src") || "").trim());
                });
            if (summary.failed || summary.skipped || localImage || editor.getPendingUploadCount() > 0) {
                saveStatus.textContent = "Some images are not ready. Review and retry or remove them, then save again.";
                return;
            }
            approvedHTML = editor.getHTMLCode();
            saveStatus.textContent = "Images ready. Complete any required fields before saving.";
            // Re-run native validation; preserve the initiating submit button.
            if (submitter && submitter.form === form) form.requestSubmit(submitter);
            else form.requestSubmit();
            approvedHTML = null; // Validation may have stopped the submit event.
        });
    } catch (error) {
        scanning = false;
        approvedHTML = null;
        saveStatus.textContent = "Could not prepare images. Check your upload handler and try again.";
    }
});

It walks the current document, converts every data:image/ source into a File, sends them through the handler one at a time, and replaces each source with the URL you return. Images already on http(s) are left alone. With no handler configured nothing is changed and every image is counted as skipped.

A zero pending count is not proof of success: review missing images after a failed or cancelled upload. Do not call form.submit() directly; it bypasses native validation and submit listeners. Uploading an image is separate from saving the document; your server must authenticate, authorize and sanitize the posted HTML before storing or rendering it.

Uploading automatically, and retrying a failure

automaticUploads schedules the scan above for you. It is not a save-completion guarantee: keep the pending/error checks and inspect unresolved embedded images before taking your save snapshot:

Code example
var config = {};
config.file_upload_handler = rte_file_upload_handler;

// Upload embedded base64 images by themselves, after setHTMLCode and after a paste.
config.automaticUploads = true;
config.automaticUploadsDelay = 300;   // ms debounce; a paste of several images uploads once

// Retry a failed upload before telling the user about it.
config.uploadRetryCount = 2;          // extra attempts after the first (default 0)
config.uploadRetryDelay = 1000;       // ms before the first retry; doubles after that

It runs after setHTMLCode and after inserted or pasted HTML, debounced, and does nothing when no handler is configured. uploadRetryCount covers the other common failure: one dropped connection used to leave the image embedded for good. The retry is silent when it succeeds, each attempt raises an uploadretryevent, and once the attempts run out your callback’s error is reported as before.

Limiting the size of uploads and of the content

Set maxUploadFileSize and the editor refuses a larger file itself, before reading it. Nothing is embedded and nothing is sent, and the user is told which size was rejected and what the limit is:

Code example
var config = {};

// Largest file the editor will insert or upload, in bytes. 0 (the default) = no limit.
config.maxUploadFileSize = 5 * 1024 * 1024;   // 5 MB

// Optional: replace the default alert with your own dialog.
// The handler receives (state, name, message); set state.returnValue = true to say
// you have handled it, and the editor shows nothing itself.
config.oncustomdialog = function (state, name, message) {
  if (name !== "uploadtoobig") return;   // leave the others to the editor
  showMyDialog(message);
  state.returnValue = true;
};

It covers every route a file arrives by: the image dialog, the document dialog, paste, and drag-and-drop. Images already embedded inside pasted HTML (from Word, for example) are not covered, because refusing one mid-paste would leave the document half converted; maxHTMLLength below is the backstop for that.

Keep the check in your handler as well, since a browser is not a trustworthy place to enforce a limit. The sample handler rejects anything over 4,000,000 bytes and answers ERROR:file too big.

Two server limits must also be above your largest file:

  • ASP.NET: maxRequestLength in <httpRuntime> (kilobytes, default 4096) and maxAllowedContentLength in <requestLimits>(bytes, default about 30 MB).
  • PHP: upload_max_filesize(default 2 MB) and post_max_size. Below these, PHP discards the file before your handler runs and the editor reports that no file was received.

To cap the document itself, set a character limit. Both options are off by default (0), and when an edit crosses the limit the editor reverts to the last good state and shows a message rather than saving something your database will reject:

Code example
var config = {};
config.maxHTMLLength = 60000;  // characters INCLUDING the HTML tags; 0 = no limit
config.maxTextLength = 0;      // characters EXCLUDING the tags; 0 = no limit

Set maxHTMLLength a little below the size of the column you store the HTML in. Handle the reachmaxlength hook to replace the default alert with your own dialog.

Shrinking photographs before they are uploaded

A phone camera produces files far larger than any web page needs, and refusing them with maxUploadFileSize only tells the writer to go and find a different picture. The editor has no resizing setting of its own; the handler is where it belongs, because only your application knows how large the stored copy should be. Scale the image on a canvas and upload the result instead of the original:

Code example
config.file_upload_handler = function (file, callback, optionalIndex, optionalFiles) {
    var MAXWIDTH = 1600, MAXHEIGHT = 1600, QUALITY = 0.82;

    function send(blob, name) {
        // your own upload code: post "blob" under the name "name",
        // then callback(theUrl) or callback(null, theError).
    }

    // Leave anything that is not a still photograph alone. GIFs would lose
    // their animation, and a PDF is not something a canvas can redraw.
    if (file.type.indexOf("image/") !== 0 || file.type === "image/gif") {
        send(file, file.name);
        return;
    }

    var url = URL.createObjectURL(file);
    var img = new Image();
    img.onload = function () {
        var scale = Math.min(1, MAXWIDTH / img.width, MAXHEIGHT / img.height);
        if (scale === 1) { URL.revokeObjectURL(url); send(file, file.name); return; }  // already small enough

        var canvas = document.createElement("canvas");
        canvas.width = Math.round(img.width * scale);
        canvas.height = Math.round(img.height * scale);
        canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
        URL.revokeObjectURL(url);

        canvas.toBlob(function (blob) {
            if (!blob) { send(file, file.name); return; }   // the browser refused: send the original
            send(blob, file.name.replace(/\.[^.]+$/, "") + ".jpg");
        }, "image/jpeg", QUALITY);
    };
    img.onerror = function () { URL.revokeObjectURL(url); callback(null, "not-an-image"); };
    img.src = url;
};

Measured on a 2400 × 1600 photograph: the 3.9 MB original is uploaded as a 1600 × 1067 JPEG of 94 KB, and the document holds the URL your server returned.

Two things to keep in mind. The conversion to JPEG discards transparency, so branch on file.typeif your users paste logos as well as photographs. And re-encoding strips the file's metadata — usually welcome, since that is where a photograph's GPS coordinates live, but not if you rely on it.

When an upload fails

Call the callback with null and a message: callback(null, "http-error-413"). The editor shows The file could not be uploaded. followed by your message in brackets, and the uploadfailedhook fires first, so you can show your own dialog instead. That hook receives your message exactly as you passed it — the raw code, not the sentence around it — so you can branch on it.

A failed upload does not leave the image out: the editor falls back to embedding it in the content as a base64 data: URL, the same as if no handler were configured. An upload endpoint that quietly rejects large files therefore produces exactly the oversized HTML you were trying to avoid. Set maxUploadFileSize to the limit your endpoint enforces, so the file is refused before it is read.

Template JavaScript code

Adapted from the working download handler, this client script shows a progress dialog with a Cancel button, posts the file, and calls back with the URL or the error. It settles once, times out after 60 seconds, provides keyboard cancellation and restores focus. The single-page rte_upload_state counter belongs to this example, not the editor API; isolate it per editor in a multi-editor application.

Code example
var uploadhandlerpath = "/imageupload.ashx";

function rte_file_upload_handler(file, callback, optionalIndex, optionalFiles) {
	var originalCallback = callback;
	var finished = false;
	rte_upload_state.pending++;
	callback = function (url, error) {
		if (finished) return;
		finished = true;
		if (error) rte_upload_state.error = String(error);
		try { originalCallback(url, error); }
		finally { rte_upload_state.pending--; }
	};
	try {

	function append(parent, tagname, csstext) {
		var tag = parent.ownerDocument.createElement(tagname);
		if (csstext) tag.style.cssText = csstext;
		parent.appendChild(tag);
		return tag;
	}

	var uploadcancelled = false;

	var dialogouter = append(document.body, "div", "display:flex;align-items:center;justify-content:center;z-index:2147483646;position:fixed;inset:0;padding:16px;box-sizing:border-box;background-color:rgba(15,23,42,0.28)");
	var dialoginner = append(dialogouter, "div", "box-sizing:border-box;min-width:260px;max-width:calc(100vw - 32px);padding:16px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;box-shadow:0 16px 40px rgba(15,23,42,.2)");

	var line1 = append(dialoginner, "div", "margin:0 0 10px;color:#172033;font:700 15px/1.3 Aptos,Segoe UI,sans-serif;");
	var previousFocus = document.activeElement;
	window.__rteDemoUploadDialogId = (window.__rteDemoUploadDialogId || 0) + 1;
	line1.id = "rte-upload-label-" + window.__rteDemoUploadDialogId;
	dialogouter.setAttribute("role", "dialog");
	dialogouter.setAttribute("aria-modal", "true");
	dialogouter.setAttribute("aria-labelledby", line1.id);
	line1.innerText = "Uploading...";

	var totalsize = file.size;
	var sentsize = 0;

	if (optionalFiles && optionalFiles.length > 1) {
		totalsize = 0;
		for (var i = 0; i < optionalFiles.length; i++) {
			totalsize += optionalFiles[i].size;
			if (i < optionalIndex) sentsize = totalsize;
		}
		line1.innerText = "Uploading..." + (optionalIndex + 1) + "/" + optionalFiles.length;
	}

	var line2 = append(dialoginner, "div", "margin:0 0 8px;color:#52657e;font:600 12px/1.3 Aptos,Segoe UI,sans-serif;");
	line2.innerText = "0%";

	var progressbar = append(dialoginner, "div", "height:8px;overflow:hidden;border-radius:4px;background:#e7eef7;");
	progressbar.setAttribute("role", "progressbar");
	progressbar.setAttribute("aria-label", "File upload progress");
	progressbar.setAttribute("aria-valuemin", "0");
	progressbar.setAttribute("aria-valuemax", "100");
	progressbar.setAttribute("aria-valuenow", "0");
	var progressbg = append(progressbar, "div", "width:0;height:100%;border-radius:inherit;background:#2487e8;transition:width 120ms ease;");

	var line3 = append(dialoginner, "div", "display:flex;justify-content:flex-end;margin-top:14px;");
	var btn = append(line3, "button");
	btn.style.cssText = "min-height:32px;padding:7px 11px;border:1px solid #bdd0e6;border-radius:7px;background:#fff;color:#315277;font:700 12px/1 Aptos,Segoe UI,sans-serif;cursor:pointer;";
	btn.type = "button";
	btn.setAttribute("aria-label", "Cancel file upload");
	dialoginner.addEventListener("keydown", function (event) {
		if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); btn.click(); }
		else if (event.key === "Tab" && !event.ctrlKey && !event.metaKey && !event.altKey) { event.preventDefault(); btn.focus(); }
	});
	btn.focus();
	btn.innerText = "Cancel";
	btn.onclick = function () {
		uploadcancelled = true;
		xh.abort();
	}

	var xh = new XMLHttpRequest();
	xh.timeout = 60000;
	xh.open("POST", uploadhandlerpath + "?name=" + encodeURIComponent(file.name || "edited-image.png") + "&type=" + encodeURIComponent(file.type || "image/png") + "&size=" + file.size, true);
	xh.onload = xh.onabort = xh.onerror = xh.ontimeout = function (pe) {
		if (finished) return;
		var restoreFocus = dialoginner.contains(document.activeElement);
		dialogouter.remove();
		try { if (restoreFocus && previousFocus && previousFocus.isConnected && typeof previousFocus.focus === "function") previousFocus.focus(); } catch (error) { /* Never prevent upload settlement. */ }
		if (pe.type == "load") {
			if (xh.status != 200) {
				if (xh.responseText.startsWith("ERROR:")) {
					callback(null, "http-error-" + xh.responseText.substring(6));
				}
				else {
					callback(null, "http-error-" + xh.status);
				}
			}
			else if (xh.responseText.startsWith("READY:")) {
				var resolved;
				try {
					var value = xh.responseText.substring(6).trim();
					if (!value) throw new Error('Missing URL');
					resolved = new URL(value, new URL(uploadhandlerpath, document.baseURI));
					if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') throw new Error('Invalid URL');
				} catch (error) { callback(null, 'Server returned an invalid image URL'); return; }
				callback(resolved.href);
			}
			else {
				callback(null, "http-error-" + xh.responseText);
			}
		}
		else if (uploadcancelled) {
			callback(null, "cancelled");
		}
		else {
			callback(null, pe.type);
		}
	}
	xh.upload.onprogress = function (pe) {
		if (finished) return;
		//pe.total
		var percent = totalsize > 0 ? Math.max(0, Math.min(100, Math.floor(100 * (sentsize + pe.loaded) / totalsize))) : 0;
		line2.innerText = percent + "%";
		progressbar.setAttribute("aria-valuenow", String(percent));

		progressbg.style.width = percent + "%";
	}
	xh.send(file);
	return function () { if (!finished) { uploadcancelled = true; xh.abort(); } };
	} catch (error) {
		if (finished) throw error;
		var restoreFailedFocus = dialoginner && dialoginner.contains(document.activeElement);
		if (dialogouter && dialogouter.parentNode) dialogouter.parentNode.removeChild(dialogouter);
		try { if (restoreFailedFocus && previousFocus && previousFocus.isConnected && typeof previousFocus.focus === "function") previousFocus.focus(); } catch (focusError) { /* Preserve upload error. */ }
		callback(null, 'Could not start upload');
	}
}

// Single-page demo state, not an editor API.
var rte_upload_state = { pending: 0, error: '' };

var config = {
    file_upload_handler: rte_file_upload_handler,
    maxUploadFileSize: 4000000, // Match your server byte limit.
    allowBase64Images: false
};
var editor = new RichTextEditor("#div_editor", config);

How an uploaded image is aligned

The editor applies no alignment of its own. An inserted image is an inline <img> with no alignattribute and no style, so it sits in the text and follows the alignment of the paragraph that contains it — left, unless that paragraph is centred or right-aligned:

Code example
<!-- what the editor inserts: no align attribute, no style -->
<p>Some text <img src="/imageuploads/20260918-a1b2c3.png" loading="lazy" decoding="async"></p>

<!-- the same image, centred only because its paragraph is centred -->
<p style="text-align:center"><img src="/imageuploads/20260918-a1b2c3.png" loading="lazy" decoding="async"></p>

There is therefore no “default image alignment” setting to change. If images look centred everywhere, the cause is a rule in your own stylesheet, or paragraphs that are centred. Check the HTML you stored: if the <img> carries no alignment, it is your CSS.

Users set alignment per image by selecting it and using the alignment menu on the image toolbar. To change what every image does, write the rule in your own CSS and give the editor the same rule, so the editing view matches the published page:

Code example
var config = {};

// Applies inside the editing area. Use the same rule on the pages that display
// the saved content, or the two will not look alike.
config.contentCssText = "p { text-align: left; } p img { float: none; }";

// Or load one stylesheet in both places:
config.contentCssUrl = "/css/editor-content.css";

Further reading