# Upload GrapesJS assets straight to S3 with presigned URLs

*Source: https://www.editorstack.cc/guides/grapesjs-asset-manager-s3 — last updated 2026-09-17. Licensed CC BY 4.0 with attribution to EditorStack.*

*Tested with GrapesJS 0.23.5. The code on this page runs in CI from examples/guides/grapesjs-asset-manager-s3 in the site repository.*

## Summary

- 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](/use-cases/build-vs-buy-visual-editor), and this covers only its upload
step.

## The presign route

```bash
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
```

```js title="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

```ts title="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.

> **Sponsored — GJS.Market:** Asset and media plugins for GrapesJS — GJS.Market lists GrapesJS asset and media libraries, if you need more than an upload button. GJS.Market funds this site; check each plugin's GrapesJS version support.

## 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](/guides/grapesjs-save-load-database). If you would rather not build asset
storage at all, the commercial [GrapesJS Studio SDK](/libraries/grapesjs-studio-sdk) includes it,
and [GrapesJS vs Studio SDK](/compare/grapesjs-vs-grapesjs-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](https://grapesjs.com/docs/modules/Assets.html) — accessed 2026-09-17
2. [GrapesJS API reference: Asset Manager](https://grapesjs.com/docs/api/assets.html) — accessed 2026-09-17
3. [Amazon S3 User Guide: Uploading objects with presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html) — accessed 2026-09-17
4. [Amazon S3 User Guide: Using cross-origin resource sharing (CORS)](https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html) — accessed 2026-09-17
5. [npm registry metadata for @aws-sdk/s3-request-presigner](https://registry.npmjs.org/@aws-sdk/s3-request-presigner) — accessed 2026-09-17
