> ## 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 /> on Cloud

> Wire the Openinary React file uploader to Openinary Cloud: drag & drop uploads straight from the browser, with no API key in the bundle.

![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)

The [Openinary File Uploader](https://docs.openinary.dev/guides/file-uploader.md) is a shadcn/ui-compatible React component with drag & drop, per-file progress, previews, retry, cancel and client-side validation. It works against Cloud unchanged, only the configuration differs.

This page covers the Cloud wiring. For the full props table, styling and the component's own troubleshooting, see the [component guide](https://docs.openinary.dev/guides/file-uploader.md).

## How it stays safe

The browser never holds your API key. Your backend mints a **short-lived presigned signature**, the browser uploads with that.

**1. Your backend requests a signature**

A route you control calls [`POST /upload/sign`](https://docs.openinary.dev/cloud/api#presigned-uploads) with your Cloud API key, scoping the upload to a folder and an expiry.

**2. The browser gets the signature**

The component calls your `sign()` function right before uploading and gets back `{ signature, expires, folder }`, opaque values that die in minutes.

**3. Cloud verifies it**

`POST /upload` reads the destination account, bucket and folder **from the token itself**, never from the request. A tampered, replayed or expired token is rejected.

The token names its own bucket, so editing the form fields cannot redirect an upload into another bucket or another customer's storage.

## Prerequisites

* A Cloud account, see [Cloud Quickstart](https://docs.openinary.dev/cloud/quickstart.md).
* An **API key** pinned to the bucket you want uploads to land in.
* A project set up for shadcn/ui (`components.json` present), with the shadcn CLI **v3 or later** for namespaced registries.

Nothing else. No `API_SECRET`, no `CORS_ORIGIN` allow-list, no `MAX_FILE_SIZE_MB`, Cloud handles all of it, and `POST /upload` accepts token-authenticated uploads from any origin.

## Installation

**1. Register the Openinary namespace**

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

**2. Add the component and the signing helper**

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

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

The first installs the component and its hook into `components/openinary/`; the second installs `lib/upload-token.ts`, a thin client for `POST /upload/sign`.

**3. Set your environment variables**

```bash .env.local theme={null}
# Public: the browser posts the upload here directly
NEXT_PUBLIC_OPENINARY_URL=https://cdn.openinary.dev

# Secret: server only, never exposed to the browser
OPENINARY_API_KEY=oik_your_api_key
```

There is no `/api` suffix on Cloud, unlike a self-hosted full-stack instance. The API is served at the root of `cdn.openinary.dev`.

## Create a signing endpoint

> [!WARNING]
>
> Protect this route with your own authentication, and derive `folder` from the **authenticated user** server-side. A folder sent by the browser is a folder any visitor can choose, including someone else's.

> [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(
>     "https://cdn.openinary.dev",
>     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(
>       "https://cdn.openinary.dev",
>       env.OPENINARY_API_KEY,
>       { expiresIn: 300, folder: "uploads" },
>     );
>     return Response.json(signed);
>   },
> };
> ```

## Usage

```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) => {
        // files[0].url is "/b/{bucketId}/t/{path}"
        const url = `${process.env.NEXT_PUBLIC_OPENINARY_URL}${files[0].url}`;
        console.log("Uploaded:", url);
      }}
      onError={({ message }) => toast.error(message)}
    />
  );
}
```

## What comes back

`onSuccess` receives the stored files.

| Field | Description |
| - | - |
| `path` | The stored path, e.g. `uploads/photo.jpg`. De-duplicated if the name was taken (`photo (1).jpg`), so always use this rather than the name you sent |
| `url` | The delivery path, `/b/{bucketId}/t/{path}`. Prefix with `https://cdn.openinary.dev` for a full URL, and insert transformations right after `/t/` |
| `filename` | The stored filename |

```text theme={null}
https://cdn.openinary.dev/b/{bucketId}/t/w_600,c_fill,f_webp/uploads/photo.jpg
```

