Skip to content

S3 presigned URL

Direct-to-bucket uploads keep large files off your app server. The browser asks your API for a short-lived URL, then PUTs the object. Wrap both steps in one adapter so the queue, retry, and cancel still apply to the whole operation.

ts
import type { UploadAdapter } from 'react-upload-kit';

interface S3UploadResponse {
  key: string;
  url: string;
}

export const s3Adapter: UploadAdapter<S3UploadResponse> = async (file, { onProgress, signal }) => {
  const presign = await fetch('/api/uploads/presign', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      fileName: file.name,
      contentType: file.type,
      size: file.size,
    }),
    signal,
  });

  if (!presign.ok) {
    throw new Error(`Presign failed: ${presign.status}`);
  }

  const { uploadUrl, key, publicUrl } = (await presign.json()) as {
    uploadUrl: string;
    key: string;
    publicUrl: string;
  };

  await putWithProgress(uploadUrl, file, { onProgress, signal });

  return { key, url: publicUrl };
};

function putWithProgress(
  url: string,
  file: File,
  { onProgress, signal }: { onProgress: (n: number) => void; signal: AbortSignal },
) {
  const xhr = new XMLHttpRequest();

  return new Promise<void>((resolve, reject) => {
    const onAbort = () => {
      xhr.abort();
      reject(new DOMException('Upload aborted', 'AbortError'));
    };

    if (signal.aborted) {
      onAbort();
      return;
    }

    signal.addEventListener('abort', onAbort, { once: true });

    xhr.upload.addEventListener('progress', (event) => {
      if (event.lengthComputable) {
        onProgress(Math.round((event.loaded / event.total) * 100));
      }
    });

    xhr.addEventListener('load', () => {
      signal.removeEventListener('abort', onAbort);
      if (xhr.status >= 200 && xhr.status < 300) resolve();
      else reject(new Error(`S3 PUT failed: ${xhr.status}`));
    });

    xhr.addEventListener('error', () => {
      signal.removeEventListener('abort', onAbort);
      reject(new Error('Network error'));
    });

    xhr.open('PUT', url);
    xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
    xhr.send(file);
  });
}

Why this shape

  • Presign on the server. The browser never sees AWS keys. The API checks auth, size, and content type before minting the URL.
  • One adapter, two HTTP calls. If presign fails, the file goes to error like any other failure. Retry runs the whole function again (new URL) — which is what you want; presigned URLs expire.
  • PUT with XHR so onProgress works. A fetch PUT would skip the bar.

Pass signal into the presign fetch as well. Cancelling mid-presign should not start the PUT.

CORS: the bucket (or CloudFront) must allow PUT from your origin, including the Content-Type header you send. That is an AWS/CORS config issue, not a library one.

For multipart / resumable uploads of multi-GB files, wrap a tus or AWS multipart client in the same UploadAdapter signature instead of a single PUT.

Released under the MIT License.