Add an image gallery to a rich text editor in Next.js

The Insert Gallery dialog in the Next.js sample, showing the blog, products and team folders and four images, one selected

RichTextEditor is a browser script, not a React component, so in a Next.js App Router project it needs a little care: it must load only in the browser, in the right order, and be destroyed when React unmounts it, which in development happens straight after the first mount. This page shows a client component that does that, and the route handler that answers the Insert Gallery dialog.

Options from the server, the editor in the browser

The page is a server component. It holds the script list and the editor options, and passes them as props to a client component:

TypeScript · app/page.tsx
// The editor and rte-files.js are plain browser scripts, served from public/
// (copied there by scripts/copy-editor.mjs).
const scripts = [
  "/richtexteditor/rte.js",
  "/richtexteditor/plugins/all_plugins.js",
  "/rte-files.js",
];

const config = {
  toolbar: "full",
  galleryEndpoint: "/rte/gallery",
  maxUploadFileSize: 4000000,
};

With galleryEndpoint set, the Insert Gallery dialog requests GET /rte/gallery?folder= when it opens. /rte-files.js provides the upload function the dialog's Upload button calls. The editor files themselves are plain files in public/richtexteditor/. The sample's npm run dev and npm run build copy them there first.

The client component

TypeScript · app/editor.tsx
// Loads the scripts one after another, once per page, however often the component mounts.
let loading: Promise<void> | undefined;
function loadScripts(sources: string[]): Promise<void> {
  loading ??= sources.reduce(
    (previous, src) =>
      previous.then(
        () =>
          new Promise<void>((resolve, reject) => {
            const script = document.createElement("script");
            script.src = src;
            script.onload = () => resolve();
            script.onerror = () => reject(new Error("Could not load " + src));
            document.head.appendChild(script);
          }),
      ),
    Promise.resolve(),
  );
  return loading;
}

export default function Editor({ scripts, config, initialHtml = "" }: EditorProps) {
  const container = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let editor: RichTextEditorInstance | undefined;
    let unmounted = false;
    loadScripts(scripts).then(() => {
      if (unmounted || !container.current) return;
      editor = new window.RichTextEditor(container.current, config);
      editor.setHTMLCode(initialHtml);
    });
    // React's development mode mounts twice; destroy the first editor before the second.
    return () => {
      unmounted = true;
      editor?.destroy();
    };
  }, [scripts, config, initialHtml]);

  return <div ref={container} />;
}

Three choices in it:

  • The scripts load one after another, once per page. rte.js must run before all_plugins.js, and both before rte-files.js. The promise is kept in a module variable, so a second mount waits for the same load instead of adding the scripts again.
  • Not next/script with beforeInteractive. The Next.js documentation says those scripts are fetched early, but their execution "does not block page hydration". The effect could then run before window.RichTextEditor exists. Waiting for the scripts' onload avoids that race.
  • The cleanup destroys the editor. In next dev, React Strict Mode mounts the component, unmounts it and mounts it again. The cleanup destroys the first instance before the second is created. Our browser check found exactly one editor on the page in both next dev and next start. Here the scripts usually finish loading only after the first unmount, so the first mount often never creates an editor at all. The unmounted flag covers that case.

The gallery route handler

TypeScript · app/rte/gallery/route.ts
// GET  /rte/gallery?folder=products                      -> listing
// POST /rte/gallery  action=create-folder, folder, name  -> listing of the parent folder
import { GalleryError, checkOrigin, createFolder, errorResponse, listFolder, readForm } from "@/lib/gallery";
import { rootFor } from "@/lib/root-for";

export const runtime = "nodejs"; // the default, written out: this route uses node:fs

export async function GET(request: Request) {
  try {
    const params = new URL(request.url).searchParams;
    if (params.getAll("folder").length > 1) throw new GalleryError(400, "Invalid folder.");
    return Response.json(await listFolder(await rootFor(request), params.get("folder") ?? ""));
  } catch (error) {
    return errorResponse(error);
  }
}

export async function POST(request: Request) {
  try {
    checkOrigin(request);
    const form = await readForm(request);
    if (form.get("action") !== "create-folder") throw new GalleryError(400, "Unknown action.");
    return Response.json(await createFolder(await rootFor(request), form.get("folder") ?? "", form.get("name")));
  } catch (error) {
    return errorResponse(error);
  }
}

GET reads ?folder= with URLSearchParams. A repeated folder parameter is refused with a 400, so the listing only ever gets one value. Route handlers are not cached by default in Next.js 16, and next build lists this one as dynamic (ƒ). The listing is read from disk on every request, so a new upload shows the next time the dialog opens.

The listing itself is plain Node.js in lib/gallery.ts:

TypeScript · lib/gallery.ts
// The JSON the gallery dialog reads (see CONTRACT.md).
export async function listFolder(root: string, folder = "") {
  if (isUsersFolder(root, folder)) throw new GalleryError(404, "Folder not found.");
  const dir = await resolveFolder(root, folder);
  const entries = await readdir(dir, { withFileTypes: true });
  const join = (name: string) => (folder ? folder + "/" + name : name);

  const folders = entries
    .filter((entry) => entry.isDirectory() && isValidName(entry.name) && !isUsersFolder(root, join(entry.name)))
    .map((entry) => ({ folder: join(entry.name), name: entry.name }));

  const images = [];
  for (const entry of entries) {
    if (!entry.isFile() || !isImageName(entry.name)) continue;
    const file = path.join(dir, entry.name);
    const { size } = await lstat(file);
    images.push({ url: publicUrl(file), name: entry.name, folder, size });
  }

  return {
    currentFolder: folder,
    currentFolderDisplay: "/" + folder,
    parentFolder: folder === "" ? null : folder.split("/").slice(0, -1).join("/"),
    folders: folders.sort(byName),
    images: images.sort(byName),
  };
}

// Plain lower-case comparison, so every server sample sorts the same way.
function byName(a: { name: string }, b: { name: string }) {
  const x = a.name.toLowerCase();
  const y = b.name.toLowerCase();
  return x < y ? -1 : x > y ? 1 : 0;
}

It lists image files and valid folder names only, sorts them with a plain lower-case comparison, and builds URLs under /uploads/. Those URLs are served by app/uploads/[...path]/route.ts, not by public/. next start does not serve files that were added to public/ after the build (we got a 404), so new uploads would not appear. Upload images in Next.js covers that route.

What the dialog shows

The picture at the top is the dialog on this sample: three folders and four images at the root. The released gallery's hint lists JFIF, BMP and AVIF among the accepted formats, but this server takes only .jpg, .jpeg, .png, .gif and .webp. A refused file shows the dialog's own message, "Upload failed for <name>.", not the server's text.

Test it

Download the sample project for this guide. Copy the editor's richtexteditor folder to public/richtexteditor, or set RTE_DIR to it. Then:

Shell
npm install
npm run build
npm start

curl -F "file=@photo.jpg" -F "folder=blog" http://localhost:5102/rte/upload
{"url":"/uploads/blog/photo-2cb4607d.jpg"}

curl "http://localhost:5102/rte/gallery?folder=../"
{"error":"Invalid folder."}

Open http://localhost:5102 and click Insert Gallery. Open blog, select the picture and click Insert. The image is in the text with src="/uploads/blog/...".

The same guide for other platforms

JavaScript · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms

Read next