> ## Documentation Index
> Fetch the complete documentation index at: https://docs.openinary.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# <FileUploader />

> Drop a secure, ready-to-use file uploader into any React app with a single shadcn/ui command. Uploads go straight to your Openinary instance using short-lived presigned signatures, no API key in the browser.

![The FileUploader component with files queued for upload](https://mintcdn.com/openinary/PRMK2ugr2XwTpyH6/images/file-uploader-hero-light@2x.png?fit=max\&auto=format\&n=PRMK2ugr2XwTpyH6\&q=85\&s=d9e2d6e0aaa688ba183c27eb3b2b8cbd)

A shadcn/ui-compatible React component that uploads straight to your Openinary instance, with drag and drop, per-file progress, previews, retry, cancel and client-side validation. It installs like any other shadcn component:

```bash theme={null}
pnpm dlx shadcn@latest add @openinary/file-uploader
```

The browser never holds your API key. It gets a short-lived presigned signature from an endpoint on your server, and uploads with that. Running Openinary Cloud? Same component, different configuration: see [File Uploader on Cloud](https://docs.openinary.dev/cloud/file-uploader.md).

## Security model

The component never holds an API key. Instead it uses **presigned upload signatures**, minted by [`POST /upload/sign`](https://docs.openinary.dev/api-reference/files/upload-sign), the same HMAC-SHA256 pattern Openinary already uses for [signed delivery URLs](https://docs.openinary.dev/media-transformations/signed-urls.md).

**1. Your backend requests a signature**

A protected endpoint on your server calls `POST /upload/sign` with your **Openinary API key**, scoping the request to a `folder` and an expiry window.

**2. The browser gets the signature**

The component calls your `sign()` function right before uploading. It returns `{ signature, expires, folder }`, opaque values that expire in minutes.

**3. Openinary verifies the signature**

`POST /upload` checks the signature against the submitted `folder` and `expires` before accepting the file. A tampered or expired signature is rejected.

Your API key never leaves your backend: only your server calls `POST /upload/sign`.

## Prerequisites

* A running Openinary instance (see [Quickstart](https://docs.openinary.dev/quickstart.md)).
* `API_SECRET` set on the instance (**at least 16 characters**; `openssl rand -hex 32` gives you a strong 64-character one), used internally to compute the HMAC signature. Same secret used for [signed delivery URLs](https://docs.openinary.dev/media-transformations/signed-urls.md).
* An **API key** for your backend to call `POST /upload/sign` with (see [API Keys](https://docs.openinary.dev/api-reference/api-keys/create.md)).
* `CORS_ORIGIN` set to include the origin(s) of the app embedding the uploader. Multiple origins are comma-separated:
  ```bash theme={null}
  CORS_ORIGIN=https://app.example.com,https://admin.example.com
  ```
* `MAX_FILE_SIZE_MB` if you need to raise the default 50 MB limit.
* A project already set up for shadcn/ui (`components.json` present). See the [shadcn install guide](https://ui.shadcn.com/docs/installation). Requires the shadcn CLI **v3 or later** for namespaced registries.

## Installation

**1. Register the Openinary namespace**

Add the `@openinary` registry to your project's `components.json`:

```json components.json theme={null}
{
  "registries": {
    "@openinary": "https://openinary.dev/r/{name}.json"
  }
}
```

**2. Add the component**

```bash theme={null}
pnpm dlx shadcn@latest add @openinary/file-uploader
```

This installs the component and its hook into `components/openinary/`, pulls in the shadcn `button` and `progress` dependencies, and adds `NEXT_PUBLIC_OPENINARY_URL` to your `.env.local`.

**3. Add the signing helper**

```bash theme={null}
pnpm dlx shadcn@latest add @openinary/upload-token
```

This installs `lib/upload-token.ts`, a thin client for `POST /upload/sign`, it makes one `fetch` call and doesn't compute anything cryptographic itself; Openinary does that.

**4. Set your environment variables**

```bash .env.local theme={null}
# Public, the browser calls this Openinary API directly for the upload
# /api is required in full stack mode (nginx only exposes the API under /api); drop it for API-only deployments
NEXT_PUBLIC_OPENINARY_URL=https://your-domain.com/api

# Secret, server only, an Openinary API key, never exposed to the browser
OPENINARY_API_KEY=your-openinary-api-key
```

## Create a signing endpoint

The component needs a `sign()` function that returns a presigned signature. Back it with a small endpoint on your server using the helper you just installed.

> [!WARNING]
>
> Protect this endpoint with your own authentication and derive `folder` from the **authenticated user** on the server. Never trust a folder sent by the browser, or a user could upload into someone else's scope.

> [tab] Next.js (App Router)
>
> ```ts app/api/upload-token/route.ts theme={null}
> import { NextResponse } from "next/server";
> import { signUpload } from "@/lib/upload-token";
> // import { auth } from "@/lib/auth"; // your own auth
>
> export async function POST() {
>   // const session = await auth();
>   // if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
>
>   const signed = await signUpload(
>     process.env.NEXT_PUBLIC_OPENINARY_URL!,
>     process.env.OPENINARY_API_KEY!,
>     {
>       expiresIn: 300,
>       folder: "uploads", // e.g. `users/${session.user.id}`
>     },
>   );
>
>   return NextResponse.json(signed);
> }
> ```

> [tab] Express
>
> ```ts theme={null}
> import express from "express";
> import { signUpload } from "./lib/upload-token";
>
> const app = express();
>
> app.post("/api/upload-token", requireAuth, async (req, res) => {
>   const signed = await signUpload(
>     process.env.OPENINARY_URL!,
>     process.env.OPENINARY_API_KEY!,
>     { expiresIn: 300, folder: `users/${req.user.id}` },
>   );
>   res.json(signed);
> });
> ```

> [tab] Cloudflare Workers
>
> ```ts theme={null}
> import { signUpload } from "./lib/upload-token";
>
> export default {
>   async fetch(request: Request, env: Env) {
>     // authenticate the request first...
>     const signed = await signUpload(env.OPENINARY_URL, env.OPENINARY_API_KEY, {
>       expiresIn: 300,
>       folder: "uploads",
>     });
>     return Response.json(signed);
>   },
> };
> ```

Keep `expiresIn` short. It only has to live long enough to start the upload, so 60 to 300 seconds is plenty, and the server clamps it to 3600 anyway.

## Usage

> [tab] Next.js
>
> ```tsx theme={null}
> "use client";
>
> import { FileUploader } from "@/components/openinary/file-uploader";
>
> export function Uploader() {
>   const sign = async () => {
>     const res = await fetch("/api/upload-token", { method: "POST" });
>     if (!res.ok) throw new Error("Failed to sign the upload");
>     return res.json();
>   };
>
>   return (
>     <FileUploader
>       sign={sign}
>       maxFiles={10}
>       onSuccess={(files) => console.log("Uploaded:", files)}
>     />
>   );
> }
> ```

> [tab] Vite / React
>
> ```tsx theme={null}
> import { FileUploader } from "@/components/openinary/file-uploader";
>
> export function Uploader() {
>   const sign = async () => {
>     const res = await fetch("/api/upload-token", { method: "POST" });
>     return res.json();
>   };
>
>   return (
>     <FileUploader
>       baseUrl={import.meta.env.VITE_OPENINARY_URL}
>       sign={sign}
>     />
>   );
> }
> ```

> [tab] Restricted type/size + transformations
>
> ```tsx theme={null}
> <FileUploader
>   sign={sign} // your signing endpoint should scope folder to "uploads/avatars"
>   accept={{ "image/jpeg": [".jpg", ".jpeg"], "image/png": [".png"] }}
>   maxSize={5 * 1024 * 1024}     // 5 MB
>   maxFiles={1}
>   multiple={false}
>   transformations={["w_512,h_512,c_fill,f_webp"]} // pre-warm a variant
>   onSuccess={(files) => setAvatar(files[0].url)}
> />
> ```

## Props

| Prop | Type | Default | Description |
| - | - | - | - |
| `sign` | `() => Promise<SignedUpload> \| SignedUpload` | **required** | Returns `{ signature, expires, folder }` from your signing endpoint. Called before each batch and again on retry, so the signature is always fresh. The destination folder is whatever the signature is scoped to |
| `baseUrl` | `string` | `NEXT_PUBLIC_OPENINARY_URL` | Base URL of your Openinary API, e.g. `https://media.example.com` |
| `multiple` | `boolean` | `true` | Allow selecting more than one file |
| `disabled` | `boolean` | `false` | Block file selection and drag and drop, e.g. while a parent form submits |
| `accept` | `Record<string, string[]>` | Openinary media types | MIME types mapped to allowed extensions. Defaults to JPEG, PNG, WebP, AVIF, GIF, HEIC/HEIF, PSD, MP4, MOV, WebM |
| `maxSize` | `number` | `52428800` | Maximum file size in bytes, 50 MB. Must not exceed the server's `MAX_FILE_SIZE_MB` |
| `maxFiles` | `number` | | Client-side cap on file count. A UI convenience only: `POST /upload/sign` encodes no file-count limit, so it is not enforced server-side |
| `transformations` | `string[]` | | Segments to prewarm after upload, e.g. `["w_800,f_webp"]`. See [upload and prewarm](https://docs.openinary.dev/media-transformations/upload-and-prewarm.md) |
| `autoUpload` | `boolean` | `false` | Upload as soon as files are added, hiding the Upload button |
| `className` | `string` | | Extra classes on the root element |
| `onSuccess` | `(files: UploadedFile[]) => void` | | Called with the stored files after a successful batch |
| `onError` | `(error: UploadFileError) => void` | | Called when a file fails |
| `onProgress` | `(file: FileUploadState, progress: number) => void` | | Called with progress from 0 to 100 per file |

## Styling & theming

The component is built entirely with shadcn's design tokens (`border`, `muted`, `primary`, `destructive`, `ring`…), so it automatically matches your theme and dark mode. Because the source is copied into your project, you own it, edit `components/openinary/file-uploader.tsx` freely. Use `className` for one-off overrides.

## Server response

`onSuccess` receives the files as the API stored them.

| Field | Type | Description |
| - | - | - |
| `filename` | `string` | The stored filename |
| `path` | `string` | The stored path. Renamed if the name was taken, e.g. `image (1).jpg` |
| `url` | `string` | Root-relative delivery path, `/t/{path}`. Prefix it with your `baseUrl` for a full URL |
| `prewarmedUrls` | `string[]` | Image variants pre-generated from `transformations` |
| `queuedTransformationUrls` | `string[]` | Video variants queued for background processing |

HEIC and HEIF files are converted to JPEG server-side, so the returned `path` may end in `.jpg`.

## Troubleshooting

> [accordion] CORS error in the browser console
>
> Add your app's origin to `CORS_ORIGIN` on the Openinary instance (comma-separated for multiple origins) and restart the API. The origin must match exactly, including scheme and port.

> [accordion] 401 'Presigned upload URL has expired'
>
> The signature's `expires` timestamp passed before the upload started. Increase `expiresIn` slightly (keep it under the 3600s server cap) and check for clock skew between your server and the API.

> [accordion] 401 'Authentication required' on POST /upload
>
> The signature didn't verify, usually because the `folder` used to sign it doesn't exactly match the folder submitted with the upload, the signature was tampered with, or the `API_SECRET` used by the Openinary instance changed since the signature was minted. The component always uses the folder returned by `sign()`, so this typically means your signing endpoint and the Openinary instance disagree, or the request was hand-crafted outside the component.

> [accordion] 401/403 calling POST /upload/sign itself
>
> That endpoint requires a real Openinary API key or session, check `OPENINARY_API_KEY` on your backend, not the browser. It should never be called directly from client code.

> [accordion] signUpload throws 'Failed to sign upload (HTTP 404)', or the response is a redirect to /login
>
> `NEXT_PUBLIC_OPENINARY_URL` is pointing at a full-stack instance's bare domain. In full stack mode the API only lives under `/api` (nginx routes everything else to the web dashboard), so append `/api` to the URL, see the note in [Set your environment variables](#installation). API-only deployments don't need the suffix.

> [accordion] File rejected before upload
>
> Client-side validation mirrors the server. Check the file type is in `accept` and the size is within `maxSize` (and the server's `MAX_FILE_SIZE_MB`).

## How it compares to Uploadcare

Like Uploadcare's File Uploader, this component uses a backend-signed, short-lived credential so the browser never holds a secret. The difference: uploads go to **your** self-hosted Openinary instance and storage, no third-party service, no per-file pricing.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.