# Helpers (/docs/helpers-client) ## Format bytes Format a number of bytes into a human-readable string. ```ts import { formatBytes } from '@better-upload/client/helpers'; formatBytes(1000); // "1 kB" formatBytes(1000, { decimalPlaces: 2 }); // "1.00 kB" formatBytes(1024, { si: false }); // "1 KiB" formatBytes(1024, { decimalPlaces: 2, si: false }); // "1.00 KiB" ``` # Helpers (/docs/helpers-server) ## S3 Clients Better Upload has built-in clients for popular S3-compatible storage services, like AWS S3 and Cloudflare R2. Suggest a new client by [opening an issue](https://github.com/Nic13Gamer/better-upload/issues). AWS S3 Cloudflare R2 Tigris Backblaze B2 DigitalOcean Spaces Wasabi MinIO Custom ```ts import { aws } from '@better-upload/server/clients'; const s3 = aws({ accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', region: 'us-east-1', }); ``` ```ts import { cloudflare } from '@better-upload/server/clients'; const s3 = cloudflare({ accountId: 'your-account-id', accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', }); ``` ```ts import { tigris } from '@better-upload/server/clients'; const s3 = tigris({ accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', endpoint: '...', // optional }); ``` ```ts import { backblaze } from '@better-upload/server/clients'; const s3 = backblaze({ region: 'your-backblaze-region', applicationKeyId: 'your-application-key-id', applicationKey: 'your-application-key', }); ``` ```ts import { digitalOcean } from '@better-upload/server/clients'; const s3 = digitalOcean({ region: 'your-spaces-region', key: 'your-spaces-key', secret: 'your-spaces-secret', }); ``` ```ts import { wasabi } from '@better-upload/server/clients'; const s3 = wasabi({ region: 'your-wasabi-region', accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', }); ``` ```ts import { minio } from '@better-upload/server/clients'; const s3 = minio({ region: 'your-minio-region', endpoint: 'https://minio.example.com', accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', }); ``` ```ts // for any S3-compatible service import { custom } from '@better-upload/server/clients'; const s3 = custom({ host: 's3.us-east-1.amazonaws.com', accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', region: 'us-east-1', secure: true, forcePathStyle: false, }); ``` You can omit the parameters and let the client get your credentials from environment variables. ## Objects Helpers for working with objects in S3-compatible storage services. Suggest new helpers by [opening an issue](https://github.com/Nic13Gamer/better-upload/issues). ### Presign get object Generate a pre-signed URL to download an object. ```ts import { presignGetObject } from '@better-upload/server/helpers'; const url = await presignGetObject(s3, { bucket: 'my-bucket', key: 'example.png', expiresIn: 3600, // 1 hour }); ``` ### Get object Get an object (including its content) from an S3 bucket. ```ts import { getObject } from '@better-upload/server/helpers'; const object = await getObject(s3, { bucket: 'my-bucket', key: 'example.png', }); ``` ### Head object Get metadata about an object without fetching its content. ```ts import { headObject } from '@better-upload/server/helpers'; const object = await headObject(s3, { bucket: 'my-bucket', key: 'example.png', }); ``` ### Move object (rename) Move an object from one key to another, within or between, S3 buckets. Also known as renaming. ```ts import { moveObject } from '@better-upload/server/helpers'; await moveObject(s3, { source: { bucket: 'source-bucket', key: 'example.png', }, destination: { bucket: 'destination-bucket', key: 'copy.png', }, }); ``` This copies the object to the new location and then deletes the original object. It can be slow. ### Copy object Copy an object, within or between, S3 buckets. ```ts import { copyObject } from '@better-upload/server/helpers'; await copyObject(s3, { source: { bucket: 'source-bucket', key: 'example.png', }, destination: { bucket: 'destination-bucket', key: 'images/example.png', }, }); ``` ### Delete object Delete an object from an S3 bucket. ```ts import { deleteObject } from '@better-upload/server/helpers'; await deleteObject(s3, { bucket: 'my-bucket', key: 'example.png', }); ``` # Client Hooks (/docs/hooks-multiple) Better Upload provides React hooks that allow you to easily upload files using pre-signed URLs. Multipart uploads are managed automatically, in case you enable them in the server. ## Usage ```tsx title="uploader.tsx" import { useUploadFiles } from '@better-upload/client'; export function Uploader() { const { upload, uploadAsync, reset, uploadedFiles, failedFiles, progresses, isPending, isAborted, isSettled, isError, allSucceeded, hasFailedFiles, averageProgress, error, metadata, control, // for use in pre-built components } = useUploadFiles({ route: 'images', }); return ( { if (e.target.files) { upload(e.target.files); } }} /> ); } ``` If your upload route handler is not located at `/api/upload`, you need to specify the correct path in the `api` option. ### Options The `useUploadFiles` hook accepts the following options: Can be include, same-origin, or{' '} omit. ), required: false, }, retry: { description: 'Number of times to retry network requests that fail.', type: 'number', default: '0', required: false, }, retryDelay: { description: 'Delay in milliseconds between retries.', type: 'number', default: '0', required: false, }, }} /> ## Events ### On before upload Callback that is called before requesting the pre-signed URLs. Use this to modify the files before uploading them, like resizing or compressing. You can also throw an error to reject the file upload. ```tsx useUploadFiles({ route: 'images', onBeforeUpload: ({ files }) => { // rename all files return files.map( (file) => new File([file], 'renamed-' + file.name, { type: file.type }) ); }, }); ``` ### On upload begin Event that is called before the files start being uploaded to S3. This happens after the server responds with the pre-signed URLs. ```tsx useUploadFiles({ route: 'images', onUploadBegin: ({ files, metadata }) => { console.log('Upload begin'); }, }); ``` ### On upload progress Event that is called when a file upload progress changes. ```tsx useUploadFiles({ route: 'images', onUploadProgress: ({ file }) => { console.log(`${file.name} upload progress: ${file.progress * 100}%`); }, }); ``` ### On upload complete Event that is called after files are successfully uploaded. ```tsx // This event is called even if some files fail to upload, but some succeed. // This event is not called if all files fail to upload. useUploadFiles({ route: 'images', onUploadComplete: ({ files, failedFiles, metadata }) => { console.log(`${files.length} files uploaded`); }, }); ``` ### On upload fail Event that is called after the entire upload if a file fails to upload. ```tsx // This event is called even if some files succeed to upload, but some fail. // This event is not called if all files succeed. useUploadFiles({ route: 'images', onUploadFail: ({ succeededFiles, failedFiles, metadata }) => { console.log('Some or all files failed to upload'); }, }); ``` ### On error Event that is called if a critical error occurs before the upload to S3, and no files were able to be uploaded. For example, if your server is unreachable. ```tsx useUploadFiles({ route: 'images', onError: (error) => { console.log(error.message); }, }); ``` This event is also called if some input is invalid. For example, if no files were selected. ### On upload settle Event that is called after the upload settles (either successfully completed or an error occurs). ```tsx useUploadFiles({ route: 'images', onUploadSettle: ({ files, failedFiles, metadata }) => { console.log('Upload settled'); }, }); ``` ## Metadata It is possible to send metadata from the client to your server on the upload request. ```tsx export function Uploader() { const { upload } = useUploadFiles({ route: 'images', }); return ( { if (e.target.files) { upload(e.target.files, { // [!code highlight:3] metadata: { folder: 'my-folder', }, }); } }} /> ); } ``` You can validate the client metadata on the server by setting the `clientMetadataSchema` option in the upload route. # Client Hooks (/docs/hooks-single) Better Upload provides React hooks that allow you to easily upload files using pre-signed URLs. Multipart uploads are managed automatically, in case you enable them in the server. ## Usage ```tsx title="uploader.tsx" import { useUploadFile } from '@better-upload/client'; export function Uploader() { const { upload, uploadAsync, reset, uploadedFile, progress, isPending, isAborted, isSettled, isError, isSuccess, error, metadata, control, // for use in pre-built components } = useUploadFile({ route: 'profile', }); return ( { if (e.target.files?.[0]) { upload(e.target.files[0]); } }} /> ); } ``` If your upload route handler is not located at `/api/upload`, you need to specify the correct path in the `api` option. ### Options The `useUploadFile` hook accepts the following options: Can be include, same-origin, or{' '} omit. ), required: false, }, retry: { description: 'Number of times to retry network requests that fail.', type: 'number', default: '0', required: false, }, retryDelay: { description: 'Delay in milliseconds between retries.', type: 'number', default: '0', required: false, }, }} /> ## Events ### On before upload Callback that is called before requesting the pre-signed URL. Use this to modify the file before uploading it, like resizing or compressing. You can also throw an error to reject the file upload. ```tsx useUploadFile({ route: 'profile', onBeforeUpload: ({ file }) => { // rename the file return new File([file], 'renamed-' + file.name, { type: file.type }); }, }); ``` ### On upload begin Event that is called before the file starts being uploaded to S3. This happens after the server responds with the pre-signed URL. ```tsx useUploadFile({ route: 'profile', onUploadBegin: ({ file, metadata }) => { console.log('Upload begin'); }, }); ``` ### On upload progress Event that is called when the file upload progress changes. ```tsx useUploadFile({ route: 'profile', onUploadProgress: ({ file }) => { console.log(`Upload progress: ${file.progress * 100}%`); }, }); ``` ### On upload complete Event that is called after the file is successfully uploaded. ```tsx useUploadFile({ route: 'profile', onUploadComplete: ({ file, metadata }) => { console.log('File uploaded'); }, }); ``` ### On error Event that is called if the upload fails. ```tsx useUploadFile({ route: 'profile', onError: (error) => { console.log(error.message); }, }); ``` This event is also called if some input is invalid. For example, if no files were selected. ### On upload settle Event that is called after the upload settles (either successfully completed or an error occurs). ```tsx useUploadFile({ route: 'profile', onUploadSettle: ({ file, metadata }) => { console.log('Upload settled'); }, }); ``` ## Metadata It is possible to send metadata from the client to your server on the upload request. ```tsx export function Uploader() { const { upload } = useUploadFile({ route: 'profile', }); return ( { if (e.target.files?.[0]) { upload(e.target.files[0], { // [!code highlight:3] metadata: { folder: 'my-folder', }, }); } }} /> ); } ``` You can validate the client metadata on the server by setting the `clientMetadataSchema` option in the upload route. # Introduction (/docs) ## Why? After needing to implement file uploads from scratch in multiple projects, I realized that there was a need for a better and simpler way to set up file uploads in React while still owning my S3 bucket. I wanted a library that was easy to use, fast to set up, and not bloated with features I didn't need. So I made Better Upload. ## Universal Better Upload is framework-agnostic, meaning you can use it with any React framework, like [Next.js](https://nextjs.org), [Remix](https://remix.run), [TanStack Start](https://tanstack.com/start), or any other. It also works with any separate backend server, like [Hono](https://hono.dev), [Elysia](https://elysiajs.com), [Express](https://expressjs.com) and [Fastify](https://fastify.dev). ## Developer Experience When creating Better Upload, my main focus was on making the developer experience as smooth as possible. * **Fast to set up:** It takes only a few minutes to get started and upload files directly to your S3 bucket. * **Beautiful:** There are copy-and-paste [shadcn/ui](https://ui.shadcn.com) components that you can use to rapidly build your UI. * **Own your data:** Upload directly to your S3 bucket, so you have full control over files. ## LLMs AI agents can access the Better Upload documentation in Markdown by: * **Full docs:** [`llms-full.txt`](https://better-upload.com/llms-full.txt) - contains the complete documentation in plain Markdown. * **Per-page content:** Every page in the docs is available as `.mdx`. For example, [`/docs/quickstart.mdx`](https://better-upload.com/docs/quickstart.mdx) # Quickstart (/docs/quickstart-single) You can have file uploads in your React app in a few minutes with Better Upload. This guide will walk you through the steps to set it up with any React framework. Before you start, make sure you have an S3-compatible bucket ready. You can use AWS S3, Cloudflare R2, or any other S3-compatible service. ## Uploading your first image ### Install Install the `@better-upload/server` and `@better-upload/client` packages. npm pnpm yarn bun ```bash npm i @better-upload/server @better-upload/client ``` ```bash pnpm add @better-upload/server @better-upload/client ``` ```bash yarn add @better-upload/server @better-upload/client ``` ```bash bun add @better-upload/server @better-upload/client ``` ### Set up server Your server generates pre-signed URLs, which the client uses to upload files directly to the S3 bucket. Change `my-bucket` to your bucket name, and [choose your S3 client](/docs/helpers-server#s3-clients). Next.js TanStack Start Remix Hono Elysia Express Fastify ```ts title="app/api/upload/route.ts" import { route, type Router } from '@better-upload/server'; import { toRouteHandler } from '@better-upload/server/adapters/next'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { profile: route({ fileTypes: ['image/*'], }), }, }; export const { POST } = toRouteHandler(router); ``` ```ts title="routes/api/upload.ts" import { createFileRoute } from '@tanstack/react-router'; import { handleRequest, route, type Router } from '@better-upload/server'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { profile: route({ fileTypes: ['image/*'], }), }, }; export const Route = createFileRoute('/api/upload')({ server: { handlers: { POST: async ({ request }) => { return handleRequest(request, router); }, }, }, }); ``` ```ts title="app/routes/api.upload.ts" import { ActionFunctionArgs } from '@remix-run/node'; import { handleRequest, route, type Router } from '@better-upload/server'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { profile: route({ fileTypes: ['image/*'], }), }, }; export async function action({ request }: ActionFunctionArgs) { return handleRequest(request, router); } ``` ```ts // when using a separate backend server, make sure to update the `api` option on the client hooks. import { Hono } from 'hono'; import { handleRequest, route, type Router } from '@better-upload/server'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { profile: route({ fileTypes: ['image/*'], }), }, }; const app = new Hono(); app.post('/upload', (c) => { return handleRequest(c.req.raw, router); }); export default app; ``` ```ts // when using a separate backend server, make sure to update the `api` option on the client hooks. import { Elysia } from 'elysia'; import { handleRequest, route, type Router } from '@better-upload/server'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { profile: route({ fileTypes: ['image/*'], }), }, }; const app = new Elysia() .post('/upload', ({ request }) => { return handleRequest(request, router); }) .listen(3000); ``` ```ts // when using a separate backend server, make sure to update the `api` option on the client hooks. import express from 'express'; import { route, type Router } from '@better-upload/server'; import { toNodeHandler } from '@better-upload/server/adapters/node'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { profile: route({ fileTypes: ['image/*'], }), }, }; const app = express(); app.post('/upload', toNodeHandler(router)); // only mount express json middleware AFTER upload router app.use(express.json()); app.listen(3000); ``` ```ts // when using a separate backend server, make sure to update the `api` option on the client hooks. import Fastify from 'fastify'; import { route, type Router } from '@better-upload/server'; import { toNodeHandler } from '@better-upload/server/adapters/node'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { profile: route({ fileTypes: ['image/*'], }), }, }; const app = Fastify(); app.post('/upload', toNodeHandler(router)); app.listen({ port: 3000 }); ``` In the example above, we create the upload route `profile`. Learn more about upload routes [here](/docs/routes-single). You can run code before uploads in the server. Use the `onBeforeUpload` callback: ```ts import { RejectUpload, route, type Router } from '@better-upload/server'; import { aws } from '@better-upload/server/clients'; const auth = (req: Request) => ({ id: 'fake-user-id' }); // [!code highlight] const router: Router = { client: aws(), bucketName: 'my-bucket', routes: { profile: route({ fileTypes: ['image/*'], // [!code ++:7] onBeforeUpload: async ({ req, file, clientMetadata }) => { const user = await auth(req); if (!user) { throw new RejectUpload('Not logged in!'); } }, }), }, }; ``` You can modify the S3 object through the `onBeforeUpload` callback: ```ts const router: Router = { client: aws(), bucketName: 'my-bucket', routes: { profile: route({ fileTypes: ['image/*'], // [!code ++:10] onBeforeUpload: async ({ req, file, clientMetadata }) => { return { objectInfo: { key: `files/${file.name}`, metadata: { author: 'user_123', }, }, }; }, }), }, }; ``` There are built-in clients for popular S3-compatible services. Suggest a new client by [opening an issue](https://github.com/Nic13Gamer/better-upload/issues). AWS S3 Cloudflare R2 Tigris Backblaze B2 DigitalOcean Spaces Wasabi MinIO Custom ```ts import { aws } from '@better-upload/server/clients'; const s3 = aws({ accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', region: 'us-east-1', }); ``` ```ts import { cloudflare } from '@better-upload/server/clients'; const s3 = cloudflare({ accountId: 'your-account-id', accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', }); ``` ```ts import { tigris } from '@better-upload/server/clients'; const s3 = tigris({ accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', endpoint: '...', // optional }); ``` ```ts import { backblaze } from '@better-upload/server/clients'; const s3 = backblaze({ region: 'your-backblaze-region', applicationKeyId: 'your-application-key-id', applicationKey: 'your-application-key', }); ``` ```ts import { digitalOcean } from '@better-upload/server/clients'; const s3 = digitalOcean({ region: 'your-spaces-region', key: 'your-spaces-key', secret: 'your-spaces-secret', }); ``` ```ts import { wasabi } from '@better-upload/server/clients'; const s3 = wasabi({ region: 'your-wasabi-region', accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', }); ``` ```ts import { minio } from '@better-upload/server/clients'; const s3 = minio({ region: 'your-minio-region', endpoint: 'https://minio.example.com', accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', }); ``` ```ts // for any S3-compatible service import { custom } from '@better-upload/server/clients'; const s3 = custom({ host: 's3.us-east-1.amazonaws.com', accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', region: 'us-east-1', secure: true, forcePathStyle: false, }); ``` ### Create `` component We will now build our UI using pre-built components. We'll use `` for single file uploads. Install it via the [shadcn](https://ui.shadcn.com/) CLI: npm pnpm yarn bun ```bash npx shadcn@latest add @better-upload/upload-button ``` ```bash pnpm dlx shadcn@latest add @better-upload/upload-button ``` ```bash yarn dlx shadcn@latest add @better-upload/upload-button ``` ```bash bun x shadcn@latest add @better-upload/upload-button ``` We'll also use the `useUploadFile` hook. The complete code looks like this: ```tsx title="uploader.tsx" 'use client'; // only for Next.js import { useUploadFile } from '@better-upload/client'; import { UploadButton } from '@/components/ui/upload-button'; export function Uploader() { const { control } = useUploadFile({ route: 'profile', }); return ; } ``` Learn more about the hooks [here](/docs/hooks-single). ### Place the component Now place the `` component in your app. ```tsx title="page.tsx" import { Uploader } from '@/components/uploader'; export default function Page() { return (
); } ```
### You're done! 🎉 You can now run your app and upload images directly to any S3-compatible service! If you plan on uploading files larger than **5GB**, take a look at [multipart uploads](/docs/routes-single#multipart-uploads). Make sure to also correctly configure CORS on your bucket. Here is an example: ```json [ { "AllowedOrigins": [ "http://localhost:3000", "https://example.com" // Add your domain here ], "AllowedMethods": ["GET", "PUT", "POST", "DELETE"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"] } ] ``` Learn more about CORS [here](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html).
## Learn more ### Concepts ### Guides ### Components # Quickstart (/docs/quickstart) You can have file uploads in your React app in a few minutes with Better Upload. This guide will walk you through the steps to set it up with any React framework. Before you start, make sure you have an S3-compatible bucket ready. You can use AWS S3, Cloudflare R2, or any other S3-compatible service. ## Uploading images ### Install Install the `@better-upload/server` and `@better-upload/client` packages. npm pnpm yarn bun ```bash npm i @better-upload/server @better-upload/client ``` ```bash pnpm add @better-upload/server @better-upload/client ``` ```bash yarn add @better-upload/server @better-upload/client ``` ```bash bun add @better-upload/server @better-upload/client ``` ### Set up server Your server generates pre-signed URLs, which the client uses to upload files directly to the S3 bucket. Change `my-bucket` to your bucket name, and [choose your S3 client](/docs/helpers-server#s3-clients). Next.js TanStack Start Remix Hono Elysia Express Fastify ```ts title="app/api/upload/route.ts" import { route, type Router } from '@better-upload/server'; import { toRouteHandler } from '@better-upload/server/adapters/next'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { images: route({ fileTypes: ['image/*'], multipleFiles: true, maxFiles: 4, }), }, }; export const { POST } = toRouteHandler(router); ``` ```ts title="routes/api/upload.ts" import { createFileRoute } from '@tanstack/react-router'; import { handleRequest, route, type Router } from '@better-upload/server'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { images: route({ fileTypes: ['image/*'], multipleFiles: true, maxFiles: 4, }), }, }; export const Route = createFileRoute('/api/upload')({ server: { handlers: { POST: async ({ request }) => { return handleRequest(request, router); }, }, }, }); ``` ```ts title="app/routes/api.upload.ts" import { ActionFunctionArgs } from '@remix-run/node'; import { handleRequest, route, type Router } from '@better-upload/server'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { images: route({ fileTypes: ['image/*'], multipleFiles: true, maxFiles: 4, }), }, }; export async function action({ request }: ActionFunctionArgs) { return handleRequest(request, router); } ``` ```ts // when using a separate backend server, make sure to update the `api` option on the client hooks. import { Hono } from 'hono'; import { handleRequest, route, type Router } from '@better-upload/server'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { images: route({ fileTypes: ['image/*'], multipleFiles: true, maxFiles: 4, }), }, }; const app = new Hono(); app.post('/upload', (c) => { return handleRequest(c.req.raw, router); }); export default app; ``` ```ts // when using a separate backend server, make sure to update the `api` option on the client hooks. import { Elysia } from 'elysia'; import { handleRequest, route, type Router } from '@better-upload/server'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { images: route({ fileTypes: ['image/*'], multipleFiles: true, maxFiles: 4, }), }, }; const app = new Elysia() .post('/upload', ({ request }) => { return handleRequest(request, router); }) .listen(3000); ``` ```ts // when using a separate backend server, make sure to update the `api` option on the client hooks. import express from 'express'; import { route, type Router } from '@better-upload/server'; import { toNodeHandler } from '@better-upload/server/adapters/node'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { images: route({ fileTypes: ['image/*'], multipleFiles: true, maxFiles: 4, }), }, }; const app = express(); app.post('/upload', toNodeHandler(router)); // only mount express json middleware AFTER upload router app.use(express.json()); app.listen(3000); ``` ```ts // when using a separate backend server, make sure to update the `api` option on the client hooks. import Fastify from 'fastify'; import { route, type Router } from '@better-upload/server'; import { toNodeHandler } from '@better-upload/server/adapters/node'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), // or cloudflare(), backblaze(), tigris(), ... // [!code highlight] bucketName: 'my-bucket', // [!code highlight] routes: { images: route({ fileTypes: ['image/*'], multipleFiles: true, maxFiles: 4, }), }, }; const app = Fastify(); app.post('/upload', toNodeHandler(router)); app.listen({ port: 3000 }); ``` In the example above, we create the upload route `images`. Learn more about upload routes [here](/docs/routes-multiple). You can run code before uploads in the server. Use the `onBeforeUpload` callback: ```ts import { RejectUpload, route, type Router } from '@better-upload/server'; import { aws } from '@better-upload/server/clients'; const auth = (req: Request) => ({ id: 'fake-user-id' }); // [!code highlight] const router: Router = { client: aws(), bucketName: 'my-bucket', routes: { images: route({ fileTypes: ['image/*'], multipleFiles: true, maxFiles: 4, // [!code ++:7] onBeforeUpload: async ({ req, files, clientMetadata }) => { const user = await auth(req); if (!user) { throw new RejectUpload('Not logged in!'); } }, }), }, }; ``` You can modify the S3 object through the `onBeforeUpload` callback: ```ts const router: Router = { client: aws(), bucketName: 'my-bucket', routes: { images: route({ fileTypes: ['image/*'], multipleFiles: true, maxFiles: 4, // [!code ++:10] onBeforeUpload: async ({ req, files, clientMetadata }) => { return { generateObjectInfo: ({ file }) => ({ key: `files/${file.name}`, metadata: { author: 'user_123', }, }), }; }, }), }, }; ``` There are built-in clients for popular S3-compatible services. Suggest a new client by [opening an issue](https://github.com/Nic13Gamer/better-upload/issues). AWS S3 Cloudflare R2 Tigris Backblaze B2 DigitalOcean Spaces Wasabi MinIO Custom ```ts import { aws } from '@better-upload/server/clients'; const s3 = aws({ accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', region: 'us-east-1', }); ``` ```ts import { cloudflare } from '@better-upload/server/clients'; const s3 = cloudflare({ accountId: 'your-account-id', accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', }); ``` ```ts import { tigris } from '@better-upload/server/clients'; const s3 = tigris({ accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', endpoint: '...', // optional }); ``` ```ts import { backblaze } from '@better-upload/server/clients'; const s3 = backblaze({ region: 'your-backblaze-region', applicationKeyId: 'your-application-key-id', applicationKey: 'your-application-key', }); ``` ```ts import { digitalOcean } from '@better-upload/server/clients'; const s3 = digitalOcean({ region: 'your-spaces-region', key: 'your-spaces-key', secret: 'your-spaces-secret', }); ``` ```ts import { wasabi } from '@better-upload/server/clients'; const s3 = wasabi({ region: 'your-wasabi-region', accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', }); ``` ```ts import { minio } from '@better-upload/server/clients'; const s3 = minio({ region: 'your-minio-region', endpoint: 'https://minio.example.com', accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', }); ``` ```ts // for any S3-compatible service import { custom } from '@better-upload/server/clients'; const s3 = custom({ host: 's3.us-east-1.amazonaws.com', accessKeyId: 'your-access-key-id', secretAccessKey: 'your-secret-access-key', region: 'us-east-1', secure: true, forcePathStyle: false, }); ``` ### Create `` component We will now build our UI using pre-built components. Use `` for multiple file uploads. Install it via the [shadcn](https://ui.shadcn.com/) CLI: npm pnpm yarn bun ```bash npx shadcn@latest add @better-upload/upload-dropzone ``` ```bash pnpm dlx shadcn@latest add @better-upload/upload-dropzone ``` ```bash yarn dlx shadcn@latest add @better-upload/upload-dropzone ``` ```bash bun x shadcn@latest add @better-upload/upload-dropzone ``` We'll also use the `useUploadFiles` hook. The complete code looks like this: ```tsx title="uploader.tsx" 'use client'; // only for Next.js import { useUploadFiles } from '@better-upload/client'; import { UploadDropzone } from '@/components/ui/upload-dropzone'; export function Uploader() { const { control } = useUploadFiles({ route: 'images', }); return ( ); } ``` Learn more about the hooks [here](/docs/hooks-multiple). ### Place the component Now place the `` component in your app. ```tsx title="page.tsx" import { Uploader } from '@/components/uploader'; export default function Page() { return (
); } ```
### You're done! 🎉 You can now run your app and upload images directly to any S3-compatible service! If you plan on uploading files larger than **5GB**, take a look at [multipart uploads](/docs/routes-multiple#multipart-uploads). Make sure to also correctly configure CORS on your bucket. Here is an example: ```json [ { "AllowedOrigins": [ "http://localhost:3000", "https://example.com" // Add your domain here ], "AllowedMethods": ["GET", "PUT", "POST", "DELETE"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"] } ] ``` Learn more about CORS [here](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html).
## Learn more ### Concepts ### Guides ### Components # Upload Routes (/docs/routes-multiple) Upload routes are where you define how files are uploaded. To define a route, use the `route` function. You can create multiple routes for different purposes (e.g. images, videos). Here is a basic example of a multiple file upload route: ```ts import { route } from '@better-upload/server'; route({ multipleFiles: true, fileTypes: ['image/*'], // Accepts all image types maxFileSize: 1024 * 1024 * 4, // 4MB }); ``` Multiple file routes have the following options: An array of file types to accept. Use any valid MIME type. You can use wildcards like image/*. ), type: 'string[]', default: 'All file types allowed', required: false, }, maxFileSize: { description: 'The maximum file size in bytes.', type: 'number', default: '5242880 (5MB)', required: false, }, signedUrlExpiresIn: { description: 'The time in seconds the upload pre-signed URL is valid.', type: 'number', default: '120 (2 minutes)', required: false, }, clientMetadataSchema: { description: 'Schema for validating metadata sent from the client. Use any validation library compatible with Standard Schema, like Zod.', type: 'object', required: false, }, }} /> ## Callbacks When defining a route, you may want to run code before or after the upload. You can do this by using the callbacks. ### Before upload The `onBeforeUpload` callback is called before pre-signed URLs are generated. Use this to run custom logic before uploading files, such as auth and rate-limiting. The request, files, and metadata sent from the client are available. ```ts route({ onBeforeUpload: async ({ req, files, clientMetadata }) => { const user = await auth(); if (!user) { throw new RejectUpload('Not logged in!'); } return { generateObjectInfo: ({ file }) => ({ key: `${user.id}/${file.name}`, }), bucketName: 'another-bucket', }; }, }); ``` Throw `RejectUpload` to reject the file upload. This will also send the error message to the client. You can return an object with the following properties: object', typeDescription: ( <> Can return: key, metadata, acl,{' '} storageClass, cacheControl. ), required: false, }, metadata: { description: ( <> Metadata to be passed to the onAfterSignedUrl callback. ), type: 'object', required: false, }, bucketName: { description: 'If you wish to upload to a bucket different from the one defined in the router, you can specify its name here.', type: 'string', required: false, }, }} /> ### After generating pre-signed URL The `onAfterSignedUrl` callback is called after the pre-signed URLs are generated. Use this to run custom logic after the URL is generated, such as logging and saving data. In addition to all previous data, metadata from the `onBeforeUpload` callback is also available. ```ts route({ onAfterSignedUrl: async ({ req, files, metadata, clientMetadata }) => { // the files now have the objectInfo property return { metadata: { example: '123', }, }; }, }); ``` You can return an object with the following properties: ## Multipart uploads If you want to upload files larger than **5GB**, you must use multipart uploads. To enable it, set `multipart` to `true`. It works both for single and multiple files. No change is needed in the client. ```ts route({ multipleFiles: true, multipart: true, // [!code highlight] partSize: 1024 * 1024 * 20, // 20MB, default is 50MB [!code highlight] }); ``` You can now also modify the following options: Even for files under 5GB, using multipart uploads can speed up the upload process. Empty files (0 bytes) will fail to upload with multipart uploads. ## Router The router is where all upload routes are defined. To define a router, create an object with the `Router` type. ```ts import { type Router } from '@better-upload/server'; export const router: Router = { client: s3, bucketName: 'my-bucket', routes: { // your routes... }, }; ``` # Upload Routes (/docs/routes-single) Upload routes are where you define how files are uploaded. To define a route, use the `route` function. You can create multiple routes for different purposes (e.g. images, videos). Here is a basic example of a single file upload route: ```ts import { route } from '@better-upload/server'; route({ fileTypes: ['image/*'], // Accepts all image types maxFileSize: 1024 * 1024 * 4, // 4MB }); ``` Single file routes have the following options: An array of file types to accept. Use any valid MIME type. You can use wildcards like image/*. ), type: 'string[]', default: 'All file types allowed', required: false, }, maxFileSize: { description: 'The maximum file size in bytes.', type: 'number', default: '5242880 (5MB)', required: false, }, signedUrlExpiresIn: { description: 'The time in seconds the upload pre-signed URL is valid.', type: 'number', default: '120 (2 minutes)', required: false, }, clientMetadataSchema: { description: 'Schema for validating metadata sent from the client. Use any validation library compatible with Standard Schema, like Zod.', type: 'object', required: false, }, }} /> ## Callbacks When defining a route, you may want to run code before or after the upload. You can do this by using the callbacks. ### Before upload The `onBeforeUpload` callback is called before the pre-signed URL is generated. Use this to run custom logic before uploading a file, such as auth and rate-limiting. The request, file, and metadata sent from the client are available. ```ts route({ onBeforeUpload: async ({ req, file, clientMetadata }) => { const user = await auth(); if (!user) { throw new RejectUpload('Not logged in!'); } return { objectInfo: { key: user.id, }, bucketName: 'another-bucket', }; }, }); ``` Throw `RejectUpload` to reject the file upload. This will also send the error message to the client. You can return an object with the following properties: Can return: key, metadata, acl,{' '} storageClass, cacheControl. ), required: false, }, metadata: { description: ( <> Metadata to be passed to the onAfterSignedUrl callback. ), type: 'object', required: false, }, bucketName: { description: 'If you wish to upload to a bucket different from the one defined in the router, you can specify its name here.', type: 'string', required: false, }, }} /> ### After generating pre-signed URL The `onAfterSignedUrl` callback is called after the pre-signed URL is generated. Use this to run custom logic after the URL is generated, such as logging and saving data. In addition to all previous data, metadata from the `onBeforeUpload` callback is also available. ```ts route({ onAfterSignedUrl: async ({ req, file, metadata, clientMetadata }) => { // the file now has the objectInfo property return { metadata: { example: '123', }, }; }, }); ``` You can return an object with the following properties: ## Multipart uploads If you want to upload files larger than **5GB**, you must use multipart uploads. To enable it, set `multipart` to `true`. It works both for single and multiple files. No change is needed in the client. ```ts route({ multipleFiles: true, multipart: true, // [!code highlight] partSize: 1024 * 1024 * 20, // 20MB, default is 50MB [!code highlight] }); ``` You can now also modify the following options: Even for files under 5GB, using multipart uploads can speed up the upload process. Empty files (0 bytes) will fail to upload with multipart uploads. ## Router The router is where all upload routes are defined. To define a router, create an object with the `Router` type. ```ts import { type Router } from '@better-upload/server'; export const router: Router = { client: s3, bucketName: 'my-bucket', routes: { // your routes... }, }; ``` # Upload Button (/docs/components/upload-button) import { UploadButtonDemo } from '@/components/templates/upload-button-demo'; ## Demo ## Installation ```bash npx shadcn@latest add @better-upload/upload-button ``` **Install the following dependencies:** npm pnpm yarn bun ```bash npm i lucide-react ``` ```bash pnpm add lucide-react ``` ```bash yarn add lucide-react ``` ```bash bun add lucide-react ``` Also add the [shadcn/ui button](https://ui.shadcn.com/docs/components/button) component to your project. As the upload button is built on top of it. **Copy and paste the following code into your project.** ```tsx title='components/ui/upload-button.tsx' import { Button } from '@/components/ui/button'; import type { UploadHookControl } from '@better-upload/client'; import { Loader2, Upload } from 'lucide-react'; import { useId } from 'react'; type UploadButtonProps = { control: UploadHookControl; id?: string; accept?: string; metadata?: Record; uploadOverride?: ( ...args: Parameters['upload']> ) => void; // Add any additional props you need. }; export function UploadButton({ control: { upload, isPending }, id: _id, accept, metadata, uploadOverride, }: UploadButtonProps) { const id = useId(); return ( ); } ``` **Update the import paths to match your project setup.** ## Usage The `` should be used with the `useUploadFile` hook. ```tsx 'use client'; import { useUploadFile } from '@better-upload/client'; import { UploadButton } from '@/components/ui/upload-button'; export function Uploader() { const { control } = useUploadFile({ route: 'profile', }); return ; } ``` The button will open a file picker dialog when clicked, and upload the selected file to the desired route. ## Props ', description: 'Metadata to send to your server on upload. Needs to be JSON serializable.', }, uploadOverride: { type: 'function', description: 'Override the default upload function. For example, set files in an array, and upload them after form submission.', }, }} /> # Upload Dropzone with Progress (/docs/components/upload-dropzone-progress) import { UploadDropzoneProgressDemo } from '@/components/templates/upload-dropzone-progress-demo'; ## Demo ## Installation ```bash npx shadcn@latest add @better-upload/upload-dropzone-progress ``` **Install the following dependencies:** npm pnpm yarn bun ```bash npm i lucide-react react-dropzone ``` ```bash pnpm add lucide-react react-dropzone ``` ```bash yarn add lucide-react react-dropzone ``` ```bash bun add lucide-react react-dropzone ``` Make sure to have [shadcn/ui](https://ui.shadcn.com/docs/installation) set up in your project, with the [progress](https://ui.shadcn.com/docs/components/progress) component installed. **Copy and paste the following code into your project.** ```tsx title='components/ui/upload-dropzone-progress.tsx' import { Progress } from '@/components/ui/progress'; import { cn } from '@/lib/utils'; import type { UploadHookControl } from '@better-upload/client'; import { formatBytes } from '@better-upload/client/helpers'; import { Dot, File, Upload } from 'lucide-react'; import { useId } from 'react'; import { useDropzone } from 'react-dropzone'; type UploadDropzoneProgressProps = { control: UploadHookControl; id?: string; accept?: string; metadata?: Record; description?: | { fileTypes?: string; maxFileSize?: string; maxFiles?: number; } | string; uploadOverride?: ( ...args: Parameters['upload']> ) => void; // Add any additional props you need. }; export function UploadDropzoneProgress({ control: { upload, isPending, progresses }, id: _id, accept, metadata, description, uploadOverride, }: UploadDropzoneProgressProps) { const id = useId(); const { getRootProps, getInputProps, isDragActive, inputRef } = useDropzone({ onDrop: (files) => { if (files.length > 0) { if (uploadOverride) { uploadOverride(files, { metadata }); } else { upload(files, { metadata }); } } inputRef.current.value = ''; }, noClick: true, }); return (
{isDragActive && (

Drop files here

)}
{progresses.map((progress) => (

{progress.name}

{formatBytes(progress.size)}

{progress.progress < 1 && progress.status !== 'failed' ? ( ) : progress.status === 'failed' ? (

Failed

) : (

Completed

)}
))}
); } const iconCaptions = { 'image/': 'IMG', 'video/': 'VID', 'audio/': 'AUD', 'application/pdf': 'PDF', 'application/zip': 'ZIP', 'application/x-rar-compressed': 'RAR', 'application/x-7z-compressed': '7Z', 'application/x-tar': 'TAR', 'application/json': 'JSON', 'application/javascript': 'JS', 'text/plain': 'TXT', 'text/csv': 'CSV', 'text/html': 'HTML', 'text/css': 'CSS', 'application/xml': 'XML', 'application/x-sh': 'SH', 'application/x-python-code': 'PY', 'application/x-executable': 'EXE', 'application/x-disk-image': 'ISO', }; function FileIcon({ type }: { type: string }) { const caption = Object.entries(iconCaptions).find(([key]) => type.startsWith(key) )?.[1]; return (
{caption && ( {caption} )}
); } ```
**Update the import paths to match your project setup.**
## Usage The `` should be used with the `useUploadFiles` hook. ```tsx 'use client'; import { useUploadFiles } from '@better-upload/client'; import { UploadDropzoneProgress } from '@/components/ui/upload-dropzone-progress'; export function Uploader() { const { control } = useUploadFiles({ route: 'images', }); return ; } ``` When clicked, the dropzone will open a file picker dialog. When selected or dropped, the files will be uploaded to the desired route. ### Description You can customize the description shown in the dropzone. You can pass a string, or an object with the following properties: * `maxFiles`: The maximum number of files that can be uploaded. * `maxFileSize`: The maximum size of the files that can be uploaded, use a formatted string (e.g. `10MB`). * `fileTypes`: The file types that can be uploaded. ```tsx ``` Note that this is only cosmetic and does not enforce any restrictions client-side. ## Props ', description: 'Metadata to send to your server on upload. Needs to be JSON serializable.', }, uploadOverride: { type: 'function', description: 'Override the default upload function. For example, set files in an array, and upload them after form submission.', }, }} /> # Upload Dropzone (/docs/components/upload-dropzone) import { UploadDropzoneDemo } from '@/components/templates/upload-dropzone-demo'; ## Demo ## Installation ```bash npx shadcn@latest add @better-upload/upload-dropzone ``` **Install the following dependencies:** npm pnpm yarn bun ```bash npm i lucide-react react-dropzone ``` ```bash pnpm add lucide-react react-dropzone ``` ```bash yarn add lucide-react react-dropzone ``` ```bash bun add lucide-react react-dropzone ``` Make sure to have [shadcn/ui](https://ui.shadcn.com/docs/installation) set up in your project. **Copy and paste the following code into your project.** ```tsx title='components/ui/upload-dropzone.tsx' import { cn } from '@/lib/utils'; import type { UploadHookControl } from '@better-upload/client'; import { Loader2, Upload } from 'lucide-react'; import { useId } from 'react'; import { useDropzone } from 'react-dropzone'; type UploadDropzoneProps = { control: UploadHookControl; id?: string; accept?: string; metadata?: Record; description?: | { fileTypes?: string; maxFileSize?: string; maxFiles?: number; } | string; uploadOverride?: ( ...args: Parameters['upload']> ) => void; // Add any additional props you need. }; export function UploadDropzone({ control: { upload, isPending }, id: _id, accept, metadata, description, uploadOverride, }: UploadDropzoneProps) { const id = useId(); const { getRootProps, getInputProps, isDragActive, inputRef } = useDropzone({ onDrop: (files) => { if (files.length > 0 && !isPending) { if (uploadOverride) { uploadOverride(files, { metadata }); } else { upload(files, { metadata }); } } inputRef.current.value = ''; }, noClick: true, }); return (
{isDragActive && (

Drop files here

)}
); } ```
**Update the import paths to match your project setup.**
## Usage The `` should be used with the `useUploadFiles` hook. ```tsx 'use client'; import { useUploadFiles } from '@better-upload/client'; import { UploadDropzone } from '@/components/ui/upload-dropzone'; export function Uploader() { const { control } = useUploadFiles({ route: 'images', }); return ; } ``` When clicked, the dropzone will open a file picker dialog. When selected or dropped, the files will be uploaded to the desired route. ### Description You can customize the description shown in the dropzone. You can pass a string, or an object with the following properties: * `maxFiles`: The maximum number of files that can be uploaded. * `maxFileSize`: The maximum size of the files that can be uploaded, use a formatted string (e.g. `10MB`). * `fileTypes`: The file types that can be uploaded. ```tsx ``` Note that this is only cosmetic and does not enforce any restrictions client-side. ## Props ', description: 'Metadata to send to your server on upload. Needs to be JSON serializable.', }, uploadOverride: { type: 'function', description: 'Override the default upload function. For example, set files in an array, and upload them after form submission.', }, }} /> # TanStack Query (/docs/guides/tanstack-query) If you prefer to use TanStack Query instead of the hooks provided by Better Upload, you can do so by using the `uploadFile` and `uploadFiles` functions with the `useMutation` hook from TanStack Query. ## Example The complete code for a simple uploader is below. Multiple files Single files ```tsx 'use client'; import { uploadFiles } from '@better-upload/client'; import { useMutation } from '@tanstack/react-query'; export function Uploader() { const { mutate: upload, isPending } = useMutation({ mutationFn: async (files: File[]) => { return uploadFiles({ files, route: 'form', onFileStateChange: ({ file }) => { // you handle the progress of each file console.log(file); }, }); }, onSuccess: ({ files, failedFiles, metadata }) => { console.log({ files, failedFiles, metadata, }); }, onError: (error) => { console.error(error); }, }); return ( { if (e.target.files) { upload(Array.from(e.target.files)); } }} /> ); } ``` ```tsx 'use client'; import { uploadFile } from '@better-upload/client'; import { useMutation } from '@tanstack/react-query'; export function Uploader() { const { mutate: upload, isPending } = useMutation({ mutationFn: async (file: File) => { return uploadFile({ file, route: 'form', onFileStateChange: ({ file }) => { // you handle the progress of the file console.log(file); }, }); }, onSuccess: ({ file, metadata }) => { console.log({ file, metadata, }); }, onError: (error) => { console.error(error); }, }); return ( { if (e.target.files?.[0]) { upload(e.target.files[0]); } }} /> ); } ``` ### Tracking upload state Note that by directly using the upload functions, you need to track the state of each file upload yourself. This is simple to do, an example for multiple files is below. ```tsx 'use client'; import { type FileUploadInfo, type UploadStatus, uploadFiles, } from '@better-upload/client'; import { useMutation } from '@tanstack/react-query'; import { useState } from 'react'; export function Uploader() { // [!code highlight:3] const [uploadState, setUploadState] = useState( () => new Map>() ); const { mutate: upload, isPending } = useMutation({ mutationFn: async (files: File[]) => { return uploadFiles({ files, route: 'form', onFileStateChange: ({ file }) => { // [!code highlight:3] setUploadState((prev) => new Map(prev).set(file.objectInfo.key, file) ); }, }); }, }); return ( { if (e.target.files) { upload(Array.from(e.target.files)); } }} /> ); } ``` # React Hook Form (/docs/guides/forms/react-hook-form) It's common for file uploads to be part of a form. In this guide, we'll take a look at how to add file uploads to a form built with **React Hook Form**. This guide is based on the shadcn/ui [form guide](https://ui.shadcn.com/docs/forms/react-hook-form) and will use the [``](https://ui.shadcn.com/docs/components/field) component. We'll cover multiple file uploads, but the same principles apply for single file uploads. ## Form ### Installation Install the following shadcn/ui components. Make sure to also have React Hook Form and Zod installed. npm pnpm yarn bun ```bash npx shadcn@latest add field input button ``` ```bash pnpm dlx shadcn@latest add field input button ``` ```bash yarn dlx shadcn@latest add field input button ``` ```bash bun x shadcn@latest add field input button ``` ### Setup upload route Setup your upload route. Use your preferred framework, but for this example, we'll use Next.js. ```ts title="app/api/upload/route.ts" import { route, type Router } from '@better-upload/server'; import { toRouteHandler } from '@better-upload/server/adapters/next'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), bucketName: 'my-bucket', routes: { form: route({ multipleFiles: true, maxFiles: 5, maxFileSize: 1024 * 1024 * 5, // 5MB onBeforeUpload() { return { generateObjectInfo: ({ file }) => ({ key: `form/${file.name}` }), }; }, }), }, }; export const { POST } = toRouteHandler(router); ``` ### Define form schema We'll start by defining the shape of our form using a Zod schema. It contains two fields: * `folderName`: For an arbitrary text input. * `objectKeys`: For the uploaded files, stores the S3 object keys. ```tsx title="form.tsx" import * as z from 'zod'; const formSchema = z.object({ folderName: z.string().min(1, 'Folder name is required.'), objectKeys: z.array(z.string()).min(1, 'Upload at least one file.'), }); ``` ### Setup the form Next, we'll use the `useForm` hook from React Hook Form to create our form instance, along with the `useUploadFiles` hook to handle file uploads. ```tsx title="form.tsx" import { useUploadFiles } from '@better-upload/client'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; const formSchema = z.object({ folderName: z.string().min(1, 'Folder name is required.'), objectKeys: z.array(z.string()).min(1, 'Upload at least one file.'), }); export function FormUploader() { const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { folderName: '', objectKeys: [], }, }); const uploader = useUploadFiles({ route: 'form', onUploadComplete: ({ files }) => { form.setValue( 'objectKeys', files.map((file) => file.objectInfo.key) ); }, onError: (error) => { form.setError('objectKeys', { message: error.message || 'An error occurred.', }); }, }); function onSubmit(data: z.infer) { // call your API here console.log(data); } return (
{/* ... */} {/* Build the form here */} {/* ... */}
); } ```
### Build the form We can now build the form using the `` component from React Hook Form and the `` component for the file uploads. ```tsx title="form.tsx" 'use client'; import { useUploadFiles } from '@better-upload/client'; import { zodResolver } from '@hookform/resolvers/zod'; import { Controller, useForm } from 'react-hook-form'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Field, FieldError, FieldGroup, FieldLabel, } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; import { UploadDropzone } from '@/components/ui/upload-dropzone'; const formSchema = z.object({ folderName: z.string().min(1, 'Folder name is required.'), objectKeys: z.array(z.string()).min(1, 'Upload at least one file.'), }); export function FormUploader() { const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { folderName: '', objectKeys: [], }, }); const uploader = useUploadFiles({ route: 'form', onUploadComplete: ({ files }) => { form.setValue( 'objectKeys', files.map((file) => file.objectInfo.key) ); }, onError: (error) => { form.setError('objectKeys', { message: error.message || 'An error occurred.', }); }, }); function onSubmit(data: z.infer) { // call your API here console.log(data); } return ( Form Uploader Upload files to a specific folder.
( Folder name {fieldState.invalid && ( )} )} /> ( Files {fieldState.invalid && ( )} )} />
); } ```
Let's hide the dropzone after the user has uploaded files. We can do this by using the `uploadedFiles` array returned by the `useUploadFiles` hook. ```tsx export function FormUploader() { // ... return ( {/* ... */}
{/* ... */} ( Files {/* [!code highlight:18] */} {field.value.length > 0 ? (
{uploader.uploadedFiles.map((file) => ( {file.name} ))}
) : ( )} {fieldState.invalid && ( )}
)} />
{/* ... */}
); } ```
## Upload on form submit In this example, we only upload the files after the user clicks on the submit button. We'll use the `uploadOverride` prop to override the default behavior of the ``. ```tsx title="form.tsx" 'use client'; import { useUploadFiles } from '@better-upload/client'; import { zodResolver } from '@hookform/resolvers/zod'; import { Controller, useForm } from 'react-hook-form'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Field, FieldError, FieldGroup, FieldLabel, } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; import { UploadDropzone } from '@/components/ui/upload-dropzone'; const formSchema = z.object({ folderName: z.string().min(1, 'Folder name is required.'), files: z.array(z.file()).min(1, 'Upload at least one file.'), // for Zod v3: z.array(z.instanceof(File)).min(1, 'Upload at least one file.'), }); export function FormUploader() { const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { folderName: '', files: [], }, }); const uploader = useUploadFiles({ route: 'form', onError: (error) => { form.setError('files', { message: error.message || 'An error occurred.', }); }, }); async function onSubmit(data: z.infer) { const { files } = await uploader.upload(data.files); // call your API here console.log({ folderName: data.folderName, objectKeys: files.map((file) => file.objectInfo.key), }); } return ( Form Uploader Upload files to a specific folder.
( Folder name {fieldState.invalid && ( )} )} /> ( Files {field.value.length > 0 ? (
{field.value.map((file) => ( {file.name} ))}
) : ( { field.onChange(Array.from(files)); }} /> )} {fieldState.invalid && ( )}
)} />
); } ``` # TanStack Form (/docs/guides/forms/tanstack-form) It's common for file uploads to be part of a form. In this guide, we'll take a look at how to add file uploads to a form built with **TanStack Form**. This guide is based on the shadcn/ui [form guide](https://ui.shadcn.com/docs/forms/tanstack-form) and will use the [``](https://ui.shadcn.com/docs/components/field) component. We'll cover multiple file uploads, but the same principles apply for single file uploads. ## Form ### Installation Install the following shadcn/ui components. Make sure to also have TanStack Form and Zod installed. npm pnpm yarn bun ```bash npx shadcn@latest add field input button ``` ```bash pnpm dlx shadcn@latest add field input button ``` ```bash yarn dlx shadcn@latest add field input button ``` ```bash bun x shadcn@latest add field input button ``` ### Setup upload route Setup your upload route. Use your preferred framework, but for this example, we'll use Next.js. ```ts title="app/api/upload/route.ts" import { route, type Router } from '@better-upload/server'; import { toRouteHandler } from '@better-upload/server/adapters/next'; import { aws } from '@better-upload/server/clients'; const router: Router = { client: aws(), bucketName: 'my-bucket', routes: { form: route({ multipleFiles: true, maxFiles: 5, maxFileSize: 1024 * 1024 * 5, // 5MB onBeforeUpload() { return { generateObjectInfo: ({ file }) => ({ key: `form/${file.name}` }), }; }, }), }, }; export const { POST } = toRouteHandler(router); ``` ### Define form schema We'll start by defining the shape of our form using a Zod schema. It contains two fields: * `folderName`: For an arbitrary text input. * `objectKeys`: For the uploaded files, stores the S3 object keys. ```tsx title="form.tsx" import * as z from 'zod'; const formSchema = z.object({ folderName: z.string().min(1, 'Folder name is required.'), objectKeys: z.array(z.string()).min(1, 'Upload at least one file.'), }); ``` ### Setup the form Use the `useForm` hook from TanStack Form to create your form instance with Zod validation, along with the `useUploadFiles` hook to handle file uploads. ```tsx title="form.tsx" import { useUploadFiles } from '@better-upload/client'; import { useForm } from '@tanstack/react-form'; import * as z from 'zod'; const formSchema = z.object({ folderName: z.string().min(1, 'Folder name is required.'), objectKeys: z.array(z.string()).min(1, 'Upload at least one file.'), }); export function FormUploader() { const form = useForm({ defaultValues: { folderName: '', objectKeys: [] as string[], }, validators: { onSubmit: formSchema, }, onSubmit: ({ value }) => { // call your API here console.log(value); }, }); const uploader = useUploadFiles({ route: 'form', onUploadComplete: ({ files }) => { form.setFieldValue( 'objectKeys', files.map((file) => file.objectInfo.key) ); }, }); return (
{ e.preventDefault(); form.handleSubmit(); }} > {/* ... */}
); } ```
### Build the form We can now build the form using the `form.Field` component from TanStack Form and the `` component for the file uploads. ```tsx title="form.tsx" 'use client'; import { useUploadFiles } from '@better-upload/client'; import { useForm } from '@tanstack/react-form'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Field, FieldError, FieldGroup, FieldLabel, } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; import { UploadDropzone } from '@/components/ui/upload-dropzone'; const formSchema = z.object({ folderName: z.string().min(1, 'Folder name is required.'), objectKeys: z.array(z.string()).min(1, 'Upload at least one file.'), }); export function FormUploader() { const form = useForm({ defaultValues: { folderName: '', objectKeys: [] as string[], }, validators: { onSubmit: formSchema, }, onSubmit: ({ value }) => { // call your API here console.log(value); }, }); const uploader = useUploadFiles({ route: 'form', onUploadComplete: ({ files }) => { form.setFieldValue( 'objectKeys', files.map((file) => file.objectInfo.key) ); }, }); return ( Form Uploader Upload files to a specific folder.
{ e.preventDefault(); form.handleSubmit(); }} > { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( Folder name field.handleChange(e.target.value)} aria-invalid={isInvalid} placeholder="my-folder" autoComplete="off" /> {isInvalid && ( )} ); }} /> { const isInvalid = (field.state.meta.isTouched && !field.state.meta.isValid) || uploader.isError; return ( Folder name {isInvalid && ( )} ); }} />
); } ```
Let's hide the dropzone after the user has uploaded files. We can do this by using the `uploadedFiles` array returned by the `useUploadFiles` hook. ```tsx export function FormUploader() { // ... return ( {/* ... */}
{ e.preventDefault(); form.handleSubmit(); }} > {/* ... */} { const isInvalid = (field.state.meta.isTouched && !field.state.meta.isValid) || uploader.isError; return ( Folder name {field.state.value.length > 0 ? (
{uploader.uploadedFiles.map((file) => ( {file.name} ))}
) : ( )} {isInvalid && ( )}
); }} />
{/* ... */}
); } ```
## Upload on form submit In this example, we only upload the files after the user clicks on the submit button. We'll use the `uploadOverride` prop to override the default behavior of the ``. ```tsx title="form.tsx" 'use client'; import { useUploadFiles } from '@better-upload/client'; import { useForm } from '@tanstack/react-form'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Field, FieldError, FieldGroup, FieldLabel, } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; import { UploadDropzone } from '@/components/ui/upload-dropzone'; const formSchema = z.object({ folderName: z.string().min(1, 'Folder name is required.'), files: z.array(z.file()).min(1, 'Upload at least one file.'), // for Zod v3: z.array(z.instanceof(File)).min(1, 'Upload at least one file.'), }); export function FormUploader() { const form = useForm({ defaultValues: { folderName: '', files: [] as File[], }, validators: { onSubmit: formSchema, }, onSubmit: async ({ value }) => { const { files } = await uploader.upload(value.files); // call your API here console.log({ folderName: value.folderName, objectKeys: files.map((file) => file.objectInfo.key), }); }, }); const uploader = useUploadFiles({ route: 'form', }); return ( Form Uploader Upload files to a specific folder.
{ e.preventDefault(); form.handleSubmit(); }} > { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( Folder name field.handleChange(e.target.value)} aria-invalid={isInvalid} placeholder="my-folder" autoComplete="off" /> {isInvalid && ( )} ); }} /> { const isInvalid = (field.state.meta.isTouched && !field.state.meta.isValid) || uploader.isError; return ( Folder name {field.state.value.length > 0 ? (
{field.state.value.map((file) => ( {file.name} ))}
) : ( { field.handleChange(Array.from(files)); }} /> )} {isInvalid && ( )}
); }} />
); } ```