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
errorlike any other failure. Retry runs the whole function again (new URL) — which is what you want; presigned URLs expire. - PUT with XHR so
onProgressworks. AfetchPUT 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.