Skip to main content
You end up with a <FileUploader /> in a client component, a route handler that mints a short-lived upload signature, and an API key that never leaves the server. Two shadcn commands install both pieces as source you own. The flow is identical on Openinary Cloud and on a self-hosted instance, and the steps that differ say which is which. Using Vite, Remix, or any React app whose backend isn’t Next.js? See Integrate with React.

Install

1

Register the Openinary registry

The uploader installs as source you own, not as a package. You need a shadcn/ui project (components.json present) and the shadcn CLI v3 or newer, which resolves namespaced registries.
components.json
2

Add the component and the signing helper

The first writes components/openinary/file-uploader.tsx and its hook, 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.No shadcn here? Don’t run shadcn init, it restructures a project that never asked for it. Take use-file-upload.ts verbatim from the registry JSON, which carries each file’s target and content. The hook is all logic and no styling, so write your own markup.
3

Set your environment

.env.local
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.The API key mints upload signatures, so it is a secret. Keep it out of NEXT_PUBLIC_*, out of client code, out of the repository. Confirm .env.local is gitignored.

What each deployment needs

  • An account with a bucket, see 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.

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.
app/api/upload-token/route.ts
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. Authenticate the route too, with whatever this app already uses. signUpload() does neither for you.
If your frontend and API are separate deployments, the route belongs in the API, not in the app that renders the uploader. signUpload sends its own User-Agent, so Cloudflare Workers, Deno and Bun work without extra setup. Pass a userAgent option if you want to identify your app specifically. The route returns { signature, expires, folder }, opaque values that expire in minutes and are safe to hand to the browser. Keep expiresIn short, 60 to 300 seconds is plenty. The server clamps it to 3600 anyway.

Drop in the uploader

app/upload/uploader.tsx
baseUrl is optional here: the component falls back to NEXT_PUBLIC_OPENINARY_URL. sign() runs before each upload batch and again on every retry, so an expired signature never survives a retry. The folder is whatever the signature is scoped to, and a folder prop is ignored when a signature is used. The transformations prop pre-warms variants at upload time on a self-hosted instance, see Upload & Pre-warm. On Cloud it is ignored, along with the prewarmedUrls and queuedTransformationUrls fields, because variants are generated on the first request instead. For the full props table and styling, see <FileUploader />. On Cloud, the Cloud page adds the behaviour table for the props that differ.

Render it once, as the original

onSuccess gives you two values worth knowing apart. Render url 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 that, because it’s generated on demand. On Openinary Cloud the first request for one returns 202 {"status":"processing"} rather than bytes. Self-hosted, that applies to video, while images are processed inline and come back as bytes on the first request. Where the 202 happens, an <img> can’t decode a JSON body: it fires onerror once and never retries. For a file uploaded seconds ago that request is yours, so the picture arrives 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 stored url, whatever the deployment shape:
Treat the original as a confirmation, not a gallery. It is full-size bytes and one CDN request, right for the file that just arrived and wrong for a grid of fifty, where you use transformations. 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.

Verify

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

The signing route returns 200

With a signature, an expires and a folder in the body.
2

The path reaches its destination

The form field, state, or column where this project keeps such a value.
3

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.

Common failures

NEXT_PUBLIC_OPENINARY_URL has the wrong shape for your deployment. A full-stack self-hosted instance needs the /api suffix. Cloud and API-only deployments must not have it.
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.
The signature’s expires passed before the upload started, or the folder used to sign doesn’t match the one submitted. The component re-signs on retry, so this usually means the signing route and the instance disagree. Self-hosted, also check clock skew and an API_SECRET that changed after the signature was minted.
Self-hosted: add your 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.
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, the retry fails identically until the plan or the usage changes. See Plans and limits.
You’re rendering a transformation of a file just uploaded, so your own request is generating it. Show the original instead, see Render it once, as the original.

Next steps

Component reference

Every prop, styling, and the full server response shape.

Transformations

Resizing, cropping, format conversion, video, and signed delivery URLs.