Skip to content
EditorStackPlayground

Upload GrapesJS assets straight to S3 with presigned URLs

Replace the Asset Manager's default multipart upload with a custom uploadFile that asks your server for a presigned S3 PUT URL and sends the file directly to the bucket. Tested with AWS SDK v3 presigning, a cross-origin CORS preflight and GrapesJS 0.23.5.

Updated · Tested with GrapesJS 0.23.5

TL;DR

  • Set assetManager.uploadFile: GrapesJS passes the drop or file-input event, you upload however you like, then add the resulting URLs with editor.AssetManager.add().
  • The server validates type and size, picks a random key and returns a presigned PUT URL from @aws-sdk/s3-request-presigner; the file never passes through your server.
  • By default the presigned URL signed only the host header in our test; passing signableHeaders with content-type binds the upload to the validated type. The declared size is still not enforced by a presigned PUT.

The flow

  1. The user drops an image on the Asset Manager or picks one with its file input.
  2. Your uploadFile function receives that event and asks your server for an upload URL.
  3. The server checks the type and size, creates a random object key and returns a presigned PUT URL plus the public URL the object will have.
  4. The browser PUTs the file to S3 with that URL.
  5. uploadFile adds the public URL to the Asset Manager.

Asset handling is one of the larger line items in our build versus buy estimate, and this covers only its upload step.

The presign route

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
presign.mjs
import { randomUUID } from 'node:crypto';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

/** SVG is excluded on purpose: an uploaded SVG can carry script. */
const ALLOWED_TYPES = {
  'image/png': '.png',
  'image/jpeg': '.jpg',
  'image/webp': '.webp',
  'image/gif': '.gif',
};

/**
 * POST /api/uploads/presign  ← { contentType, size }
 *                            → { uploadUrl, publicUrl, key }
 *
 * The browser then PUTs the file straight to S3; the bytes never pass through this server.
 */
export function createPresignRoute({ s3, bucket, publicBaseUrl, maxBytes = 5 * 1024 * 1024, expiresIn = 60 }) {
  return async function handlePresign(req, res) {
    if (req.method !== 'POST' || new URL(req.url ?? '/', 'http://local').pathname !== '/api/uploads/presign') return false;

    const send = (status, body) => {
      res.writeHead(status, { 'content-type': 'application/json' });
      res.end(JSON.stringify(body));
    };

    // Authenticate the user here: a presign endpoint without auth is a free file host.

    let input;
    try {
      const chunks = [];
      for await (const chunk of req) chunks.push(chunk);
      input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
    } catch {
      send(400, { error: 'Expected a JSON body' });
      return true;
    }

    const extension = ALLOWED_TYPES[input?.contentType];
    if (!extension) {
      send(415, { error: `Unsupported type: ${String(input?.contentType)}` });
      return true;
    }
    if (!Number.isInteger(input.size) || input.size <= 0 || input.size > maxBytes) {
      send(413, { error: `File must be between 1 byte and ${maxBytes} bytes` });
      return true;
    }

    // Never use the client's filename as the key.
    const key = `uploads/${randomUUID()}${extension}`;
    try {
      const uploadUrl = await getSignedUrl(
        s3,
        new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: input.contentType }),
        // By default only `host` is signed. Signing content-type means the PUT must send the
        // type that was validated above.
        { expiresIn, signableHeaders: new Set(['content-type']) },
      );
      send(200, { uploadUrl, publicUrl: `${publicBaseUrl}/${key}`, key });
    } catch (error) {
      console.error('presign failed', error);
      send(500, { error: 'Could not create an upload URL' });
    }
    return true;
  };
}

The decisions in it:

  • An allowlist of image types, without SVG. An SVG file can contain script. If you need SVG, sanitise it server-side after upload or serve it from a separate domain.
  • A random key. Client filenames collide, leak information and sometimes contain path tricks.
  • expiresIn: 60. The URL is used immediately; a minute is plenty.
  • signableHeaders. Without it, the URL we generated in testing had X-Amz-SignedHeaders=host, meaning the signature did not cover the Content-Type the route had just validated. With content-type in signableHeaders the signed headers became content-type;host, so the upload has to send the same type.

The editor

editor.ts
import grapesjs, { type Editor } from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';

type Presigned = { uploadUrl: string; publicUrl: string };

async function uploadOne(file: File): Promise<string> {
  const presign = await fetch('/api/uploads/presign', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ contentType: file.type, size: file.size }),
  });
  if (!presign.ok) throw new Error((await presign.json()).error ?? `Presign failed (${presign.status})`);
  const { uploadUrl, publicUrl }: Presigned = await presign.json();

  // content-type is part of the signature (see presign.mjs), so it must match what was validated.
  const put = await fetch(uploadUrl, { method: 'PUT', headers: { 'content-type': file.type }, body: file });
  if (!put.ok) throw new Error(`Upload to storage failed (${put.status})`);
  return publicUrl;
}

