Skip to main content
The Openinary File Uploader is a shadcn/ui-compatible React component that lets your users upload media directly to your self-hosted Openinary instance. It ships with drag & drop, per-file progress, previews, retry, cancel, and client-side validation. It installs the same way as any shadcn component:
pnpm dlx shadcn@latest add @openinary/file-uploader

Security model

The component never holds an API key. Instead it uses presigned upload signatures, minted by POST /upload/sign — the same HMAC-SHA256 pattern Openinary already uses for signed delivery URLs.
1

Your backend requests a signature

A protected endpoint on your server calls POST /upload/sign with your Openinary API key, scoping the request to a folder and an expiry window.
2

The browser gets the signature

The component calls your sign() function right before uploading. It returns { signature, expires, folder } — opaque values that expire in minutes.
3

Openinary verifies the signature

POST /upload checks the signature against the submitted folder and expires before accepting the file. A tampered or expired signature is rejected.
Your Openinary API key never leaves your backend — only your server should call POST /upload/sign. The endpoint you create for the component must be protected by your own auth and rate limiting; derive folder from the authenticated user, not from the client.

Prerequisites

  • A running Openinary instance (see Quickstart).
  • API_SECRET set on the instance (64 charactersopenssl rand -hex 32) — used internally to compute the HMAC signature. Same secret used for signed delivery URLs.
  • An API key for your backend to call POST /upload/sign with (see API Keys).
  • CORS_ORIGIN set to include the origin(s) of the app embedding the uploader. Multiple origins are comma-separated:
    CORS_ORIGIN=https://app.example.com,https://admin.example.com
    
  • MAX_FILE_SIZE_MB if you need to raise the default 50 MB limit.
  • A project already set up for shadcn/ui (components.json present). See the shadcn install guide. Requires the shadcn CLI v3 or later for namespaced registries.

Installation

1

Register the Openinary namespace

Add the @openinary registry to your project’s components.json:
components.json
{
  "registries": {
    "@openinary": "https://raw.githubusercontent.com/openinary/openinary/main/r/{name}.json"
  }
}
2

Add the component

pnpm dlx shadcn@latest add @openinary/file-uploader
This installs the component and its hook into components/openinary/, pulls in the shadcn button and progress dependencies, and adds NEXT_PUBLIC_OPENINARY_URL to your .env.local.
3

Add the signing helper

pnpm dlx shadcn@latest add @openinary/upload-token
This installs lib/upload-token.ts, a thin client for POST /upload/sign — it makes one fetch call and doesn’t compute anything cryptographic itself; Openinary does that.
4

Set your environment variables

.env.local
# Public — the browser calls this Openinary API directly for the upload
# /api is required in full stack mode (nginx only exposes the API under /api); drop it for API-only deployments
NEXT_PUBLIC_OPENINARY_URL=https://your-domain.com/api

# Secret — server only, an Openinary API key, never exposed to the browser
OPENINARY_API_KEY=your-openinary-api-key

Create a signing endpoint

The component needs a sign() function that returns a presigned signature. Back it with a small endpoint on your server using the helper you just installed.
Protect this endpoint with your own authentication and derive folder from the authenticated user on the server. Never trust a folder sent by the browser, or a user could upload into someone else’s scope.
app/api/upload-token/route.ts
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);
}
Keep expiresIn short — 60–300 seconds is plenty (the server clamps it to at most 3600). It only needs to live long enough to start the upload.

Usage

"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) => console.log("Uploaded:", files)}
    />
  );
}

Props

sign
() => Promise<SignedUpload> | SignedUpload
required
Returns { signature, expires, folder } from your signing endpoint. Called before each upload batch and again on retry, so it always fetches a fresh signature. The upload’s destination folder is whatever the signature is scoped to.
baseUrl
string
Base URL of your Openinary API, e.g. https://media.example.com. Falls back to NEXT_PUBLIC_OPENINARY_URL.
multiple
boolean
default:"true"
Allow selecting more than one file.
accept
Record<string, string[]>
MIME types mapped to allowed extensions. Defaults to the Openinary media types (JPEG, PNG, WebP, AVIF, GIF, HEIC/HEIF, PSD, MP4, MOV, WebM).
maxSize
number
default:"52428800"
Maximum file size in bytes. Default is 50 MB. Must not exceed the server’s MAX_FILE_SIZE_MB.
maxFiles
number
Client-side cap on the number of files. This is a UI convenience only — POST /upload/sign does not encode a file-count limit, so it isn’t enforced server-side.
transformations
string[]
Transformation segments to pre-warm after upload, e.g. ["w_800,f_webp"]. See Upload & Pre-warm.
autoUpload
boolean
default:"false"
Start uploading as soon as files are added, hiding the explicit Upload button.
disabled
boolean
default:"false"
className
string
Additional classes for the root element.
onSuccess
(files: UploadedFile[]) => void
Called with the stored files after a successful batch.
onError
(error: UploadFileError) => void
Called when a file fails to upload.
onProgress
(file: FileUploadState, progress: number) => void
Called with upload progress (0–100) for each file.

Styling & theming

The component is built entirely with shadcn’s design tokens (border, muted, primary, destructive, ring…), so it automatically matches your theme and dark mode. Because the source is copied into your project, you own it — edit components/openinary/file-uploader.tsx freely. Use className for one-off overrides.

Server response

On success, onSuccess receives the files stored by the API:
filename
string
The stored filename.
path
string
The stored path. May be renamed if a file with the same name already exists (e.g. image (1).jpg).
url
string
The transformation URL prefix, e.g. /t/{path}. Prefix it with your baseUrl to build a full URL.
prewarmedUrls
string[]
URLs of image variants pre-generated from transformations.
queuedTransformationUrls
string[]
URLs of video variants queued for background processing.
HEIC/HEIF files are automatically converted to JPEG by the server, so the returned path may end in .jpg.

Troubleshooting

Add your app’s origin to CORS_ORIGIN on the Openinary instance (comma-separated for multiple origins) and restart the API. The origin must match exactly, including scheme and port.
The signature’s expires timestamp passed before the upload started. Increase expiresIn slightly (keep it under the 3600s server cap) and check for clock skew between your server and the API.
The signature didn’t verify — usually because the folder used to sign it doesn’t exactly match the folder submitted with the upload, the signature was tampered with, or the API_SECRET used by the Openinary instance changed since the signature was minted. The component always uses the folder returned by sign(), so this typically means your signing endpoint and the Openinary instance disagree, or the request was hand-crafted outside the component.
That endpoint requires a real Openinary API key or session — check OPENINARY_API_KEY on your backend, not the browser. It should never be called directly from client code.
NEXT_PUBLIC_OPENINARY_URL is pointing at a full-stack instance’s bare domain. In full stack mode the API only lives under /api (nginx routes everything else to the web dashboard), so append /api to the URL — see the note in Set your environment variables. API-only deployments don’t need the suffix.
Client-side validation mirrors the server. Check the file type is in accept and the size is within maxSize (and the server’s MAX_FILE_SIZE_MB).

How it compares to Uploadcare

Like Uploadcare’s File Uploader, this component uses a backend-signed, short-lived credential so the browser never holds a secret. The difference: uploads go to your self-hosted Openinary instance and storage — no third-party service, no per-file pricing.