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.
- Cloud
- Self-hosted
- An account with a bucket, see Cloud Quickstart.
- An API key pinned to that bucket, from Settings → API keys in the dashboard.
API_SECRET, no CORS_ORIGIN allow-list, no MAX_FILE_SIZE_MB, and POST /upload accepts token-authenticated uploads from any origin.Install
Register the Openinary registry
Add the component and the signing helper
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.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, 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.Set your environment
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.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.
signUpload() does neither for you:
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.{ 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.
Drop in the uploader
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.
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.<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:
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./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.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:
Verify
Run the app, upload a real file through the new UI, and confirm three things:The signing route returns 200
signature, an expires and a folder in the body.The path reaches its destination
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
signUpload throws 'Failed to sign upload (HTTP 404)', or the response redirects to /login
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.401 on POST /upload/sign
401 on POST /upload/sign
OPENINARY_API_KEY on your backend.401 'Invalid or expired upload signature' on POST /upload
401 'Invalid or expired upload signature' on POST /upload
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.CORS error in the console
CORS error in the console
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.402 quota_exceeded (Cloud only)
402 quota_exceeded (Cloud only)
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.The image appears late, or flashes broken first
The image appears late, or flashes broken first