> ## 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.

# Integrate with React

> Add secure file uploads and media delivery to any React app, on Openinary Cloud or your own instance: your backend mints a short-lived signature, the browser uploads with it, and your API key never reaches the bundle.

This guide wires Openinary into a React app whose backend is separate, a Vite frontend with an Express, Hono or Fastify API behind it. The component is the same one Next.js uses; only the plumbing around it differs. It works the same on Openinary Cloud and on a self-hosted instance, and the few steps that differ say which is which.

Copy each file into your project as you go. Every snippet is complete.

<Note>
  On Next.js the route handler and the uploader live in one project, which changes where the environment variables go. See [Integrate with Next.js](/guides/integrate/nextjs).
</Note>

## Prerequisites

Both deployments need a project set up for shadcn/ui (`components.json` present) and the shadcn CLI **v3 or newer**, which is what resolves namespaced registries.

<Tabs>
  <Tab title="Cloud">
    * An account with a bucket, see [Cloud Quickstart](/cloud/quickstart).
    * An **API key** pinned to that bucket, from **Settings → API keys** in the dashboard.

    Nothing else. No `API_SECRET`, no `CORS_ORIGIN` allow-list, no `MAX_FILE_SIZE_MB`, and `POST /upload` accepts token-authenticated uploads from any origin.
  </Tab>

  <Tab title="Self-hosted">
    * A running instance, see [Quickstart](/quickstart).
    * `API_SECRET` set on the instance (**64 characters**, `openssl rand -hex 32`). It computes the HMAC signature, and is the same secret used for [signed delivery URLs](/media-transformations/signed-urls).
    * An **API key** for your backend to sign with, see [API Keys](/api-reference/api-keys/create).
    * `CORS_ORIGIN` including the origin of the app that embeds the uploader, comma-separated for several: `CORS_ORIGIN=https://app.example.com,https://admin.example.com`.
    * `MAX_FILE_SIZE_MB` if you need to raise the default 50 MB limit.
  </Tab>
</Tabs>

## Install

<Steps>
  <Step title="Register the Openinary registry">
    The uploader installs as source you own, not as a package. Point shadcn at the namespace once.

    ```json components.json theme={null}
    {
      "registries": {
        "@openinary": "https://raw.githubusercontent.com/openinary/openinary/main/r/{name}.json"
      }
    }
    ```
  </Step>

  <Step title="Add the component and the signing helper">
    ```bash theme={null}
    npx shadcn@latest add @openinary/file-uploader
    npx shadcn@latest add @openinary/upload-token
    ```

    The first writes `components/openinary/file-uploader.tsx` and its hook into the frontend, with drag & drop, per-file progress, previews, retry, cancel and client-side validation. The second writes `lib/upload-token.ts`, a thin client for `POST /upload/sign`.

    <Warning>
      `upload-token` belongs to **your backend**, not to the Vite app. It reads an API key, and anything the Vite app imports ends up in the bundle. Run that second command in the backend project, or move the file there.
    </Warning>

    <Note>
      No shadcn in this project? Don't run `shadcn init` for it, that would restructure a project that never asked for it. Take `use-file-upload.ts` alone, verbatim, from [the registry JSON](https://raw.githubusercontent.com/openinary/openinary/main/r/file-uploader.json), which carries each file's target and full content. The hook holds all the logic and no styling, so you can write the markup your codebase would have written anyway.
    </Note>
  </Step>

  <Step title="Set your environment">
    ```bash .env theme={null}
    # Frontend: the browser posts uploads straight here
    VITE_OPENINARY_URL=YOUR_OPENINARY_URL

    # Backend only, where signUpload runs. Never in the Vite env.
    OPENINARY_URL=YOUR_OPENINARY_URL
    OPENINARY_API_KEY=your-openinary-api-key
    ```

    <Note>
      `YOUR_OPENINARY_URL` is `https://cdn.openinary.dev` on Cloud. Self-hosted, it's your own domain, plus `/api` in full stack mode where nginx reserves the root for the dashboard.
    </Note>

    <Warning>
      The API key mints upload signatures, so it is a secret. Keep it in the backend environment only, never in a `VITE_*` variable, never in client code, never in the repository. Vite inlines every `VITE_*` variable into the bundle, so the prefix is the whole difference between a secret and a public value.
    </Warning>
  </Step>
