genesis-editor

Image uploads

Add images by URL, or upload them from the device to your server or your own function.

Clicking an empty image block opens a panel with Upload and Link tabs. Files dropped or pasted into the document upload too. Either way the block ends up with a URL in props.url, so an uploaded image behaves exactly like a linked one.

By URL#

No configuration needed. Use the Link tab, or create image blocks with a url in your content.

Alt text, captions, and alignment#

Select an image to open the formatting toolbar. Alt text edits props.alt (kept apart from the file name). Edit caption edits the figure caption. The alignment buttons set textAlignment so the image sits left, center, or right in the column. All three round-trip through HTML.

Upload to an endpoint#

Set uploadUrl. The editor sends a multipart/form-data POST with the file in a field named file and expects a 2xx JSON response with the image URL:

{ "url": "https://cdn.example.com/images/abc123.png" }

This example posts to the docs site's mock server. Try a file, a drop, or a paste.

A matching server route

// app/api/uploads/route.ts (Next.js)
export async function POST(request: Request) {
  const form = await request.formData();
  const file = form.get("file");
  if (!(file instanceof File)) {
    return Response.json({ error: "Expected a file" }, { status: 400 });
  }
  const url = await saveSomewhere(file); // S3, R2, disk, ...
  return Response.json({ url });
}

A relative url is resolved against uploadUrl. For non-2xx responses the editor shows the response's error field when present. Servers on another origin must allow CORS from your page.

Different response shapes

Pass mapUploadResponse to read the URL from any body. It receives the parsed body (JSON, or text) and the Response. mapResponsePath("data.link") builds the same mapper from a dot path.

<BlockEditor
  uploadUrl="https://api.example.com/uploads"
  mapUploadResponse={(body) => (body as { data: { link: string } }).data.link}
/>

Custom upload function#

For signed URLs, SDKs, or auth headers, pass uploadFile: it receives the File and resolves to its URL. Throw to show an error. uploadFile takes precedence over uploadUrl.

uploadFile also receives { signal, onProgress, blockId, source }. Pass uploadHeaders (an object, or a function returning one) to add auth headers to uploadUrl and uploadFromUrl requests.

Pasted and loaded images#

Images pasted from other sites, or loaded with an HTML value, setHTML or setBlocks, still point at their original host. When an upload destination is configured, the editor copies them to it in the background. The paste is never blocked: blocks appear right away with the original image, a badge shows the progress, and the URL is swapped when the copy is ready. The swap isn't an undo step, so undo never brings the remote URL back, and props.originalUrl keeps the source.

<BlockEditor
  uploadUrl="/api/uploads"                 // files the user adds
  uploadFromUrl="/api/uploads/from-url"    // pasted and loaded images, copied by your server
  uploadHeaders={async () => ({ Authorization: `Bearer ${await getToken()}` })}
  rehostImages="auto"                      // or "off", or (url) => boolean
  uploadConcurrency={4}
/>

With uploadFromUrl the editor sends the image's URL, and your server downloads it, so there's no CORS problem:

// POST /api/uploads/from-url
{ "url": "https://other.site/photo.jpg", "alt": "A photo", "filename": "photo.jpg" }
// → 200 { "url": "https://cdn.example.com/images/def456.jpg" }
A server that fetches URLs for users must block SSRF: refuse private, loopback and link-local addresses (also after redirects), cap the size, and check the content type. The editor only ever sends http(s) URLs. The docs site's mock endpoint shows one way to do it.

Without a URL uploader, the browser downloads the image and sends it through uploadFile or uploadUrl. That only works when the image's host allows CORS; otherwise the image keeps its URL. Pasted data: images are always sent as files. With no destination at all they stay inline up to 1 MB, and larger ones are left out of the paste with a warning in the import report.

For custom storage, uploadRemote(url, ctx) replaces uploadFromUrl, the same way uploadFile replaces uploadUrl:

<BlockEditor
  uploadFile={(file, { signal, onProgress }) => uploadToS3(file, { signal, onProgress })}
  uploadRemote={(url, { signal }) => copyToS3(url, { signal })}
/>

Up to uploadConcurrency copies (4 by default) run at once, in document order, each with a 30 s timeout. The same image pasted twice is copied once. A copy is aborted when its block is deleted or the editor unmounts. rehostImages="auto" skips images on your page's origin, on your upload endpoints' hosts and on hosts earlier uploads returned.

Images that are already on your storage are never copied. Direct uploads and finished copies set props.hosted, which HTML export writes as data-genesis-hosted (copies also carry data-original-src), so loading the document again in a later session leaves them alone. Giving the image a different URL clears the flag. For images your storage serves from other hosts, such as a CDN, list them in trustedHosts; their images are never re-hosted, whatever rehostImages says:

<BlockEditor
  uploadUrl="/api/uploads"
  uploadFromUrl="/api/uploads/from-url"
  trustedHosts={["cdn.example.com", "*.assets.example.com"]}   // or (url) => boolean
/>

Paste the article below. The mock server waits 1.5 s per image so you can see the badges; tick "Make the server fail" first to see the retry. "Save when uploads finish" waits with uploadsSettled() before reading the HTML.

Saving after uploads

ref.current.pendingUploads counts the file uploads and copies still running, and await ref.current.uploadsSettled() waits for all of them, so a save stores the final URLs. Failed copies count as settled.

const editor = useRef<BlockEditorHandle>(null);

async function save() {
  await editor.current?.uploadsSettled(); // final image URLs, not the remote ones
  await api.save(editor.current?.getHTML());
}

On the server

For bulk imports, parseHTMLAsync from genesis-editor/parser copies the images before it returns, with the same queue, retry and deduplication.

import { parseHTMLAsync } from "genesis-editor/parser";
import { linkedomDom } from "genesis-editor/parser/linkedom";

const { blocks, report } = await parseHTMLAsync(html, {
  dom: linkedomDom,
  uploadRemote: (url, { signal }) => copyToStorage(url, { signal }),
});
report.images.rehosted;     // copied
report.images.rehostFailed; // kept their original URL

Errors and retries#

While an upload runs the block shows a spinner. If it fails, the block goes back to "Add image" and its panel shows the error with a Retry button that re-sends the same file. A failed copy of a pasted image is retried once automatically on network errors and 5xx responses (not on 4xx); then the image keeps its original URL and shows Not re-uploaded · Retry. onUploadError receives { kind: "file", error, file, blockId, retry } or { kind: "rehost", error, url, blockId, retry } for your own toast or logging.

Without a destination#

With neither uploadUrl nor uploadFile, files are embedded in the document as data: URLs, up to 2 MB each, and the Upload tab says so. That's handy for prototypes; configure a destination for real use.