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

> Add secure file uploads and media delivery to a Next.js App Router project, on Openinary Cloud or your own instance: a route handler mints a short-lived signature, the browser uploads with it, and your API key never leaves the server.

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](/guides/integrate/react).

## Install

<Steps>
  <Step title="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.

    ```json components.json theme={null}
    {
      "registries": {
        "@openinary": "https://openinary.dev/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, 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](https://openinary.dev/r/file-uploader.json), which carries each file's target and content. The hook is all logic and no styling, so write your own markup.
  </Step>

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

    # Secret: server only, never NEXT_PUBLIC_*
    OPENINARY_API_KEY=your-openinary-api-key
    ```

    `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.
  </Step>
</Steps>

## What each deployment needs

<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, **at least 16 characters**. `openssl rand -hex 32` gives you a strong 64-character one. 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>

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

```ts app/api/upload-token/route.ts theme={null}
import { NextResponse } from "next/server";
import { signUpload } from "@/lib/upload-token";
import { auth } from "@/lib/auth"; // whatever this app already uses

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,
      // Derived from the session, never from the request body.
      folder: `users/${session.user.id}`,
    },
  );

  return NextResponse.json(signed);
}
```

<Warning>
  **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.
</Warning>

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

```tsx app/upload/uploader.tsx theme={null}
"use client";

import { useState } from "react";
import { FileUploader } from "@/components/openinary/file-uploader";

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

  return (
    <>
      <FileUploader
        maxFiles={10}
        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) => {
          // Persist files[0].path. files[0].url is the delivery path,
          // with no transformation segment in it.
          setImage(
            `${process.env.NEXT_PUBLIC_OPENINARY_URL}${files[0].url}`,
          );
        }}
      />

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

`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](/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.

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.

| Field | What it holds |
| - | - |
| `path` | 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 use it rather than the name you sent. |
| `url` | The delivery path, with no transformation segment: `/t/{path}` self-hosted, `/b/{bucketId}/t/{path}` on Cloud. The server echoes it, so prefix it with your base URL and use it unchanged on either deployment. |

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:

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

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.

<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">
    `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.
  </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 on POST /upload after signing">
    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.
  </Accordion>

  <Accordion title="CORS error in the console">
    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.
  </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, 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 just uploaded, so your own request is 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>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.