</Steps>

## Mint the signature on your server

Your backend holds the key and hands the browser a signature that dies in five minutes. `signUpload()` makes that call; you write the route around it, in whatever framework your API uses.

```ts server/upload-token.ts theme={null}
import express from "express";
import { signUpload } from "./lib/upload-token";

const app = express();

// requireAuth is your own: this route hands out an upload grant.
app.post("/api/upload-token", requireAuth, async (req, res) => {
  const signed = await signUpload(
    process.env.OPENINARY_URL!,
    process.env.OPENINARY_API_KEY!,
    // Scoped to the authenticated user, server-side.
    { expiresIn: 300, folder: `users/${req.user.id}` },
  );
  res.json(signed);
});
```

Two things this route has to do, and `signUpload()` does neither for you:

<Warning>
  **Authenticate it with whatever this app already uses.** The route hands out an upload grant, so it belongs wherever your existing auth lives, in the API rather than in the app that renders the uploader.

  **Derive `folder` server-side, from the authenticated user.** A folder read from the request body is a folder any visitor can choose, including someone else's.
</Warning>

<Note>
  Running the API on Cloudflare Workers, Deno or Bun? Add a `User-Agent` header to `signUpload`'s fetch. Those runtimes send none on outbound requests, and a request without one comes back as an HTML block page instead of JSON.
</Note>

The route returns `{ signature, expires, folder }`. Those are opaque values that expire in minutes, so they are safe to hand to the browser. Keep `expiresIn` short, 60 to 300 seconds is plenty; the server clamps it to 3600 anyway, and it only has to live long enough to start the upload.

<Tip>
  A frontend on `app.example.com` calling an API on `api.example.com` needs CORS on **your own** signing route, which is separate from anything Openinary is configured with.
</Tip>

## Drop in the uploader

```tsx src/uploader.tsx theme={null}
import { useState } from "react";
import { FileUploader } from "@/components/openinary/file-uploader";

export function Uploader() {
  const [image, setImage] = useState<string | null>(null);

  return (
    <>
      <FileUploader
        baseUrl={import.meta.env.VITE_OPENINARY_URL}
        sign={async () => {
          const res = await fetch("/api/upload-token", { method: "POST" });
          if (!res.ok) throw new Error("Could not sign upload");
          return res.json();
        }}
        onSuccess={(files) => {
          // files[0].path is what you store; files[0].url is the delivery
          // path, with no transformation segment in it.
          setImage(
            `${import.meta.env.VITE_OPENINARY_URL}${files[0].url}`,
          );
        }}
      />

      {image && <img alt="Uploaded" src={image} />}
    </>
  );
}
```

`baseUrl` is required outside Next.js: the component's fallback reads `process.env.NEXT_PUBLIC_OPENINARY_URL`, which doesn't exist here.

`sign()` runs right before each upload batch and again on every retry, so an expired signature never survives a retry. The destination folder is whatever the signature is scoped to, and a `folder` prop on the component is ignored when a signature is used.

<Note>
  The `transformations` prop pre-warms variants at upload time on a self-hosted instance, see [Upload & Pre-warm](/media-transformations/upload-and-prewarm). **On Cloud it is ignored**, along with the `prewarmedUrls` and `queuedTransformationUrls` fields, because variants are generated on the first request instead.
</Note>

For the full props table and styling, see [`<FileUploader />`](/guides/file-uploader). On Cloud, [the Cloud page](/cloud/file-uploader) adds the behaviour table for the props that differ.

## Render it once, as the original

`onSuccess` gives you two values worth knowing apart:

<ResponseField name="path" type="string">
  The stored path, e.g. `users/42/photo.jpg`. **This is what you persist**, not a URL. It may be de-duplicated if a file of that name already existed (`photo (1).jpg`), so always use this value rather than the name you sent.
</ResponseField>

<ResponseField name="url" type="string">
  The delivery path, with no transformation segment: `/t/{path}` self-hosted, `/b/{bucketId}/t/{path}` on Cloud. The server echoes it rather than expecting you to rebuild it, so prefix it with your base URL and use it unchanged, on either deployment.
</ResponseField>

Render it once and never swap it for another URL. That form is served straight from storage, so it decodes 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. Insert the transformation right after `/t/` in the `url` you stored, and you don't need to know which deployment shape it has:

```tsx theme={null}
// The first `/t/` is always the delimiter, whatever the deployment.
const thumbnail = `${import.meta.env.VITE_OPENINARY_URL}${url.replace(
  "/t/",
  "/t/w_600,c_fill,f_webp/",
)}`;
```

<Warning>
  A confirmation, not a gallery. The original is full-size bytes and one CDN request, which is right for the single file that just arrived and wrong for a grid of fifty, use transformations there. Only jpg, png, webp, avif and gif decode in a browser, anything else is a link. And drop the component's local preview once the upload finishes rather than showing both, that second copy is the same swap.
</Warning>

## Verify

Run the app, upload a real file through the new UI, and confirm three things:

<Steps>
  <Step title="The signing route returns 200">
    With a `signature`, an `expires` and a `folder` in the body.
  </Step>

  <Step title="The path reaches its destination">
    The form field, state, or column where this project keeps such a value.
  </Step>

  <Step title="The image decodes on the first try">
    `naturalWidth > 0`, no retry, no flicker. If you needed a retry to see it, you're rendering a transformation rather than the original.
  </Step>
</Steps>

## Common failures

<AccordionGroup>
  <Accordion title="signUpload throws 'Failed to sign upload (HTTP 404)', or the response redirects to /login">
    `OPENINARY_URL` has the wrong shape for your deployment. A full-stack self-hosted instance needs the `/api` suffix, because nginx routes everything else to the dashboard. Cloud and API-only deployments must not have it.
  </Accordion>

  <Accordion title="401 on POST /upload/sign">
    The API key is missing, disabled, expired, or the call is coming from the browser, where the key shouldn't be at all. Check `OPENINARY_API_KEY` on your backend.
  </Accordion>

  <Accordion title="401 'Invalid or expired upload signature' on POST /upload">
    The signature's `expires` passed before the upload started, or the folder used to sign doesn't match the one submitted. The component always uses the folder `sign()` returned and re-signs on retry, so this normally means the signing route and the instance disagree. On self-hosted, also check for clock skew and for an `API_SECRET` that changed after the signature was minted.
  </Accordion>

  <Accordion title="CORS error in the console">
    First work out whose route failed. Your own signing route needs CORS if the frontend is on a different origin. For `POST /upload` itself: self-hosted, add the app's origin to `CORS_ORIGIN` and restart the API, matching scheme and port exactly; Cloud accepts token-authenticated uploads from any origin, so a failure there is usually a browser calling `/upload/sign` or `/storage` directly. Those are backend-only.
  </Accordion>

  <Accordion title="402 quota_exceeded (Cloud only)">
    An upload that would push the account past its storage allowance is refused with `402` and a body naming the feature. Surface it, don't retry it, the retry fails identically until the plan or the usage changes. See [Plans and limits](/cloud/overview#plans-and-limits).
  </Accordion>

  <Accordion title="The image appears late, 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. Show the original instead, see [Render it once, as the original](#render-it-once-as-the-original).
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Component reference" icon="puzzle-piece" href="/guides/file-uploader">
    Every prop, styling, and the full server response shape.
  </Card>

  <Card title="Transformations" icon="wand-magic-sparkles" href="/media-transformations/overview">
    Resizing, cropping, format conversion, video, and signed delivery URLs.
  </Card>
</CardGroup>