function filesFrom(event: DragEvent): File[] {
  const list = event.dataTransfer?.files ?? (event.target as HTMLInputElement | null)?.files;
  return list ? Array.from(list) : [];
}

const editor: Editor = grapesjs.init({
  container: '#gjs',
  height: '100vh',
  storageManager: false,
  projectData: { pages: [{ component: '<section><img alt="Hero image" style="width:320px"/></section>' }] },
  assetManager: {
    // Replaces the built-in multipart POST: GrapesJS hands us the drop/change event.
    uploadFile: async (event, done) => {
      const files = filesFrom(event);
      if (!files.length) return;
      try {
        const urls = await Promise.all(files.map(uploadOne));
        const assets = files.map((file, i) => ({ type: 'image', src: urls[i]!, name: file.name }));
        editor.AssetManager.add(assets);
        // Same contract as the built-in uploader, which calls this with the server's { data } response.
        done?.({ data: assets });
      } catch (caught) {
        const error = caught instanceof Error ? caught : new Error(String(caught));
        editor.log(error.message, { ns: 'asset-upload', level: 'error' });
        editor.trigger('asset:upload:error', error);
      }
    },
  },
});

editor.on('asset:upload:error', (error) => {
  const status = document.getElementById('upload-status');
  if (status) status.textContent = `Upload failed: ${error instanceof Error ? error.message : String(error)}`;
});

// Exposed for the test harness.
Object.assign(window, { editor });

GrapesJS calls uploadFile with the change event from its file input or the drop event from its dropzone, which is why filesFrom reads both dataTransfer.files and target.files. The second argument, done, is an optional callback; GrapesJS's built-in uploader calls it with the parsed server response, whose data array holds the assets, so the example calls it the same way. GrapesJS's typings note that a custom uploadFile takes over the asset:upload:* events too, which is why the error path triggers asset:upload:error itself.

What our test checks

The server route runs with a real S3Client pointed at a stand-in bucket: a small HTTP server on a different origin from the editor that answers CORS preflights, accepts a PUT only when the URL carries a SigV4 query signature, stores the bytes and serves them back. It does not validate the signature cryptographically; that is AWS's job and was not tested. The test asserts that:

  1. setting a PNG on the Asset Manager's file input runs the custom uploadFile;
  2. the browser sends a CORS preflight, then exactly one PUT with Content-Type: image/png;
  3. the presigned URL expires in 60 seconds and signs content-type;host;
  4. the stored bytes equal the uploaded file, the asset appears in the Asset Manager with a URL under uploads/, and the image loads back from the bucket;
  5. an SVG is rejected by the presign route with 415, the message reaches the page, and nothing is sent to the bucket.

Limitations

  • Serving the files. publicUrl assumes objects are readable at that URL, through a public bucket policy or a CDN in front of the bucket. Setting that up is not covered.
  • File size is not enforced by S3 here. See the FAQ.
  • No image processing. No resizing, no format conversion, no thumbnails. Uploaded files are used as-is.
  • No deletion. Removing an asset in GrapesJS does not delete the object from the bucket.
  • Authentication. The presign route must check who is asking; it is marked in the code but not implemented.

Assets are referenced from project JSON by URL, so they persist with the project described in saving to a database. If you would rather not build asset storage at all, the commercial GrapesJS Studio SDK includes it, and GrapesJS vs Studio SDK weighs that choice.

Frequently asked questions

Why not use the Asset Manager's built-in upload option?
The built-in upload POSTs multipart form data to your endpoint, so every byte passes through your server. With presigned URLs the browser sends the file to S3 directly and your server only signs a short-lived URL. Both are valid; the second scales without upload bandwidth on your servers.
Does a presigned PUT limit the file size?
Not by itself. In our test the signed headers were content-type and host; content-length was not part of the signature, so the size the client declared is only checked by your presign route. For a hard limit, use a presigned POST policy with a content-length-range condition, or check object size after upload. We did not test either.
What CORS configuration does the bucket need?
The browser sends a preflight before the PUT, because PUT with a Content-Type of image/png is not a simple request. The bucket must allow the PUT method and the content-type header from your editor's origin. Our test runs a stand-in bucket on a different origin and asserts the preflight happens.
Does this work with S3-compatible storage like Cloudflare R2 or MinIO?
The code uses standard SigV4 presigning with a configurable endpoint, which S3-compatible services generally accept, but we only ran it against our stand-in bucket, not against AWS, R2 or MinIO.

Sources

  1. GrapesJS documentation: Asset Manager — accessed 2026-09-17
  2. GrapesJS API reference: Asset Manager — accessed 2026-09-17
  3. Amazon S3 User Guide: Uploading objects with presigned URLs — accessed 2026-09-17
  4. Amazon S3 User Guide: Using cross-origin resource sharing (CORS) — accessed 2026-09-17
  5. npm registry metadata for @aws-sdk/s3-request-presigner — accessed 2026-09-17

Written by the EditorStack Research Team. How we test and what we refuse to publish is on the methodology page.