An S3 upload SDK that writes to your own bucket
Run the UploadKit handler in your app, give it your S3, Cloudflare R2 or MinIO credentials, and browsers upload straight to your bucket with presigned URLs. The React components don’t change.
Presigned URLs, signed with your credentials
BYOS is the self-hosted path. You run createUploadKitHandler yourself and pass a storage option.
When a user picks a file, the React component asks your route handler for permission to upload. The handler checks the request against your file router (including the allowed content types), builds an object key from the route name, a random id and a sanitized file name, and signs a presigned PUT URL with the AWS S3 client using your credentials. The browser then sends the file directly to the bucket. The file bytes never pass through your server.
The URL is scoped to one key, one content type and one content length, and the default expiry in the handler code is 900 seconds. After the upload, the component calls the handler again and your onUploadComplete callback runs on your server. If you want the trade-offs of this design in detail, see presigned URLs vs server proxy uploads.
Browser Your server Your S3 bucket
| "Upload imageUploader" --->| |
| | signs a presigned URL -------->|
| <--- presigned URL --------| |
| PUT file -----------------------------------------------> |
| "Done" ------------------->| runs onUploadComplete |Your accessKeyId and secretAccessKey stay in server-side environment variables. Keep the storage config out of client components and out of NEXT_PUBLIC_ variables.
Config for Cloudflare R2 and AWS S3
Both snippets come from the BYOS docs. Put the route in app/api/uploadkit/route.ts.
Cloudflare R2
import { createUploadKitHandler } from '@uploadkitdev/next';
import type { FileRouter } from '@uploadkitdev/next';
const router = {
imageUploader: {
maxFileSize: '4MB',
allowedTypes: ['image/*'],
onUploadComplete: async ({ file }) => {
console.log('Stored in your R2 bucket:', file.url);
},
},
} satisfies FileRouter;
export const { GET, POST } = createUploadKitHandler({
router,
storage: {
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
region: 'auto',
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
bucket: process.env.R2_BUCKET_NAME!,
},
});R2_ACCOUNT_ID=your_cloudflare_account_id
R2_ACCESS_KEY_ID=your_r2_access_key_id
R2_SECRET_ACCESS_KEY=your_r2_secret_access_key
R2_BUCKET_NAME=your-bucket-nameAWS S3
import { createUploadKitHandler } from '@uploadkitdev/next';
import type { FileRouter } from '@uploadkitdev/next';
const router = {
documentUploader: {
maxFileSize: '16MB',
allowedTypes: ['application/pdf'],
},
} satisfies FileRouter;
export const { GET, POST } = createUploadKitHandler({
router,
storage: {
region: process.env.AWS_REGION!,
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
bucket: process.env.AWS_S3_BUCKET!,
},
});For S3, the docs recommend an IAM user with s3:PutObject, s3:GetObject, s3:DeleteObject and s3:AbortMultipartUpload on the bucket. Because the browser PUTs to the bucket origin, the bucket also needs a CORS rule that allows your site.
The same React components in both modes
Storage config lives in the server handler, so the UI code is identical.
UploadButton, UploadDropzone and useUploadKit work the same whether files go to UploadKit’s managed storage or your bucket. The only difference is that the URLs you get back point at your storage.
<UploadDropzone
route="imageUploader"
onUploadComplete={(files) => {
// files[0].url points to YOUR bucket, not UploadKit's managed storage
saveToDatabase(files[0].url);
}}
/>Next.js, Express, Hono and Fastify
The adapters wrap the same handler, so the storage option works the same way.
Next.js route handler
createUploadKitHandler from @uploadkitdev/next returns GET and POST for an App Router route.
Express, Hono, Fastify
createExpressHandler, createHonoHandler and createFastifyHandler are exported from @uploadkitdev/next/express, /hono and /fastify.
S3-compatible providers
The handler uses the AWS S3 client with an optional custom endpoint. The docs show endpoints for R2, AWS S3, MinIO, DigitalOcean Spaces, Wasabi and Backblaze B2.
Google Cloud Storage factory
createGCSStorage defaults the endpoint to https://storage.googleapis.com (S3 interoperability). The BYOS docs don’t include a GCS walkthrough, so test it against your own bucket.
import { createHonoHandler } from '@uploadkitdev/next/hono';
app.all('/api/uploadkit/*', createHonoHandler({ router, storage }));Here storage is the same object you would pass to createUploadKitHandler. See the backend adapters docs for Express and Fastify.
Managed storage or your own bucket
From the BYOS docs. Pick one per deployment.
| Feature | BYOS (self-hosted) | Managed (UploadKit Cloud) |
|---|---|---|
| Storage location | Your own bucket | UploadKit’s Cloudflare R2 |
| Setup | Bucket config, around 10 minutes per the docs | Works out of the box |
| Credentials | You manage IAM or API keys | UploadKit handles them |
| CDN | Configure separately | Included (Cloudflare) |
| Cost | Your provider’s storage rates | Included in the UploadKit plan |
| Lock-in | Switch providers freely | Files live in UploadKit storage |
Choose managed if you want no infrastructure to run and the Free or Pro limits fit: Free includes 5 GB of storage and a 4 MB max file, Pro is $15/mo with 100 GB and 512 MB per file. See pricing. Choose BYOS if you need data to stay in a bucket you control, already have storage infrastructure, have compliance requirements, or want your own provider’s rates for high-volume storage. For provider prices, read S3 vs R2 vs Backblaze B2 pricing.
UploadThing stores files on its managed infrastructure (source, checked October 2026). The UploadKit vs UploadThing page covers the comparison.
Frequently asked questions
Can I use BYOS with a UploadKit Cloud project?
No. UploadKit Cloud keys (uk_live_* and uk_test_*) always store files on UploadKit’s managed R2 and CDN, and there is no BYOS toggle in project settings. BYOS is the self-hosted path: you run the handler yourself and pass a storage option. You pick one path per deployment. BYOS docs
Do my bucket credentials ever reach the browser?
No. The access key and secret live in your server environment. The handler uses them to sign a presigned PUT URL, and the browser only receives that URL.
How long is a presigned URL valid?
The default in the handler code is 900 seconds (15 minutes). Each URL is signed for one object key, one content type and one content length.
Which storage providers work?
The handler builds an AWS S3 client with an optional custom endpoint, so S3-compatible APIs work. The docs list configs for Cloudflare R2, AWS S3 and MinIO, and also show endpoints for DigitalOcean Spaces, Wasabi and Backblaze B2. The package also exports a Google Cloud Storage factory that points at the S3 interoperability endpoint.
Do I need to change my React components?
No. UploadButton, UploadDropzone and useUploadKit behave the same in managed and BYOS mode. Only the server handler changes, and the returned file URLs point at your bucket.
Can I use BYOS outside Next.js?
Yes. The package ships adapters for Express, Hono and Fastify that wrap the same handler config, so a storage option works there too. Backend adapters
Keep your bucket, keep your components
Same SDK and components whether files land in UploadKit’s storage or in your S3, R2 or MinIO bucket.