File Uploads
Slingshot’s built-in upload system handles presigned URL generation, file authorization, and storage routing. Configure a storage adapter and the upload routes mount automatically at startup.
Install
Section titled “Install”For S3 uploads:
bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/lib-storageBasic setup
Section titled “Basic setup”import { defineApp, s3Storage } from '@lastshotlabs/slingshot';
export default defineApp({ port: 3000, upload: { storage: s3Storage({ bucket: process.env.UPLOAD_BUCKET!, region: process.env.AWS_REGION!, }), maxFileSize: 10 * 1024 * 1024, // 10 MB
generateKey: (file, ctx) => { const ext = file.name.split('.').pop(); return `uploads/${ctx.userId}/${Date.now()}.${ext}`; },
authorization: { authorize: async ({ action, key, userId }) => { if (!userId) return false; return key.startsWith(`uploads/${userId}/`); }, }, },});What gets mounted
Section titled “What gets mounted”| Method | Path | Description |
|---|---|---|
POST | /uploads/presign | Get a presigned S3 URL for direct browser upload |
GET | /uploads/presign/:key | Get a presigned read URL for a file |
DELETE | /uploads/:key | Delete a file |
The upload flow
Section titled “The upload flow”Slingshot uses presigned URLs - files go directly from the browser to S3, not through your server:
Browser -> POST /uploads/presign -> Slingshot generates presigned URLBrowser -> PUT <presigned S3 URL> -> S3 stores the file directlyBrowser -> tells your app the key -> app saves it to the databaseLarge files never touch your server, so upload throughput scales independently.
// Browser: get a presigned URLconst { url, key } = await fetch('/uploads/presign', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ name: file.name, type: file.type, size: file.size, }),}).then(r => r.json());
// Browser: upload directly to S3await fetch(url, { method: 'PUT', body: file, headers: { 'Content-Type': file.type },});
// Browser: save the key to your appawait fetch('/api/posts', { method: 'POST', body: JSON.stringify({ title: 'My post', imageKey: key }),});Storage adapters
Section titled “Storage adapters”"storage": { "adapter": "s3", "config": { "bucket": "${env:UPLOAD_BUCKET}", "region": "us-east-1" }}Works with AWS S3 and any S3-compatible service (Cloudflare R2, MinIO, DigitalOcean Spaces).
For Cloudflare R2:
"storage": { "adapter": "s3", "config": { "bucket": "${env:R2_BUCKET}", "region": "auto", "endpoint": "https://<account-id>.r2.cloudflarestorage.com", "accessKeyId": "${secret:R2_ACCESS_KEY_ID}", "secretAccessKey": "${secret:R2_SECRET_ACCESS_KEY}" }}"storage": { "adapter": "local", "config": { "directory": "${importMetaDir}/uploads", "publicPath": "/files" }}Files are served from /files/:key. Good for development and single-server deployments.
"storage": { "adapter": "memory"}Files are stored in-process. Resets on restart. Development and testing only.
File size limits
Section titled “File size limits”"upload": { "maxFileSize": 52428800}Default is 10 MB (10,485,760 bytes). Requests with Content-Length exceeding the limit are rejected before the presigned URL generates.
When upload.allowedMimeTypes is configured, the presigned upload endpoint also enforces that allowlist and requires the client to send mimeType in the presign request. The generated presigned URL carries the effective maximum size, which cannot exceed upload.maxFileSize.
Upload routes also fail closed for stale authenticated sessions when the account is suspended or no longer satisfies a required email-verification policy. That guard still applies when auth.checkSuspensionOnIdentify is explicitly disabled.
Allowed file types
Section titled “Allowed file types”authorization: { authorize: async ({ action, key, userId, file }) => { if (!userId) return false;
// Only allow images and PDFs const allowed = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']; if (!allowed.includes(file.type)) return false;
return key.startsWith(`uploads/${userId}/`); },}Access control patterns
Section titled “Access control patterns”Public files
Section titled “Public files”generateKey: (file, ctx) => `public/${Date.now()}-${file.name}`,authorization: { authorize: ({ action }) => action === 'read', // anyone can read},Organization-scoped files
Section titled “Organization-scoped files”generateKey: (file, ctx) => `orgs/${ctx.organizationId}/${Date.now()}-${file.name}`,authorization: { authorize: async ({ action, key, userId }) => { const orgId = key.split('/')[1]; return checkOrgMembership(userId, orgId); },},Temporary uploads
Section titled “Temporary uploads”Generate a key with a short-lived prefix, then move it to permanent storage after the browser uploads:
generateKey: (file, ctx) => `tmp/${ctx.userId}/${Date.now()}-${file.name}`,Your route handler moves the file to its permanent key using the S3 SDK.
See also
Section titled “See also”- Secrets -
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYsetup - Deployment - S3 bucket config in
slingshot.infra.ts