To display the file that was just uploaded, use `url` unchanged: see [showing the file you just uploaded](#showing-the-file-you-just-uploaded).

HEIC and HEIF files are converted to JPEG on upload, so `path` may end in `.jpg`.

## Showing the file you just uploaded

Render it once, as the original, and never swap it for another URL afterwards. `url` already points at `/t/{path}` with no transformation segment, so use it as it comes:

```tsx theme={null}
onSuccess={(files) => {
  setImage(`${process.env.NEXT_PUBLIC_OPENINARY_URL}${files[0].url}`);
}}
```

That form is served straight from storage, so it renders on the first try, every time.

A sized transformation can't promise the same, because it's generated on demand: the first request for one returns `202 {"status":"processing"}` rather than bytes, and an `<img>` can't decode a JSON body, it fires `onerror` once and never retries. For a file uploaded seconds ago that first request is yours, so the picture arrives late, after a broken-image frame. Transformed URLs are for the next page load, once the file is at rest.

Treat this as a confirmation, not a gallery. The original is full-size bytes and one CDN request, which is right for the one file that just arrived and wrong for a grid of fifty, where transformations belong. Only jpg, png, webp, avif and gif decode in a browser; anything else is a link. Drop the component's local preview once the upload finishes rather than showing both.

## Cloud-specific behaviour

| Prop / field | On Cloud |
| - | - |
| `baseUrl` / `NEXT_PUBLIC_OPENINARY_URL` | `https://cdn.openinary.dev`, no `/api` suffix |
| `transformations` | **Ignored.** Variants are generated on the first request instead of at upload time |
| `prewarmedUrls` / `queuedTransformationUrls` | Never returned, for the same reason |
| `maxSize` | Component default is 50 MB. Cloud has no server-side size setting, raise it only after testing the sizes you need |
| Destination folder | Always the one in the signature. `folder` set on the component is ignored when a signature is used |

## Handling quota errors

An upload that would push the account past its storage allowance is refused with `402` and a JSON body naming the feature. Surface it, don't retry it, the retry will fail identically until the plan or the usage changes.

```tsx theme={null}
<FileUploader
  sign={sign}
  onError={({ message }) => {
    // "Storage limit reached on the Free plan. …"
    toast.error(message);
  }}
/>
```

See [Errors](https://docs.openinary.dev/cloud/api.md#errors) and [Plans and limits](https://docs.openinary.dev/cloud/overview.md#plans-and-limits).

## Troubleshooting

> [accordion] signUpload throws 'Failed to sign upload (HTTP 404)'
>
> `NEXT_PUBLIC_OPENINARY_URL` probably still ends in `/api`, that suffix is a self-hosted full-stack thing. On Cloud the API is at the root: `https://cdn.openinary.dev`.

> [accordion] 401 on POST /upload/sign
>
> The API key is missing, disabled, expired, or you're calling the endpoint from the browser (where the key shouldn't be at all). Check `OPENINARY_API_KEY` on your backend, and that the key is still enabled in **Settings → API keys**.

> [accordion] 401 'Invalid or expired upload signature'
>
> The token's `expires` passed before the upload started, or the signature was altered. Raise `expiresIn` a little (the server clamps it to 3600s). The component always re-signs on retry, so this shouldn't survive a retry.

> [accordion] 402 quota\_exceeded
>
> A plan allowance ran out. `feature` in the body says which one. Storage is cumulative, deleting files frees it back; everything else resets monthly.

> [accordion] Files land in the wrong folder
>
> On Cloud the destination comes from the **signature**, not the component. Fix the `folder` you pass to `signUpload` on your backend.

> [accordion] The uploaded image appears after a delay, or flashes broken first
>
> You're rendering a transformation of a file that was just uploaded, so your own request is the one generating it and it answers `202` before it answers bytes. Show the original instead, see [Showing the file you just uploaded](#showing-the-file-you-just-uploaded). Retrying on a visible `<img>` makes it worse, every cycle paints the broken state again.

> [accordion] CORS error in the console
>
> `POST /upload` accepts token-authenticated uploads from any origin, so a CORS failure here usually means the request isn't the one you think, e.g. your code is calling `/upload/sign` or `/storage` from the browser. Those are backend-only endpoints. Move the call server-side.


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