Upload images from a rich text editor in Node.js

When someone drops, pastes or picks an image in RichTextEditor, the editor hands the file to an upload function and waits for a URL. This page gives you both halves for Node.js: the browser file that sends the image, and an Express 5 route that checks it with multer, stores it under a new name and answers with the URL the editor inserts.
The browser side
The editor does not upload anything by itself. It calls window.rte_file_upload_handler for drag and drop, paste and the Insert Image dialog, and window.richTextBoxUploadFile for uploads from the gallery dialog, which also passes the folder that is open. rte-files.js defines both. It posts the file as multipart/form-data to /rte/upload and calls back with the URL, or with the server's error text. Load it after the editor scripts:
<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css">
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<script src="/rte-files.js"></script><div id="editor"><p>Hello from Express.</p></div>
<script>
var editor = new RichTextEditor("#editor", {
toolbar: "full",
galleryEndpoint: "/rte/gallery",
maxUploadFileSize: 4000000
});
</script>maxUploadFileSize lets the gallery dialog refuse a file that is too large before it is sent. The server must still check the size itself.
The route
multer is the multipart parser most Express apps already use. The sample uses its memory storage, so the file arrives as req.file.buffer and its first bytes can be checked before anything is written to disk. Memory storage is only safe with limits: multer's own defaults for fileSize, files and parts are Infinity.
// multer is the multipart parser most Express apps already use. Memory storage keeps the
// file in req.file.buffer, so its bytes can be checked before anything touches the disk;
// the limits keep that buffer small. multer allows exactly fileSize bytes and fails with
// LIMIT_FILE_SIZE above that (turned into a 413 in the error handler below).
const parseForm = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_BYTES, files: 1, fields: 10, fieldSize: 1024, parts: 12 },
defParamCharset: "utf8", // file names are sent as UTF-8; multer's default is latin1
});One option here is easy to miss. multer's defParamCharset defaults to 'latin1', but browsers send file names as UTF-8. With the default, a file called café.png arrived as café.png in our test. With "utf8" it arrives intact, and the sample stores it as cafe-1a2b3c4d.png.
The routes themselves are short. A small middleware picks the storage root first, then multer parses the form and the handler passes the bytes on:
// Every /rte request gets its storage root first, before multer reads the body.
app.use("/rte", (req, res, next) => {
req.storageRoot = rootFor(req);
next();
});
// GET /rte/gallery?folder=products -> listing
app.get("/rte/gallery", async (req, res) => {
res.json(await listFolder(req.storageRoot, req.query.folder ?? ""));
});
// POST /rte/gallery action=create-folder, folder, name -> listing of the parent folder
app.post("/rte/gallery", parseForm.none(), async (req, res) => {
const { action, folder, name } = req.body ?? {};
if (action !== "create-folder") throw new GalleryError(400, "Unknown action.");
res.json(await createFolder(req.storageRoot, folder ?? "", name));
});
// POST /rte/upload file, folder -> { url }
app.post("/rte/upload", parseForm.single("file"), async (req, res) => {
if (!req.file) throw new GalleryError(400, "No file was uploaded.");
const url = await saveImage(req.storageRoot, req.body.folder ?? "", req.file.originalname, req.file.buffer);
res.json({ url });
});Saving the file
saveImage() in gallery.mjs has no Express in it. It checks the extension and the first bytes, makes sure the folder exists inside the storage root, and writes the file under a generated name:
// Checks the name and the bytes, stores the file under a new random name, returns its URL.
export async function saveImage(root, folder, originalName, bytes) {
const ext = extensionOf(originalName);
const matches = Object.hasOwn(SIGNATURES, ext) && SIGNATURES[ext];
if (!matches) {
throw new GalleryError(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
}
if (bytes.length > MAX_BYTES) {
throw new GalleryError(413, "The image is larger than 4,000,000 bytes.");
}
if (!matches(bytes)) {
throw new GalleryError(400, "The file content is not a valid " + ext.slice(1).toUpperCase() + " image.");
}
if (isUsersFolder(root, folder)) throw new GalleryError(404, "Folder not found.");
const dir = await resolveFolder(root, folder);
const storedName = baseNameOf(originalName) + "-" + randomBytes(4).toString("hex") + ext;
const file = path.join(dir, storedName);
await writeFile(file, bytes, { flag: "wx" }); // "wx" fails instead of overwriting
return publicUrl(file);
}The stored name is the cleaned-up original name plus eight random hex characters, so two uploads of photo.jpg never overwrite each other. The "wx" flag makes writeFile fail if the name exists anyway. The extension is lower-cased: photo.JPG is stored as photo-….jpg.
The answer is {"url":"/uploads/blog/photo-ce99c014.jpg"}. The editor inserts that URL as the image's src, and express.static serves the file from uploads/. Which files it serves, and why only images, is covered in secure image uploads in Node.js.
When something goes wrong
The route handlers have no try/catch. In Express 5, a rejected promise from an async handler goes to the error middleware, which turns the sample's own errors and multer's errors into JSON:
// Express 5 sends errors thrown in async handlers here, so the routes need no try/catch.
app.use((err, req, res, next) => {
if (err instanceof GalleryError) {
return res.status(err.status).json({ error: err.message });
}
if (err instanceof multer.MulterError) {
const tooBig = err.code === "LIMIT_FILE_SIZE";
return res
.status(tooBig ? 413 : 400)
.json({ error: tooBig ? "The image is larger than 4,000,000 bytes." : "Invalid upload: " + err.message });
}
console.error(err);
res.status(500).json({ error: "Server error." });
});This is an Express 5 behaviour. We ran the same kind of async handler on Express 4.22: the error was an unhandled rejection, and on Node 24 the whole process exited. On Express 4 you need a wrapper or a try/catch in every async route.
rte-files.js passes the JSON error text back to the editor as the second callback argument. The gallery dialog does not display it: it shows its own message, "Upload failed for holiday.jpg.", whatever the server answered.
Test it
Download the sample project for this guide, put the richtexteditor folder from the editor download next to server.mjs, and run npm install and npm start. The server listens on 127.0.0.1:5101, so only your own machine can reach it. Then, from a folder with a photo in it:
curl -F "file=@photo.jpg" -F "folder=blog" http://localhost:5101/rte/upload
{"url":"/uploads/blog/photo-ce99c014.jpg"}
printf '<?php echo 1; ?>' > fake.jpg
curl -F "file=@fake.jpg" http://localhost:5101/rte/upload
{"error":"The file content is not a valid JPG image."}The second answer is an HTTP 400. Open http://localhost:5101, drop an image into the editor and it appears in uploads/ and in the text. To check every rule at once, run node shared/contract-test.mjs http://localhost:5101. It ends with ALL 35 CHECKS PASSED.
The same guide for other platforms
Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms