Skip to main content
The FileUploader component with files queued for upload
The Openinary File Uploader 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.

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 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.
Because the token names its own bucket, an upload can’t be redirected into another bucket, or another customer’s storage, by editing the form fields.

Prerequisites

  • A Cloud account, see Cloud Quickstart.
  • 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+ 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

components.json
2

Add the component and the signing helper

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

.env.local
Unlike a self-hosted full-stack instance, there is no /api suffix on Cloud. The API is served at the root of cdn.openinary.dev.

Create a signing endpoint

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.
app/api/upload-token/route.ts

Usage

What comes back

onSuccess receives the stored files:
string
The stored path, e.g. uploads/photo.jpg. 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.
string
The delivery path, /b/{bucketId}/t/{path}. Prefix it with https://cdn.openinary.dev for a full URL, and insert transformations right after /t/:
To display the file that was just uploaded, use it unchanged, see Showing the file you just uploaded.
string
The stored filename.
HEIC/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:
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.
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.

Cloud-specific behaviour

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.
See Errors and Plans and limits.

Troubleshooting

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.
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.
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.
A plan allowance ran out. feature in the body says which one. Storage is cumulative, deleting files frees it back; everything else resets monthly.
On Cloud the destination comes from the signature, not the component. Fix the folder you pass to signUpload on your backend.
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. Retrying on a visible <img> makes it worse, every cycle paints the broken state again.
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.