# dimah-s3

> Presign-first S3 toolkit: the server signs, the browser talks to your bucket. Not a hosted uploader, and not an S3 SDK wrapper.

TypeScript packages: `@dimah-s3/server` (handlers and hooks), `@dimah-s3/react` (headless client), optional `@dimah-s3/ui` (shadcn) and `@dimah-s3/db` (object tracking). Protocol types live in `@dimah-s3/core`. You pass an AWS SDK `S3Client`.

HTTP adapters: Next.js, Express, Hono, Fastify, Elysia, SvelteKit, Node, and Fetch. CLI starters include Next.js, Vite + Hono, and Hono.

Use it for direct uploads to a bucket you own, typed authorization, multipart/resume, and the same route rules for download and delete. Skip it when you need image or video transforms. List, copy, tagging, and other S3 operations stay in your own AWS SDK code.

Neighbors: UploadThing (hosted), Better Upload (BYO-bucket, upload-only), Uppy (client dashboard; can sit in front).

Install: `npx @dimah-s3/cli@latest create` or `npm i @dimah-s3/server @dimah-s3/react @aws-sdk/client-s3`.

Constraints:

- Never expose S3 credentials. The client sends a route name; the server owns keys under that route's `keyPrefix` (default: the route name). Default key is `{keyPrefix}/{uuid}/{name}`. `keyPrefix: false` generates `{uuid}/{name}`. Nested or identical prefixes across routes are rejected at init.
- Prefer one feature per named route. Combine upload, download, and delete only when those callers share the key namespace.
- Scope per-user folders with `upload.object` / `guard`. Enforce ownership with `db()` or a download/delete `guard`.
- Trust `onConfirmed` (HeadObject, including multipart complete) for size and type, not the presign body. `fileTypes` is the S3 Content-Type header and filename, not a byte sniff.
- Without `db()`, download can presign unconfirmed keys under `keyPrefix`. Auth and quota stay in consumer hooks.


# Introduction (https://s3.dimah.dev/docs)



**dimah-s3** is a modular, full-stack S3 toolkit for React and Node.js runtimes. It provides typed server handlers, headless React hooks, optional [shadcn](https://ui.shadcn.com) UI components, and a database plugin for object tracking.

It is not an S3 SDK wrapper. List, copy, tagging, and other operations stay
in your own code with the
[AWS SDK](https://www.npmjs.com/package/@aws-sdk/client-s3).

## Architecture flow [#architecture-flow]

<Flow
  label="Presign-first architecture"
  steps="[
  { name: &#x22;Browser&#x22;, kind: &#x22;client&#x22;, note: &#x22;requests signed URL&#x22; },
  { name: &#x22;Server&#x22;, kind: &#x22;server&#x22;, note: &#x22;executes guards & signs URL&#x22; },
  { name: &#x22;S3 Bucket&#x22;, kind: &#x22;s3&#x22;, note: &#x22;receives direct upload bytes&#x22; },
]"
/>

It follows a **presign-first** architecture: the backend generates short-lived signed URLs, the browser communicates directly with your S3-compatible storage, and AWS credentials never leave your server.

Coding agents: [llms.txt](https://s3.dimah.dev/llms.txt) · [llms-full.txt](https://s3.dimah.dev/llms-full.txt).

***

## What you get [#what-you-get]

* Fast setup — a working upload in minutes, not a week of wiring
* Full stack — server, client, and optional shadcn UI; not a client uploader you
  have to back yourself
* Full lifecycle — upload, download, and delete, including multipart
* Server hooks — auth, quotas, confirm, and cleanup where they belong
* Optional database — ownership, listings, and resumable uploads when you
  need them

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How does delete work compared to upload and download?">
    Upload and download are presigned (the client communicates directly with S3). Deletion is executed server-side: the client sends a delete request to your API, the server runs your `delete.guard`, issues `DeleteObjectCommand` via the AWS SDK, and triggers `onDeleted` cleanup.
  </Accordion>

  <Accordion title="Can I use dimah-s3 with Cloudflare R2 or MinIO?">
    Yes. Any S3-compatible storage works out of the box. For Cloudflare R2, configure `upload: { method: "PUT" }`. For MinIO, pass `forcePathStyle: true` in your S3Client.
  </Accordion>
</Accordions>

***

## Explore documentation [#explore-documentation]

<Cards>
  <Card title="Quickstart" href="/docs/quickstart" description="Get a working upload flow running in under 5 minutes." />

  <Card title="Server Guide" href="/docs/server" description="Configure routes, lifecycle hooks, guards, and runtimes." />

  <Card title="React Client" href="/docs/react" description="Headless hooks for upload, download, and delete." />

  <Card title="UI Components" href="/docs/react/ui" description="Prebuilt shadcn components for buttons, dropzones, and status." />

  <Card title="Providers" href="/docs/providers" description="Setup guides for AWS S3, Cloudflare R2, and MinIO." />

  <Card title="Database Plugin" href="/docs/db" description="Track object metadata, ownership scopes, and file listings." />
</Cards>


# Quickstart (https://s3.dimah.dev/docs/quickstart)



## Create a new project [#create-a-new-project]

Scaffold a full-stack project with preconfigured S3 routes and UI:

<CodeBlockTabs defaultValue="npm">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="npm">
      npm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="pnpm">
      pnpm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="yarn">
      yarn
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="bun">
      bun
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="npm">
    ```bash
    npx @dimah-s3/cli@latest create
    ```
  </CodeBlockTab>

  <CodeBlockTab value="pnpm">
    ```bash
    pnpm dlx @dimah-s3/cli@latest create
    ```
  </CodeBlockTab>

  <CodeBlockTab value="yarn">
    ```bash
    yarn dlx @dimah-s3/cli@latest create
    ```
  </CodeBlockTab>

  <CodeBlockTab value="bun">
    ```bash
    bun x @dimah-s3/cli@latest create
    ```
  </CodeBlockTab>
</CodeBlockTabs>

***

## Manual installation [#manual-installation]

<div className="fd-steps">
  <div className="fd-step">
    ### Install dependencies [#install-dependencies-step]

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i @dimah-s3/server @dimah-s3/react @aws-sdk/client-s3
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add @dimah-s3/server @dimah-s3/react @aws-sdk/client-s3
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add @dimah-s3/server @dimah-s3/react @aws-sdk/client-s3
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add @dimah-s3/server @dimah-s3/react @aws-sdk/client-s3
        ```
      </CodeBlockTab>
    </CodeBlockTabs>
  </div>

  <div className="fd-step">
    ### Configure S3 and routes [#configure-s3-and-routes-step]

    Define your AWS S3 client and instance. In this example, we create an `avatar` route for user profile photos:

    ```dotenv title=".env"
    S3_ENDPOINT="https://your-endpoint.example.com"
    S3_REGION="auto"
    S3_ACCESS_KEY_ID="your-access-key-id"
    S3_SECRET_ACCESS_KEY="your-secret-access-key"
    S3_BUCKET="your-bucket-name"
    ```

    ```ts title="lib/s3.ts"
    import { S3Client } from "@aws-sdk/client-s3";
    import { dimahS3, route } from "@dimah-s3/server";

    export const awsS3 = new S3Client({
      region: process.env.S3_REGION,
      endpoint: process.env.S3_ENDPOINT,
      credentials: {
        accessKeyId: process.env.S3_ACCESS_KEY_ID!,
        secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
      },
    });

    export const s3 = dimahS3({
      client: awsS3,
      bucket: process.env.S3_BUCKET!,
      routes: {
        avatar: route({
          upload: {
            fileTypes: ["image/*"],
            maxFileSize: 2 * 1024 * 1024, // 2MB
          },
        }),
      },
    });
    ```
  </div>

  <div className="fd-step">
    ### Mount the route handler [#mount-the-route-handler-step]

    <Tabs items="[&#x22;Next.js&#x22;, &#x22;Vite&#x22;, &#x22;Express&#x22;, &#x22;Hono&#x22;, &#x22;SvelteKit&#x22;]">
      <Tab value="Next.js">
        ```ts title="app/api/s3/[...s3]/route.ts"
        import { toNextJsHandler } from "@dimah-s3/server/next";
        import { s3 } from "@/lib/s3";

        export const { GET, POST, PUT, PATCH, DELETE } = toNextJsHandler(s3);
        ```
      </Tab>

      <Tab value="Vite">
        ```ts title="server/index.ts"
        import { Hono } from "hono";
        import { toHonoHandler } from "@dimah-s3/server/hono";
        import { s3 } from "./s3";

        const app = new Hono();
        app.on(
          ["GET", "POST", "PUT", "PATCH", "DELETE"],
          "/api/s3/*",
          toHonoHandler(s3),
        );
        ```
      </Tab>

      <Tab value="Express">
        ```ts title="server.ts"
        import express from "express";
        import { toExpressHandler } from "@dimah-s3/server/express";
        import { s3 } from "./s3";

        const app = express();
        app.all("/api/s3/*", toExpressHandler(s3));
        app.use(express.json());
        ```
      </Tab>

      <Tab value="Hono">
        ```ts title="src/index.ts"
        import { Hono } from "hono";
        import { toHonoHandler } from "@dimah-s3/server/hono";
        import { s3 } from "./s3";

        const app = new Hono();
        app.on(
          ["GET", "POST", "PUT", "PATCH", "DELETE"],
          "/api/s3/*",
          toHonoHandler(s3),
        );
        ```
      </Tab>

      <Tab value="SvelteKit">
        ```ts title="src/routes/api/s3/[...path]/+server.ts"
        import { toSvelteKitHandler } from "@dimah-s3/server/svelte-kit";
        import { s3 } from "$lib/s3";

        const handler = toSvelteKitHandler(s3);
        export const GET = handler;
        export const POST = handler;
        export const PUT = handler;
        export const PATCH = handler;
        export const DELETE = handler;
        ```
      </Tab>
    </Tabs>
  </div>

  <div className="fd-step">
    ### Create the client client & provider [#create-the-client-client--provider-step]

    ```ts title="lib/s3-client.ts"
    "use client";

    import { createS3Client } from "@dimah-s3/react";

    export const s3Client = createS3Client();
    export const S3Provider = s3Client.Provider;
    ```
  </div>

  <div className="fd-step">
    ### Wrap your application [#wrap-your-application-step]

    <Tabs items="[&#x22;npm package&#x22;, &#x22;shadcn registry&#x22;]">
      <Tab value="npm package">
        <CodeBlockTabs defaultValue="npm">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npm i @dimah-s3/ui shadcn
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm add @dimah-s3/ui shadcn
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn add @dimah-s3/ui shadcn
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun add @dimah-s3/ui shadcn
            ```
          </CodeBlockTab>
        </CodeBlockTabs>

        ```css title="app/globals.css"
        @import "shadcn/tailwind.css";
        @import "@dimah-s3/ui/styles.css";

        /* + shadcn theme variables (`--primary`, `--muted`, …) */

        /* Optional: theme dimah-s3 independently of the rest of the app
        @theme {
          --color-dimah-s3-primary: oklch(0.65 0.15 150);
        }
        */

        ```

        ```tsx title="app/layout.tsx"
        import { Toaster } from "@dimah-s3/ui";
        import { S3Provider } from "@/lib/s3-client";

        export default function RootLayout({
          children,
        }: {
          children: React.ReactNode;
        }) {
          return (
            <html lang="en">
              <body>
                <S3Provider>{children}</S3Provider>
                <Toaster />
              </body>
            </html>
          );
        }
        ```
      </Tab>

      <Tab value="shadcn registry">
        ```json title="components.json"
        {
          "registries": {
            "@dimah-s3": "https://s3.dimah.dev/r/{name}.json"
          }
        }
        ```

        <CodeBlockTabs defaultValue="npm">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npx shadcn@latest add @dimah-s3/upload-button
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm dlx shadcn@latest add @dimah-s3/upload-button
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn dlx shadcn@latest add @dimah-s3/upload-button
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun x shadcn@latest add @dimah-s3/upload-button
            ```
          </CodeBlockTab>
        </CodeBlockTabs>

        ```tsx title="app/layout.tsx"
        import { Toaster } from "@/components/ui/toast";
        import { S3Provider } from "@/lib/s3-client";

        export default function RootLayout({
          children,
        }: {
          children: React.ReactNode;
        }) {
          return (
            <html lang="en">
              <body>
                <S3Provider>{children}</S3Provider>
                <Toaster />
              </body>
            </html>
          );
        }
        ```
      </Tab>
    </Tabs>
  </div>

  <div className="fd-step">
    ### Add an upload button [#add-an-upload-button-step]

    ```tsx title="app/page.tsx"
    "use client";

    import { useUpload } from "@dimah-s3/react";
    import { UploadButton } from "@dimah-s3/ui";

    export default function AvatarUploadPage() {
      const upload = useUpload({ route: "avatar" });

      return <UploadButton upload={upload} />;
    }
    ```

    Selecting an image presigns a secure URL and uploads directly to your bucket under `avatar/{uuid}/{filename}`.
  </div>
</div>

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I restrict uploads to authenticated users?">
    Add a `guard` on the route or instance. Throw `errors.unauthorized()` or `errors.forbidden()` if the session is invalid:

    ```ts title="lib/s3.ts"
    import { errors, route } from "@dimah-s3/server";

    export const s3 = dimahS3({
      // ...
      routes: {
        avatar: route({
          guard: async ({ request }) => {
            const session = await getSession(request);
            if (!session) throw errors.unauthorized();
          },
          upload: {
            fileTypes: ["image/*"],
            maxFileSize: 2 * 1024 * 1024,
          },
        }),
      },
    });
    ```
  </Accordion>

  <Accordion title="How do I save the uploaded avatar key to my database?">
    Handle `onConfirmed` on the server or `onSuccess` on the client:

    ```ts title="Server-side (recommended)"
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
        onConfirmed: async ({ key, request }) => {
          const session = await getSession(request);
          await db.user.update({
            where: { id: session.userId },
            data: { avatarKey: key },
          });
        },
      },
    });
    ```

    ```tsx title="Client-side"
    const upload = useUpload({
      route: "avatar",
      onSuccess: (results) => {
        console.log("Uploaded avatar key:", results[0]?.key);
      },
    });
    ```
  </Accordion>

  <Accordion title="How do I use Cloudflare R2 instead of AWS S3?">
    Cloudflare R2 requires `upload: { method: "PUT" }` because it does not support presigned POST:

    ```ts title="lib/s3.ts"
    export const s3 = dimahS3({
      client: r2Client,
      bucket: process.env.R2_BUCKET!,
      routes: {
        avatar: route({
          upload: {
            method: "PUT",
            fileTypes: ["image/*"],
            maxFileSize: 2 * 1024 * 1024,
          },
        }),
      },
    });
    ```

    See the [Cloudflare R2 guide](https://s3.dimah.dev/docs/providers/cloudflare-r2) for full details.
  </Accordion>

  <Accordion title="How do I display or preview the uploaded image?">
    Use `useObjectUrl` to generate a temporary display URL, or construct your CDN URL if the bucket is public:

    ```tsx title="components/avatar-preview.tsx"
    "use client";

    import { useObjectUrl } from "@dimah-s3/react";

    export function AvatarPreview({ avatarKey }: { avatarKey: string }) {
      const { url } = useObjectUrl({
        route: "avatar",
        objectKey: avatarKey,
        disposition: "inline",
      });

      if (!url) return null;
      return (
        <img
          src={url}
          alt="User avatar"
          className="size-16 rounded-full object-cover"
        />
      );
    }
    ```
  </Accordion>
</Accordions>


# Comparison (https://s3.dimah.dev/docs/comparison)



dimah-s3 is a full-stack file toolkit for S3-compatible storage you control.
It covers upload, confirm, download, and delete under the same route policy.
Better Upload is a focused React uploader for the same storage model.
UploadThing is hosted. Uppy is a client uploader you wire yourself.

## Feature comparison [#feature-comparison]

| Feature                  |           dimah-s3           | [Better Upload](https://better-upload.com) | [UploadThing](https://uploadthing.com) | [Uppy](https://uppy.io) |
| ------------------------ | :--------------------------: | :----------------------------------------: | :------------------------------------: | :---------------------: |
| **Happy-path setup**     | Routes + hooks + optional UI |            Two packages, minutes           |               Hosted API               |       Client-only       |
| **S3 storage ownership** |          Your bucket         |                 Your bucket                |              Hosted bucket             |       Your bucket       |
| **Upload lifecycle**     | Presign + HeadObject confirm |          Presign (no confirm hook)         |               Hosted API               |       Client-only       |
| **Download & preview**   |    Presign, proxy, inline    |       Server helpers, no client hook       |               Hosted URLs              |           DIY           |
| **Object deletion**      |  Server-mediated with guards |       Server helpers, no client hook       |               Hosted API               |           DIY           |
| **Database tracking**    |   Optional (`@dimah-s3/db`)  |                    None                    |            Hosted dashboard            |           None          |
| **Client UI**            |       Headless + shadcn      |              shadcn uploaders              |            React components            |     Dashboard modal     |

Better Upload also ships S3 helpers (`presignGetObject`, `deleteObject`,
`listObjectsV2`, …). Those are utilities, not download/delete routes with
guards, hooks, or UI. dimah-s3 signs `storageClass`, `tagging`, and
`cacheControl` from `upload.object` the same way.

***

## When to use which [#when-to-use-which]

* **Better Upload** — you need a file picker into your bucket and will
  persist keys yourself. Fastest path for avatars and form attachments.
* **dimah-s3** — you need the rest of the object lifecycle: confirmed
  metadata, private downloads, deletes, quotas, or resumable multipart.
* **UploadThing** — you want a hosted bucket and dashboard.
* **Uppy** — you already have a backend and want a client dashboard.

***

## Why choose dimah-s3? [#why-choose-dimah-s3]

* **Unified lifecycle**: upload, confirm, download, and delete share one
  route policy and key namespace.
* **Server-owned keys**: follow-up operations cannot target keys outside
  the route `keyPrefix`.
* **Verified metadata**: `HeadObject` on confirm — do not trust the
  client's declared size or type.
* **Zero lock-in**: Amazon S3, Cloudflare R2, MinIO, and other
  S3-compatible stores. You pass your own AWS SDK `S3Client`.

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="Can I use Uppy or Dropzone with dimah-s3?">
    Yes. Connect a third-party uploader to the `@dimah-s3/server` presign
    endpoints, or implement `S3Api` with `defineApi`.
  </Accordion>

  <Accordion title="Is dimah-s3 a Better Upload replacement?">
    For "pick a file and PUT it to S3", Better Upload is enough and lighter.
    Use dimah-s3 when you also need confirm, download, delete, resume, or
    database-backed quotas.
  </Accordion>
</Accordions>


# Providers (https://s3.dimah.dev/docs/providers)



dimah-s3 talks to storage through the AWS SDK `S3Client`. Any service that
speaks enough of the S3 API can work — Amazon S3, Cloudflare R2, MinIO, and
similar backends.

S3-compatible is not the same as Amazon S3. Providers skip or ignore parts of
the API. With a presign-first stack, those gaps show up quickly: the browser
hits storage directly, so CORS, addressing, and the upload method have to
match what the provider actually supports.

| Topic                 | Why it matters                                                                                     |
| --------------------- | -------------------------------------------------------------------------------------------------- |
| Presigned POST vs PUT | Default upload is POST. Some providers only accept PUT.                                            |
| Object ACL            | `acl: "public-read"` needs real ACL support. Others use bucket policies or a public-access toggle. |
| Endpoint & addressing | Custom endpoints often need `endpoint`, and sometimes `forcePathStyle: true`.                      |
| CORS                  | Browser uploads hit storage directly. The bucket must allow your origin, methods, and headers.     |
| Public URLs           | Public reads may use a CDN, a custom domain, or `*.r2.dev` — not object ACL.                       |

Wire the shared client once in [Quickstart](https://s3.dimah.dev/docs/quickstart). Use the guides
below only for the extras that defaults do not cover.

## Comparison [#comparison]

|                   | [AWS S3](https://s3.dimah.dev/docs/providers/aws-s3) | [Cloudflare R2](https://s3.dimah.dev/docs/providers/cloudflare-r2) | [MinIO](https://s3.dimah.dev/docs/providers/minio) |
| ----------------- | :------------------------------: | :--------------------------------------------: | :----------------------------: |
| Presigned POST    |                Yes               |                       No                       |               Yes              |
| Presigned PUT     |                Yes               |                 Yes (required)                 |               Yes              |
| Object ACL        |                Yes               |                     Ignored                    |          Use policies          |
| Custom `endpoint` |             Optional             |                    Required                    |            Required            |
| `forcePathStyle`  |            Usually off           |                       Off                      |            Required            |

## Configuration [#configuration]

```ts
import { dimahS3, route } from "@dimah-s3/server";

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  routes: {
    avatar: route({
      upload: { method: "PUT" },
    }),
  },
});
```

The default upload method is POST. Switch to PUT when the provider has no
Presigned POST (R2 is the usual case). ACL on confirm comes from
`upload.acl` / `object` — there is no `GetObjectAcl` lookup.

Client hooks and `@dimah-s3/ui` read `method` from the presign response.
Change it on the server only.

## Guides [#guides]

<Cards>
  <Card title="Amazon S3" href="/docs/providers/aws-s3" description="Defaults work. Configure bucket CORS for browser uploads." />

  <Card title="Cloudflare R2" href="/docs/providers/cloudflare-r2" description="PUT uploads, the R2 endpoint, and bucket CORS." />

  <Card title="MinIO" href="/docs/providers/minio" description="Path-style addressing and server CORS for self-hosted S3." />
</Cards>


# Cloudflare R2 (https://s3.dimah.dev/docs/providers/cloudflare-r2)



Cloudflare R2 is compatible with S3 APIs with two key differences: it requires Presigned `PUT` (it does not support Presigned `POST`), and it ignores object ACLs.

<div className="fd-steps">
  <div className="fd-step">
    ## Configure upload method [#1-configure-upload-method]

    Set `upload: { method: "PUT" }`:

    ```ts title="lib/s3.ts"
    import { S3Client } from "@aws-sdk/client-s3";
    import { dimahS3, route } from "@dimah-s3/server";

    export const awsS3 = new S3Client({
      region: "auto",
      endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
      credentials: {
        accessKeyId: process.env.R2_ACCESS_KEY_ID!,
        secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
      },
    });

    export const s3 = dimahS3({
      client: awsS3,
      bucket: process.env.R2_BUCKET!,
      routes: {
        avatar: route({
          upload: {
            method: "PUT", // Required for Cloudflare R2
            fileTypes: ["image/*"],
            maxFileSize: 2 * 1024 * 1024,
          },
        }),
      },
    });
    ```
  </div>

  <div className="fd-step">
    ## Bucket CORS policy [#2-bucket-cors-policy]

    Add a CORS rule in the Cloudflare R2 dashboard for your frontend domain:

    ```json
    [
      {
        "AllowedOrigins": ["https://your-app.example"],
        "AllowedMethods": ["GET", "PUT", "HEAD"],
        "AllowedHeaders": ["*"],
        "ExposeHeaders": ["ETag", "Content-Type"],
        "MaxAgeSeconds": 3000
      }
    ]
    ```
  </div>
</div>

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I serve R2 files publicly?">
    Enable the public access toggle in the R2 bucket settings or connect a custom domain (e.g. `cdn.example.com`). Construct your image URLs directly: `https://cdn.example.com/{avatarKey}`.
  </Accordion>
</Accordions>


# Introduction (https://s3.dimah.dev/docs/server)



[`@dimah-s3/server`](https://www.npmjs.com/package/@dimah-s3/server) is the backend engine for dimah-s3. It generates presigned URLs, enforces route constraints, authorizes requests through guards, and coordinates lifecycle callbacks.

Upload and download operate directly between the client and the bucket via short-lived signed URLs. S3 credentials remain on the server, while deletion is handled securely server-side.

## Key features [#key-features]

* **Unified configuration** — `dimahS3()` exports both an HTTP route handler and a typed server-side `api`.
* **Server-owned keys** — Keys follow `{keyPrefix}/{uuid}/{name}` namespaces to prevent bucket tampering.
* **HeadObject verification** — Real file sizes and MIME types are verified directly from S3 on upload confirmation.
* **Lifecycle hooks** — Inject auth, database records, or quota checks at presign, confirmation, and deletion.
* **Universal adapters** — Native handlers for Next.js, Hono, Express, Fastify, SvelteKit, Elysia, and standard Fetch.

## Documentation [#documentation]

<Cards>
  <Card title="Setup & Adapters" href="/docs/server/setup" description="Install and mount route handlers across modern runtimes." />

  <Card title="Route Definitions" href="/docs/server/routes" description="Configure routes, file constraints, operations, and key prefixes." />

  <Card title="Security & Auth" href="/docs/server/security" description="Guards, key isolation, tenant scoping, and metadata validation." />

  <Card title="Error Handling" href="/docs/server/errors" description="APIError structure, error codes, and client mapping." />

  <Card title="Lifecycle Hooks" href="/docs/server/hooks/upload" description="Presign, confirmation, download, and delete lifecycle events." />

  <Card title="Plugins" href="/docs/server/plugins" description="Extend server capabilities with plugins like @dimah-s3/db." />
</Cards>


# Security (https://s3.dimah.dev/docs/server/security)



dimah-s3 follows a **presign-first** security model: AWS credentials remain on the server, while clients upload directly to storage via short-lived signed URLs.

<Callout type="warn">
  Never expose S3 credentials in the browser or client bundles. S3 clients must
  always run server-side.
</Callout>

## Security model [#security-model]

1. **Server owns keys**: The client requests an upload for a named `route`. The server decides the destination key path (`avatar/{uuid}/{fileName}` or customized via `object`).
2. **Namespace isolation**: Confirm, download, and delete follow-ups are restricted to the route's `keyPrefix`. Requests referencing keys outside the prefix fail with `INVALID_KEY`.
3. **Verified metadata via `HeadObject`**: Presign payloads (size/type) are untrusted. Verified file sizes and Content-Types are confirmed server-side via `HeadObject` in `upload.onConfirmed`.
4. **Guards & Authorization**: Route `guard` and feature guards (`upload.guard`, `download.guard`, `delete.guard`) control per-user authorization.

***

## Scoping objects & user tenancy [#scoping-objects--user-tenancy]

Use `upload.object` to isolate files by tenant or user ID:

```ts title="lib/s3.ts"
import { dimahS3, errors, route } from "@dimah-s3/server";

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  routes: {
    avatar: route({
      guard: async ({ request }) => {
        const session = await getSession(request);
        if (!session) throw errors.unauthorized();
      },
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
        object: async ({ request }) => {
          const session = await getSession(request);
          return {
            folder: `users/${session.userId}`,
            // Generates: avatar/users/{userId}/{uuid}/{fileName}
          };
        },
        onConfirmed: async ({ key, contentLength, request }) => {
          // Trusted size & key confirmed from S3 HeadObject
          const session = await getSession(request);
          await db.user.update({
            where: { id: session.userId },
            data: { avatarKey: key, avatarSize: contentLength },
          });
        },
      },
      download: true,
      delete: true,
    }),
  },
});
```

***

## Object ACL [#object-acl]

Uploads are `private` by default. Set `acl` on the route or return it dynamically from `upload.object`:

```ts
avatar: route({
  upload: {
    acl: "public-read",
    fileTypes: ["image/*"],
    maxFileSize: 2 * 1024 * 1024,
  },
}),
```

***

## Hook error mapping [#hook-error-mapping]

| Hook Location                                      | Plain `Error` Thrown   | `errors.*` / `APIError` |
| -------------------------------------------------- | ---------------------- | ----------------------- |
| `guard` / `*Guard` / `object`                      | **403 Forbidden**      | Custom status & code    |
| `on*` lifecycle hooks (`onConfirmed`, `onDeleted`) | **500 Internal Error** | Custom status & code    |

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I verify the true MIME type (magic bytes) of an uploaded file?">
    The server checks MIME headers at presign and HeadObject. If you need strict magic-byte validation, use `sniffContentType` / `matchesMagicBytes` in `onConfirmed`. Throwing an error from `onConfirmed` automatically deletes the uploaded object from S3:

    ```ts
    import { matchesMagicBytes, sniffContentType } from "@dimah-s3/core";

    avatar: route({
      upload: {
        onConfirmed: async ({ key, client }) => {
          // Fetch the first few bytes
          const res = await client.send(
            new GetObjectCommand({
              Bucket: bucket,
              Key: key,
              Range: "bytes=0-15",
            }),
          );
          const bytes = new Uint8Array(await res.Body.transformToByteArray());

          if (
            !matchesMagicBytes(bytes, ["image/png", "image/jpeg", "image/webp"])
          ) {
            throw errors.fileTypeNotAllowed("Invalid image magic bytes");
          }
        },
      },
    });
    ```
  </Accordion>

  <Accordion title="How do I prevent users from deleting or downloading other users' files?">
    Use `guard` on download/delete or use the [`@dimah-s3/db`](https://s3.dimah.dev/docs/db) plugin for automatic scope isolation:

    ```ts
    avatar: route({
      delete: {
        guard: async ({ key, request }) => {
          const session = await getSession(request);
          const isOwner = await checkAvatarOwnership(session.userId, key);
          if (!isOwner) throw errors.forbidden();
        },
      },
    }),
    ```
  </Accordion>
</Accordions>


# Setup (https://s3.dimah.dev/docs/server/setup)



## Install [#install]

<CodeBlockTabs defaultValue="npm">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="npm">
      npm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="pnpm">
      pnpm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="yarn">
      yarn
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="bun">
      bun
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="npm">
    ```bash
    npm i @dimah-s3/server @aws-sdk/client-s3
    ```
  </CodeBlockTab>

  <CodeBlockTab value="pnpm">
    ```bash
    pnpm add @dimah-s3/server @aws-sdk/client-s3
    ```
  </CodeBlockTab>

  <CodeBlockTab value="yarn">
    ```bash
    yarn add @dimah-s3/server @aws-sdk/client-s3
    ```
  </CodeBlockTab>

  <CodeBlockTab value="bun">
    ```bash
    bun add @dimah-s3/server @aws-sdk/client-s3
    ```
  </CodeBlockTab>
</CodeBlockTabs>

## Configure S3 & routes [#configure-s3--routes]

Initialize `dimahS3` by passing your `@aws-sdk/client-s3` instance and defining named routes.

Operations (`upload`, `download`, `delete`, `multipart`) are disabled by default. Enable only what each route needs.

```ts title="lib/s3.ts"
import { S3Client } from "@aws-sdk/client-s3";
import { dimahS3, route } from "@dimah-s3/server";

export const awsS3 = new S3Client({
  region: process.env.S3_REGION,
  endpoint: process.env.S3_ENDPOINT,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID!,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
  },
});

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  routes: {
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024, // 2MB
      },
      download: true,
      delete: true,
    }),
  },
});
```

***

## Mount route handler [#mount-route-handler]

Adapters are available for all major JavaScript runtimes:

<Tabs items="[&#x22;Next.js&#x22;, &#x22;Express&#x22;, &#x22;Hono&#x22;, &#x22;SvelteKit&#x22;, &#x22;Fastify&#x22;, &#x22;Elysia&#x22;, &#x22;Node&#x22;, &#x22;Web&#x22;]">
  <Tab value="Next.js">
    ```ts title="app/api/s3/[...s3]/route.ts"
    import { toNextJsHandler } from "@dimah-s3/server/next";
    import { s3 } from "@/lib/s3";

    export const { GET, POST, PUT, PATCH, DELETE } = toNextJsHandler(s3);
    ```
  </Tab>

  <Tab value="Express">
    ```ts title="server.ts"
    import express from "express";
    import { toExpressHandler } from "@dimah-s3/server/express";
    import { s3 } from "./s3";

    const app = express();
    app.all("/api/s3/*", toExpressHandler(s3));
    app.use(express.json());
    ```
  </Tab>

  <Tab value="Hono">
    ```ts title="src/index.ts"
    import { Hono } from "hono";
    import { toHonoHandler } from "@dimah-s3/server/hono";
    import { s3 } from "./s3";

    const app = new Hono();
    app.on(
      ["GET", "POST", "PUT", "PATCH", "DELETE"],
      "/api/s3/*",
      toHonoHandler(s3),
    );
    ```
  </Tab>

  <Tab value="SvelteKit">
    ```ts title="src/routes/api/s3/[...path]/+server.ts"
    import { toSvelteKitHandler } from "@dimah-s3/server/svelte-kit";
    import { s3 } from "$lib/s3";

    const handler = toSvelteKitHandler(s3);
    export const GET = handler;
    export const POST = handler;
    export const PUT = handler;
    export const PATCH = handler;
    export const DELETE = handler;
    ```
  </Tab>

  <Tab value="Fastify">
    ```ts title="server.ts"
    import Fastify from "fastify";
    import { toFastifyHandler } from "@dimah-s3/server/fastify";
    import { s3 } from "./s3";

    const app = Fastify();
    app.all("/api/s3/*", toFastifyHandler(s3));
    ```
  </Tab>

  <Tab value="Elysia">
    ```ts title="src/index.ts"
    import { Elysia } from "elysia";
    import { toElysiaHandler } from "@dimah-s3/server/elysia";
    import { s3 } from "./s3";

    new Elysia().all("/api/s3/*", toElysiaHandler(s3)).listen(3000);
    ```
  </Tab>

  <Tab value="Node">
    ```ts title="server.ts"
    import { createServer } from "node:http";
    import { toNodeHandler } from "@dimah-s3/server/node";
    import { s3 } from "./s3";

    const handler = toNodeHandler(s3);

    createServer((req, res) => {
      if (req.url?.startsWith("/api/s3")) return handler(req, res);
      res.statusCode = 404;
      res.end("Not found");
    }).listen(3000);
    ```
  </Tab>

  <Tab value="Web">
    ```ts
    export default {
      fetch(request: Request) {
        const url = new URL(request.url);
        if (url.pathname.startsWith("/api/s3")) {
          return s3.handler(request);
        }
        return new Response("Not found", { status: 404 });
      },
    };
    ```
  </Tab>
</Tabs>

***

## Server-side API (`s3.api`) [#server-side-api-s3api]

You can invoke S3 actions directly on the server without going through HTTP endpoints. Pass request `headers` so that route guards and auth hooks execute properly:

```ts title="app/actions.ts"
"use server";

import { headers } from "next/headers";
import { s3 } from "@/lib/s3";

export async function getAvatarDownloadUrl(key: string) {
  return s3.api.download({
    query: { route: "avatar", key },
    headers: await headers(),
  });
}
```

To upload files directly from the server (e.g. background jobs, image processing) while still executing route validation and `onConfirmed` hooks:

```ts
await s3.put({
  route: "avatar",
  fileName: "profile.png",
  contentType: "image/png",
  body: imageBuffer,
  headers: await headers(),
});
```

***

## Type reference [#type-reference]

```ts
import type { DimahS3Config, DimahS3Logger } from "@dimah-s3/server";
```

### DimahS3Config [#dimahs3config]

<AutoTypeTable path="packages/server/src/types/config.ts" name="DimahS3Config" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do error handling and status codes work?">
    All handlers throw typed `APIError` objects with stable string codes and numeric HTTP status codes:

    * A plain `Error` thrown in `guard`, `*Guard`, or `object` maps to **403 Forbidden**.
    * A plain `Error` thrown in `onPresigned`, `onConfirmed`, or other `on*` hooks maps to **500 Internal Error**.
    * Use the `errors` helper (e.g. `throw errors.unauthorized()`) to specify exact codes.

    See [Errors](https://s3.dimah.dev/docs/server/errors) for the full list of error codes.
  </Accordion>

  <Accordion title="How does key generation and prefix isolation work?">
    By default, dimah-s3 isolates object keys by route prefix:

    ```
    avatar/{uuid}/{fileName}
    ```

    All follow-up operations (confirm, download, delete) must target keys within the route's `keyPrefix`. Any request targeting a key outside its prefix is rejected with `INVALID_KEY`.

    To customize the folder or key structure, use `upload.object` in your route definition.
  </Accordion>

  <Accordion title="How do I change the base path or support cross-origin APIs?">
    Set `basePath` when initializing `dimahS3`:

    ```ts
    export const s3 = dimahS3({
      basePath: "/api/storage",
      // ...
    });
    ```

    Make sure the client specifies the same `basePath` (or full `baseURL` if hosted on a separate domain):

    ```ts
    export const s3Client = createS3Client({
      baseURL: "https://api.example.com/api/storage",
    });
    ```
  </Accordion>
</Accordions>


# Routes (https://s3.dimah.dev/docs/server/routes)



A **route** is a named storage policy on your server. Each route manages its own constraints, object keys, and enabled operations (`upload`, `download`, `delete`, and `multipart`).

The client specifies the route name on every request. The server enforces file constraints, validates permissions, and generates object keys.

```ts title="lib/s3.ts"
import { S3Client } from "@aws-sdk/client-s3";
import { dimahS3, route } from "@dimah-s3/server";

export const awsS3 = new S3Client({/* env */});

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  routes: {
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
      },
      download: true,
      delete: true,
    }),
    document: route({
      upload: {
        fileTypes: ["application/pdf", "image/*"],
        maxFileSize: 10 * 1024 * 1024,
      },
      download: true,
      delete: true,
    }),
    video: route({
      upload: {
        fileTypes: ["video/*"],
        maxFileSize: 500 * 1024 * 1024,
        multipart: true,
      },
    }),
  },
});
```

***

## Combining operations [#combining-operations]

Enable operations on the **same route** when they manage the same objects. For example, if users upload, view, and remove their avatars:

```ts
avatar: route({
  upload: {
    fileTypes: ["image/*"],
    maxFileSize: 2 * 1024 * 1024,
  },
  download: true,
  delete: true,
}),
```

Follow-up operations (confirm, download, delete) must target keys within that route's namespace.

***

## Key generation & object identity [#key-generation--object-identity]

By default, the server generates object keys formatted as:

```
{keyPrefix}/{uuid}/{sanitizedFileName}
```

`keyPrefix` defaults to the route name (e.g. `avatar/…`).

To customize key generation or organize files by user/tenant, return `folder` or `key` from `upload.object`:

```ts
avatar: route({
  upload: {
    fileTypes: ["image/*"],
    maxFileSize: 2 * 1024 * 1024,
    object: async ({ request }) => {
      const session = await getSession(request);
      return {
        folder: `users/${session.userId}`,
        // Results in: avatar/users/{userId}/{uuid}/{fileName}
      };
    },
  },
}),
```

***

## Type reference [#type-reference]

```ts
import type {
  DimahS3RouteConfig,
  UploadConfig,
  DownloadConfig,
  DeleteConfig,
  MultipartConfig,
} from "@dimah-s3/server";
```

### DimahS3RouteConfig [#dimahs3routeconfig]

<AutoTypeTable path="packages/server/src/types/config.ts" name="DimahS3RouteConfig" />

***

### UploadConfig [#uploadconfig]

<AutoTypeTable path="packages/server/src/types/config.ts" name="UploadConfig" />

***

### DownloadConfig [#downloadconfig]

<AutoTypeTable path="packages/server/src/types/config.ts" name="DownloadConfig" />

***

### DeleteConfig [#deleteconfig]

<AutoTypeTable path="packages/server/src/types/config.ts" name="DeleteConfig" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I overwrite an existing avatar key instead of generating new UUIDs?">
    Set `replace: "overwrite"` and return a deterministic `key` from `object`:

    ```ts
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
        replace: "overwrite",
        object: async ({ request }) => {
          const session = await getSession(request);
          return {
            key: `avatars/${session.userId}/avatar.png`,
          };
        },
      },
    }),
    ```
  </Accordion>

  <Accordion title="How do I clean up the user's previous avatar after a new upload?">
    Return `previousKey` from `upload.object`. Once the new upload confirms successfully, dimah-s3 will automatically delete the old object from S3:

    ```ts
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
        object: async ({ request }) => {
          const user = await getCurrentUser(request);
          return {
            folder: `users/${user.id}`,
            previousKey: user.currentAvatarKey,
          };
        },
      },
    }),
    ```
  </Accordion>

  <Accordion title="How do I set storage class, Cache-Control, or object tags?">
    Return them from `upload.object`. They are signed into PUT, POST, and
    multipart init (and applied by `s3.put`) so the browser cannot change
    them. Tag values are visible to the client — do not put secrets there.

    ```ts
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        object: () => ({
          storageClass: "STANDARD_IA",
          cacheControl: "max-age=31536000, immutable",
          tagging: { purpose: "avatar" },
        }),
      },
    }),
    ```
  </Accordion>

  <Accordion title="How do I enforce SHA-256 integrity checksums?">
    Set `checksum: true` in your upload config. The React client will automatically compute the SHA-256 hash in the browser and attach it to the presigned request:

    ```ts
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
        checksum: true,
      },
    }),
    ```
  </Accordion>

  <Accordion title="How do I stream downloads through my server without direct bucket URLs?">
    Set `mode: "proxy"` on `download`. The download URL will point to a same-origin `/api/s3/file` endpoint that streams the object through your backend:

    ```ts
    avatar: route({
      download: {
        mode: "proxy",
      },
    }),
    ```
  </Accordion>
</Accordions>


# Global Guard (https://s3.dimah.dev/docs/server/hooks/global-guard)



`guard` is defined on `dimahS3()` and executes on every incoming request prior to route resolution or plugin execution.

Use it for system-wide checks like user authentication, IP filtering, or tenant context.

```ts title="lib/s3.ts"
import { S3Client } from "@aws-sdk/client-s3";
import { dimahS3, errors, route } from "@dimah-s3/server";

export const awsS3 = new S3Client({/* env */});

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  guard: async ({ request }) => {
    const session = await getSession(request);
    if (!session) throw errors.unauthorized();
  },
  routes: {
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
      },
    }),
  },
});
```

***

## Guard context [#guard-context]

The global guard receives the raw `request` object. Route name and target key are resolved later in the lifecycle.

```ts
import type { GuardContext, RouteGuardContext } from "@dimah-s3/server";
```

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="GuardContext" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="When should I use route.guard instead of global guard?">
    If your application has public routes (e.g. public media upload) and protected routes (e.g. private invoices), leave the global `guard` empty and apply `guard` on specific routes:

    ```ts
    export const s3 = dimahS3({
      routes: {
        publicMedia: route({
          upload: true,
        }),
        avatar: route({
          guard: requireUserSession,
          upload: true,
        }),
      },
    });
    ```
  </Accordion>

  <Accordion title="How do I attach custom headers from client requests?">
    On the client, pass headers in `createS3Client`:

    ```ts
    export const s3Client = createS3Client({
      headers: async () => ({
        Authorization: `Bearer ${await getToken()}`,
      }),
    });
    ```
  </Accordion>
</Accordions>


# Upload Hooks (https://s3.dimah.dev/docs/server/hooks/upload)



Enable upload operations on a [named route](https://s3.dimah.dev/docs/server/routes) with `upload: true` or an `UploadConfig` object.

```ts title="lib/s3.ts"
import { S3Client } from "@aws-sdk/client-s3";
import { dimahS3, errors, route } from "@dimah-s3/server";

export const awsS3 = new S3Client({/* env */});

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  routes: {
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
        guard: async ({ request }) => {
          const session = await getSession(request);
          if (!session) throw errors.unauthorized();
        },
        onConfirmed: async ({ key, contentLength, request }) => {
          const session = await getSession(request);
          await db.user.update({
            where: { id: session.userId },
            data: { avatarKey: key, avatarSize: contentLength },
          });
        },
      },
    }),
  },
});
```

***

## Upload lifecycle flow [#upload-lifecycle-flow]

<Flow
  label="Upload lifecycle"
  steps="[
  { name: &#x22;route.guard&#x22;, kind: &#x22;hook&#x22; },
  { name: &#x22;object&#x22;, kind: &#x22;hook&#x22; },
  { name: &#x22;upload.guard&#x22;, kind: &#x22;hook&#x22; },
  { name: &#x22;onPresigned&#x22;, kind: &#x22;hook&#x22; },
  { name: &#x22;Upload to S3&#x22;, kind: &#x22;s3&#x22; },
  { name: &#x22;confirmGuard&#x22;, kind: &#x22;hook&#x22; },
  { name: &#x22;HeadObject&#x22;, kind: &#x22;server&#x22; },
  { name: &#x22;onConfirmed&#x22;, kind: &#x22;hook&#x22; },
]"
/>

1. **`guard`**: Evaluates session and permissions before key generation.
2. **`object`**: Computes destination folder, key, metadata, ACL, storage class, cache control, or tags.
3. **`onPresigned`**: Fires after the presigned URL is created.
4. **`confirmGuard`**: Authorizes the confirmation request after bytes land in S3.
5. **`onConfirmed`**: Reads verified metadata from `HeadObject` and persists the record.

***

## Type reference [#type-reference]

```ts
import type {
  UploadGuardContext,
  UploadOnConfirmedContext,
  UploadConfirmGuardContext,
  UploadObjectContext,
  UploadObjectInfo,
  UploadOnPresignedContext,
} from "@dimah-s3/server";
```

### UploadGuardContext [#uploadguardcontext]

Runs after `upload.object` assigns the key (and after global / route guards).

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="UploadGuardContext" />

***

### UploadOnConfirmedContext [#uploadonconfirmedcontext]

Runs after `HeadObject` validates the uploaded file size and content type.

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="UploadOnConfirmedContext" />

***

### UploadConfirmGuardContext [#uploadconfirmguardcontext]

Runs before `HeadObject` when confirming single-shot uploads or completing multipart uploads.

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="UploadConfirmGuardContext" />

***

### UploadObjectContext [#uploadobjectcontext]

Input passed to `upload.object` to generate custom destination keys and metadata.

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="UploadObjectContext" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="Why should I record file size in onConfirmed rather than presign?">
    Presign payloads represent client-declared values, which can be spoofed. In `onConfirmed`, the server queries S3 with `HeadObject` to obtain the actual byte length and content type.
  </Accordion>

  <Accordion title="What happens if onConfirmed throws an error?">
    If `onConfirmed` throws an error, dimah-s3 automatically performs a best-effort `DeleteObject` to prevent orphaned files in your S3 bucket.
  </Accordion>

  <Accordion title="How do I attach custom S3 user metadata?">
    Return `metadata` from `upload.object`:

    ```ts
    avatar: route({
      upload: {
        object: async ({ request, file }) => ({
          folder: "avatars",
          metadata: {
            originalName: file.name,
          },
        }),
      },
    });
    ```
  </Accordion>
</Accordions>


# Introduction (https://s3.dimah.dev/docs/react)



[`@dimah-s3/react`](https://www.npmjs.com/package/@dimah-s3/react) is a headless S3 client for React. It manages file intake, validation, progress tracking, retry, cancellation, and multipart transfers — leaving full UI control to you.

Upload and download streams run directly between the browser and the bucket, keeping credentials secure on the server.

## Key features [#key-features]

* **Headless architecture** — Full state management and accessible DOM props without imposing markup or styles.
* **Direct-to-bucket transfers** — Fast, signed uploads and downloads that bypass your application server.
* **Multipart support** — Automatic chunking for large files with resumable state via `UploadStore`.
* **Automatic constraint sync** — Infers file size and MIME type constraints directly from server route definitions.
* **Ready-made UI** — Optional accessible [shadcn components](https://s3.dimah.dev/docs/react/ui) for instant drop-in controls.
* **Backend agnostic** — Works seamlessly with `@dimah-s3/server` or any custom API implementing `S3Api`.

## Documentation [#documentation]

<Cards>
  <Card title="Setup" href="/docs/react/setup" description="Initialize createS3Client, configure S3Provider, and call your first hook." />

  <Card title="Upload" href="/docs/react/hooks/upload" description="useUpload — single and multi-file uploads with progress tracking." />

  <Card title="Download" href="/docs/react/hooks/download" description="useDownload — direct downloads and progress streams." />

  <Card title="Delete" href="/docs/react/hooks/delete" description="useDelete — confirm and remove objects through your API." />

  <Card title="UI Components" href="/docs/react/ui" description="Ready-made shadcn buttons and dropzones built on these hooks." />

  <Card title="Upload Store" href="/docs/react/upload-store" description="Persist and resume in-progress multipart uploads across page reloads." />

  <Card title="Helpers" href="/docs/react/helpers" description="Pure utilities for progress formatting, file sizing, and validation." />

  <Card title="Forms" href="/docs/react/forms" description="Add file uploads to React Hook Form or TanStack Form." />

  <Card title="TanStack Query" href="/docs/react/tanstack-query" description="Invalidate cached records after a confirmed upload." />

  <Card title="Custom Backend" href="/docs/react/custom-backend" description="Connect the headless hooks to any existing REST or RPC backend." />
</Cards>


# Introduction (https://s3.dimah.dev/docs/db)



[`@dimah-s3/db`](https://www.npmjs.com/package/@dimah-s3/db) is an optional
plugin for [`dimahS3`](https://s3.dimah.dev/docs/server/setup). It keeps your database aligned
with S3 — who owns each file, whether it is still uploading, and when it was
removed — without custom SQL on every endpoint.

dimah-s3 works without a database. Add this plugin when you need ownership
checks, usage totals, file listings, or resumable multipart uploads backed
by metadata.

## What the plugin does [#what-the-plugin-does]

* Tracks objects — writes and updates a row on presign, confirm, and delete
* Enforces ownership — each file belongs to a scope you define (for example
  `user:123`); download requires a confirmed (`active`) object owned by
  that scope; delete rejects access from another scope
* Lists files — query by scope on the server and in the browser
* Handles multipart — records in-progress uploads so you can resume or purge
  stale ones

Auth, quotas, and business rules stay in your hooks. The plugin only manages
object metadata and scope isolation.

## Lifecycle [#lifecycle]

Each object moves through three statuses:

| Status    | Meaning                                |
| --------- | -------------------------------------- |
| `pending` | Upload started, not confirmed          |
| `active`  | Confirmed in S3 — verified size stored |
| `deleted` | Removed from S3                        |

Typical flow: pending → active → deleted. Aborted multipart uploads and
[purge](https://s3.dimah.dev/docs/db/purge) clean up rows that never reached `active`.

## Next steps [#next-steps]

<Cards>
  <Card title="Setup" href="/docs/db/setup" description="Install, add the schema, and register the plugin." />

  <Card title="Hooks" href="/docs/db/hooks" description="Lifecycle hooks the plugin attaches automatically." />

  <Card title="API" href="/docs/db/api" description="List and query objects from server or browser." />

  <Card title="Purge" href="/docs/db/purge" description="Remove stale pending uploads." />

  <Card title="Full example" href="https://github.com/dimah-kz/dimah-s3/tree/main/examples/with-db" description="Minimal Next.js + Drizzle setup with upload and listing." />
</Cards>


# Core (https://s3.dimah.dev/docs/core)



# @dimah-s3/core [#dimah-s3core]

Shared protocol types, the isomorphic HTTP client, and pure helpers used by `@dimah-s3/server` and `@dimah-s3/react`.

## What lives here [#what-lives-here]

* `S3_API_ROUTES` / `S3_API_BASE_PATH` — route path SSOT
* `createS3Client` — browser/isomorphic client implementing `S3Api` (+ client plugins). Exposes `$ERROR_CODES`, `$fetch`, `$Infer`. `baseURL` wins over `basePath`. Upload and download `expiresIn` default to **600** seconds (`S3_DEFAULT_EXPIRES_IN`) on the server.
* `APIError` — better-call class (`status`, `body`); JSON `{ message, code?, params? }`. Use `APIError.from(status, S3_ERROR_CODES.FORBIDDEN)` or `isAPIError`. Catalog: [Errors](https://s3.dimah.dev/docs/server/errors).
* `s3FetchErrorSchema` — Zod schema for that JSON (better-fetch `errorSchema`)
* Client plugin helpers — `defineClientPlugin`, `createS3Fetch`, `pluginPath`
* Pure file helpers — `validateFile`, `formatFileSize`, `buildContentDisposition`, …

The browser client (`createS3Client`) uses nested calls
(`api.download({ route, key })`, `api.multipart.init`). The server `s3.api`
is the better-call map (`s3.api.download({ query })`, plus nested aliases
`s3.api.multipart.init`).
Both share these paths (under `basePath`, default `/api/s3`):

| Constant             | Method | Path                          |
| -------------------- | ------ | ----------------------------- |
| `upload`             | POST   | `/presign/upload`             |
| `uploadConfirm`      | POST   | `/presign/upload/confirm`     |
| `download`           | GET    | `/presign/download`           |
| `delete`             | DELETE | `/delete`                     |
| `multipartInit`      | POST   | `/presign/multipart/init`     |
| `multipartPart`      | POST   | `/presign/multipart/part`     |
| `multipartListParts` | GET    | `/presign/multipart/parts`    |
| `multipartComplete`  | POST   | `/presign/multipart/complete` |
| `multipartAbort`     | POST   | `/presign/multipart/abort`    |

## Quick start [#quick-start]

```ts
import { createS3Client } from "@dimah-s3/core";
import { dbClient } from "@dimah-s3/db/client";

export const api = createS3Client({
  basePath: "/api/s3",
  credentials: "include",
  plugins: [dbClient()],
});

await api.upload({
  route: "avatar",
  fileName: "avatar.png",
  contentType: "image/png",
  fileSize: 1024,
});
await api.db.listObjects({ limit: 20, offset: 0 });
```

For React apps prefer `createS3Client` from `@dimah-s3/react` — same options, plus a bound `Provider` / typed `useApi` on the client object.

## Client plugins [#client-plugins]

```ts
import { defineClientPlugin, pluginPath } from "@dimah-s3/core";

export function myClient() {
  return defineClientPlugin({
    id: "my",
    getActions: ($fetch) => ({
      ping: () => $fetch(pluginPath("my", "ping"), { method: "GET" }),
    }),
  });
}
```

Paths must match the server plugin endpoint (e.g. `createS3Endpoint("/my/ping", …)`).

## Validation [#validation]

`validateFile` returns `{ code, message, params? }` (or `null`). On the client, `useFormatValidateFileError` maps `code` to a UI string.


# API (https://s3.dimah.dev/docs/db/api)



## Server [#server]

With the plugin registered, `s3.db.objects` is available in routes, quota
checks, and jobs:

```ts
const scope = "user:123";
const ref = { bucket: "my-bucket", key: "avatar/photo.jpg" };

const objects = await s3.db.objects.listByScope({
  scope,
  status: "active",
  route: "avatar",
  limit: 50,
  cursor: previous.nextCursor ?? undefined,
});

const count = await s3.db.objects.countByScope(scope, "active");
const usage = await s3.db.objects.getScopeUsage(scope);

const object = await s3.db.objects.find({ bucket: ref.bucket, key: ref.key });
const active = await s3.db.objects.findActive({
  bucket: ref.bucket,
  key: ref.key,
});

const pending = await s3.db.objects.findPendingMultipart({
  bucket: ref.bucket,
  key: ref.key,
  fileSize: 1024000,
});
```

### StorageObjectStore [#storageobjectstore]

```ts
import type { StorageObjectStore, StorageObject } from "@dimah-s3/db";
```

<AutoTypeTable path="packages/db/src/store/storage-object-store.ts" name="StorageObjectStore" />

To remove a file, use [`api.delete`](https://s3.dimah.dev/docs/react) — not store helpers. See
[delete behavior](https://s3.dimah.dev/docs/db/setup#delete-behavior).

`GET /db/objects` (browser `listObjects`) defaults to `status: active` and
`limit: 50`, and rejects a `limit` above `100`. It returns `nextCursor`
when another page exists. `GET /db/object` (`getObject`) loads one row by
key. Pass `limit` on `listByScope` yourself for server listings.

## Browser [#browser]

Register `dbClient()` once. In a client component, `s3Client.useApi()` gives
you `api.db.listObjects`. Call it and render the list yourself — there is no
list hook or list component.

```ts title="lib/s3-client.ts"
"use client";

import { createS3Client } from "@dimah-s3/react";
import { dbClient } from "@dimah-s3/db/client";

export const s3Client = createS3Client({
  basePath: "/api/s3",
  plugins: [dbClient()],
});
```

```tsx title="components/file-list.tsx"
"use client";

import { useCallback, useEffect, useState } from "react";
import type { DbClientObject } from "@dimah-s3/db/client";
import { s3Client } from "@/lib/s3-client";

export function Files() {
  const api = s3Client.useApi();
  const [objects, setObjects] = useState<DbClientObject[]>([]);
  const [nextCursor, setNextCursor] = useState<string | null>(null);

  const load = useCallback(
    async (cursor?: string) => {
      const page = await api.db.listObjects({
        route: "avatar",
        ...(cursor ? { cursor } : {}),
      });
      setObjects((prev) =>
        cursor ? [...prev, ...page.objects] : page.objects,
      );
      setNextCursor(page.nextCursor ?? null);
    },
    [api],
  );

  useEffect(() => {
    void load();
  }, [load]);

  return (
    <>
      <ul>
        {objects.map((file) => (
          <li key={file.id}>{file.filename ?? file.key}</li>
        ))}
      </ul>
      {nextCursor ? (
        <button type="button" onClick={() => void load(nextCursor)}>
          More
        </button>
      ) : null}
    </>
  );
}
```

<Callout title="Scope is resolved on the server">
  `listObjects` is the browser wrapper for `GET /db/objects`. It does not take a
  scope. The plugin uses `resolveScope` on the request. To list another scope,
  call `listByScope` on the server.
</Callout>

## Quota and extra guards [#quota-and-extra-guards]

Quotas are app-owned numbers. Pass them to `db({ quota })` or
`createQuotaGuard` — the plugin only compares usage.

```ts
plugins: [
  db({
    client: dimahS3Db,
    resolveScope,
    quota: { maxBytes: 100 * 1024 * 1024, maxFiles: 50 },
  }),
],
```

Ownership still runs first. Stack more user guards with `chainHooks`:

<Accordions>
  <Accordion title="getScopeUsage on large scopes">
    `getScopeUsage` scans every row for the scope on each call. That is fine
    for demos and light use. For frequent presign checks or large scopes, keep
    a separate quota table (or counter) updated in your hooks instead of
    recounting each time.
  </Accordion>
</Accordions>

## Custom access [#custom-access]

For rules beyond same-scope ownership, add a guard on the feature config
(it runs after the plugin) or use `createObjectAccessGuard` on your own
routes:

```ts
download: {
  guard: async ({ request, bucket, key }) => {
    // ownership already checked by the db plugin
  },
},
```

```ts
createObjectAccessGuard({ client: dimahS3Db, resolveScope, authorize: myRule });
```

`authorize` replaces the default `object.scope === scope` check. Use it
where you own the full access policy — custom routes, or a guard you wire
yourself.


# Database Hooks (https://s3.dimah.dev/docs/db/hooks)



The [`db()`](https://s3.dimah.dev/docs/db/setup) plugin automatically attaches guards and synchronization actions across upload, download, and delete flows.

## Access & scope validation [#access--scope-validation]

Before executing operations, `@dimah-s3/db` resolves the active session scope via `resolveScope(request)`:

| Situation                                   | Response Status      | Error Code         |
| ------------------------------------------- | -------------------- | ------------------ |
| `resolveScope` returns `null`               | **401 Unauthorized** | `UNAUTHORIZED`     |
| Target key belongs to another scope         | **403 Forbidden**    | `FORBIDDEN`        |
| Target object row not found or soft-deleted | **404 Not Found**    | `OBJECT_NOT_FOUND` |

***

## Lifecycle behavior by operation [#lifecycle-behavior-by-operation]

* **Upload**: Inserts a new row with `status: "pending"`.
* **Upload Confirmation**: Verifies `pending` state, records verified `contentLength` from `HeadObject`, and transitions status to `"active"`.
* **Download**: Enforces that the object has `status: "active"` and matches the caller's scope.
* **Delete**: Runs S3 `DeleteObjectCommand`, then updates row status to `"deleted"` (soft delete) or removes it (hard delete).

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do my custom route guards interact with db plugin hooks?">
    Plugin guards run **first** to verify scope ownership. Your custom route and feature guards run **second** for application-specific authorization.
  </Accordion>
</Accordions>


# Purge Stale Uploads (https://s3.dimah.dev/docs/db/purge)



When clients request a presigned upload but fail to upload or confirm, database rows remain in `status: "pending"`.

`purgeStalePendingObjects` cleans up expired rows and aborts any incomplete multipart uploads.

```ts title="scripts/purge-stale.ts"
import { purgeStalePendingObjects } from "@dimah-s3/db";
import { dimahS3Db } from "@/lib/dimah-s3-db";
import { awsS3 } from "@/lib/s3";

const { purged } = await purgeStalePendingObjects({
  client: dimahS3Db,
  olderThanMs: 24 * 60 * 60 * 1000, // 24 hours
  s3: awsS3,
});

console.log(`Purged ${purged.length} stale uploads.`);
```

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I schedule the purge script?">
    Run it on a daily cron job (e.g. Vercel Cron, GitHub Actions, or Node cron workers).
  </Accordion>
</Accordions>


# Setup (https://s3.dimah.dev/docs/db/setup)



This guide assumes you already have Drizzle, Prisma, or Kysely connected to
a database. A working reference is
[`examples/with-db`](https://github.com/dimah-kz/dimah-s3/tree/main/examples/with-db)
(Next.js + Drizzle + SQLite).

<div className="fd-steps">
  <div className="fd-step">
    ### Install [#install-step]

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i @dimah-s3/db fumadb
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add @dimah-s3/db fumadb
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add @dimah-s3/db fumadb
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add @dimah-s3/db fumadb
        ```
      </CodeBlockTab>
    </CodeBlockTabs>
  </div>

  <div className="fd-step">
    ### Schema [#schema-step]

    Add the `storage_object` table (including the recommended indexes), or
    [generate it with the CLI](#cli).

    <Tabs items="[&#x22;Drizzle&#x22;, &#x22;Prisma&#x22;, &#x22;Kysely&#x22;]">
      <Tab value="Drizzle">
        CLI / Drizzle output imports `fumadb/cuid` — alias it to
        `@paralleldrive/cuid2` in your `tsconfig` paths.

        ```ts title="db/dimah-s3.ts"
        import {
          sqliteTable,
          text,
          blob,
          integer,
          uniqueIndex,
          index,
        } from "drizzle-orm/sqlite-core";
        import { createId } from "fumadb/cuid";

        export const storageObject = sqliteTable(
          "storage_object",
          {
            id: text("id", { length: 255 })
              .primaryKey()
              .notNull()
              .$defaultFn(() => createId()),
            scope: text("scope").notNull(),
            bucket: text("bucket").notNull(),
            key: text("key").notNull(),
            contentType: text("content_type"),
            size: blob("size", { mode: "bigint" }),
            eTag: text("e_tag"),
            filename: text("filename"),
            status: text("status").notNull(),
            metadata: blob("metadata", { mode: "json" }),
            acl: text("acl"),
            uploadId: text("upload_id"),
            declaredSize: blob("declared_size", { mode: "bigint" }),
            confirmedAt: integer("confirmed_at", { mode: "timestamp" }),
            expiresAt: integer("expires_at", { mode: "timestamp" }),
            createdAt: integer("created_at", { mode: "timestamp" })
              .notNull()
              .defaultNow(),
            updatedAt: integer("updated_at", { mode: "timestamp" })
              .notNull()
              .defaultNow(),
            deletedAt: integer("deleted_at", { mode: "timestamp" }),
          },
          (table) => [
            uniqueIndex("storage_object_bucket_key_uk").on(table.bucket, table.key),
            // Recommended — FumaDB `generate` does not emit these yet.
            index("storage_object_scope_status_created_idx").on(
              table.scope,
              table.status,
              table.createdAt,
            ),
            index("storage_object_status_expires_idx").on(
              table.status,
              table.expiresAt,
            ),
            index("storage_object_status_created_idx").on(
              table.status,
              table.createdAt,
            ),
          ],
        );

        export const private_dimah_s3_settings = sqliteTable(
          "private_dimah_s3_settings",
          {
            id: text("id", { length: 255 }).primaryKey().notNull(),
            version: text("version", { length: 255 }).notNull().default("1.0.0"),
          },
        );
        ```
      </Tab>

      <Tab value="Prisma">
        ```prisma title="prisma/schema.prisma"
        generator client {
          provider = "prisma-client-js"
        }

        datasource db {
          provider = "postgresql"
        }

        model StorageObject {
          id           String    @id @map("id") @db.VarChar(255)
          scope        String    @map("scope")
          bucket       String    @map("bucket")
          key          String    @map("key")
          contentType  String?   @map("content_type")
          size         BigInt?   @map("size")
          eTag         String?   @map("e_tag")
          filename     String?   @map("filename")
          status       String    @map("status")
          metadata     Json?     @map("metadata")
          acl          String?   @map("acl")
          uploadId     String?   @map("upload_id")
          declaredSize BigInt?   @map("declared_size")
          confirmedAt  DateTime? @map("confirmed_at")
          expiresAt    DateTime? @map("expires_at")
          createdAt    DateTime  @default(now()) @map("created_at")
          updatedAt    DateTime  @updatedAt @map("updated_at")
          deletedAt    DateTime? @map("deleted_at")

          @@unique([bucket, key], map: "storage_object_bucket_key_uk")
          @@index([scope, status, createdAt], map: "storage_object_scope_status_created_idx")
          @@index([status, expiresAt], map: "storage_object_status_expires_idx")
          @@index([status, createdAt], map: "storage_object_status_created_idx")
          @@map("storage_object")
        }

        model PrivateDimahS3Settings {
          id      String @id @map("id") @db.VarChar(255)
          version String @default("1.0.0") @map("version") @db.VarChar(255)

          @@map("private_dimah_s3_settings")
        }
        ```
      </Tab>

      <Tab value="Kysely">
        There is no committed schema file for Kysely. Migrate with the CLI, then add
        the [indexes](#cli) with SQL:

        ```bash
        node --import tsx scripts/db-cli.mts migrate
        ```
      </Tab>
    </Tabs>
  </div>

  <div className="fd-step">
    ### Client [#client-step]

    Wrap your ORM connection with a FumaDB adapter, then create the dimah-s3 DB
    client:

    <Tabs items="[&#x22;Drizzle&#x22;, &#x22;Prisma&#x22;, &#x22;Kysely&#x22;]">
      <Tab value="Drizzle">
        drizzle-orm 0.44 / 0.45 and 1.x (RC) are both supported. 1.x needs FumaDB
        0.5 or later.

        <Tabs items="[&#x22;0.x&#x22;, &#x22;1.x&#x22;]">
          <Tab value="0.x">
            ```ts title="lib/db.ts"
            import { drizzle } from "drizzle-orm/better-sqlite3"; // or your driver
            import * as schema from "./db/dimah-s3";

            export const db = drizzle(client, { schema });
            ```
          </Tab>

          <Tab value="1.x">
            ```ts title="lib/db.ts"
            import { defineRelations } from "drizzle-orm";
            import { drizzle } from "drizzle-orm/better-sqlite3"; // or your driver
            import { storageObject, private_dimah_s3_settings } from "./db/dimah-s3";

            export const db = drizzle({
              client,
              relations: defineRelations({ storageObject, private_dimah_s3_settings }),
            });
            ```
          </Tab>
        </Tabs>

        ```ts title="lib/dimah-s3-db.ts"
        import { drizzleAdapter } from "fumadb/adapters/drizzle";
        import { DimahS3DB } from "@dimah-s3/db";
        import { db } from "@/lib/db";

        export const dimahS3Db = DimahS3DB.client(
          drizzleAdapter({ db, provider: "sqlite" }), // or "postgresql" | "mysql"
        );
        ```
      </Tab>

      <Tab value="Prisma">
        ```ts title="lib/dimah-s3-db.ts"
        import { prismaAdapter } from "fumadb/adapters/prisma";
        import { DimahS3DB } from "@dimah-s3/db";
        import { prisma } from "@/lib/db";

        export const dimahS3Db = DimahS3DB.client(
          prismaAdapter({ prisma, provider: "postgresql" }),
        );
        ```
      </Tab>

      <Tab value="Kysely">
        ```ts title="lib/dimah-s3-db.ts"
        import { kyselyAdapter } from "fumadb/adapters/kysely";
        import { DimahS3DB } from "@dimah-s3/db";
        import { db } from "@/lib/db";

        export const dimahS3Db = DimahS3DB.client(
          kyselyAdapter({ db, provider: "postgresql" }),
        );
        ```
      </Tab>
    </Tabs>
  </div>

  <div className="fd-step">
    ### Register the plugin [#register-the-plugin-step]

    Pass `db()` in `plugins`. `resolveScope` must return a stable ownership
    string, or `null` to reject unauthenticated callers (`401`):

    ```ts title="lib/s3.ts"
    import { dimahS3, route } from "@dimah-s3/server";
    import { db } from "@dimah-s3/db";
    import { dimahS3Db } from "@/lib/dimah-s3-db";

    export const s3 = dimahS3({
      client: awsS3,
      bucket: process.env.S3_BUCKET!,
      plugins: [
        db({
          client: dimahS3Db,
          resolveScope: async (request) => {
            const session = await getSession(request);
            return session ? `user:${session.userId}` : null;
          },
        }),
      ],
      routes: {
        avatar: route({
          upload: {
            fileTypes: ["image/*"],
            maxFileSize: 2 * 1024 * 1024,
          },
          download: true,
          delete: true,
        }),
      },
    });
    ```

    The same store is available as `s3.db` (and `s3.context.db`):

    ```ts
    s3.db.objects.listByScope({ scope: "user:123" });
    ```

    <Callout type="warn" title="Features stay opt-in">
      Plugin hooks merge onto every route unless the route sets
      `plugins: { db: false }`. You still enable each operation on the route
      yourself — the plugin does not turn them on. Enable download or delete
      on this **same** route when those callers share these objects
      ([combining features](https://s3.dimah.dev/docs/server/routes#combining-features)).
    </Callout>
  </div>
</div>

### Plugin options [#plugin-options]

```ts
import type { DbPluginOptions, DbPluginContext } from "@dimah-s3/db";
```

<AutoTypeTable path="packages/db/src/plugin/db.ts" name="DbPluginOptions" />

### Delete behavior [#delete-behavior]

App deletes always go through the normal server delete path (`api.delete`,
the Delete button, `useDelete`, or your own delete hooks). Do not call
`s3.db.objects.softDelete` or `hardDelete` for that. Those store helpers
only update the database. They are not a replacement for deleting the S3
object.

When that path runs:

1. S3 — `DeleteObject` removes the object
2. Database — controlled by `deleteMode` (default `"soft"`)

| `deleteMode` | S3 object           | Database row                                     |
| ------------ | ------------------- | ------------------------------------------------ |
| `"soft"`     | permanently deleted | kept as `status: "deleted"` with `deletedAt` set |
| `"hard"`     | permanently deleted | row removed entirely                             |

Omit `deleteMode` and the row stays as `deleted` after S3 removal. Listings
hide soft-deleted rows by default.

<Callout type="warn" title="Not a recycle bin">
  Soft delete is not a recycle bin. The file is gone from S3. The row is kept
  for audit and history.
</Callout>

```ts
db({
  client: dimahS3Db,
  resolveScope,
  deleteMode: "soft", // default — you can omit this line
});
```

<Accordions>
  <Accordion title="When to use hard delete">
    Use `deleteMode: "hard"` when every delete should also erase the DB row
    (for example GDPR-style erasure). To prune old soft-deleted audit rows
    later, call `s3.db.objects.hardDelete({ bucket, key })` from a script or
    job — that helper only removes the row; it does not delete from S3.
  </Accordion>
</Accordions>

## CLI [#cli]

Generate adapter-specific schema (or run migrations) through FumaDB:

```ts title="scripts/db-cli.mts"
import { DimahS3DB } from "@dimah-s3/db";
import { runCli } from "@dimah-s3/db/cli";
import { drizzleAdapter } from "fumadb/adapters/drizzle";

void runCli(
  DimahS3DB.client(drizzleAdapter({ db: {} as never, provider: "sqlite" })),
);
```

```bash
node --import tsx scripts/db-cli.mts generate latest -o ./db/dimah-s3.ts
```

`generate` overwrites the output file. Re-add secondary indexes afterward,
or keep them in a separate migration.

| Index                                     | Columns                        |
| ----------------------------------------- | ------------------------------ |
| `storage_object_scope_status_created_idx` | `scope`, `status`, `createdAt` |
| `storage_object_status_expires_idx`       | `status`, `expiresAt`          |
| `storage_object_status_created_idx`       | `status`, `createdAt`          |


# Internationalization (https://s3.dimah.dev/docs/i18n)



English needs no setup. To localize, put a map in a file and pass it to
the provider.

```ts title="lib/translations-fa.ts"
import type { Translations } from "@dimah-s3/react";

export const fa = {
  "Upload failed(toast)": "آپلود ناموفق بود",
  "Cancel(dialog button)": "لغو",
} satisfies Partial<Translations>;
```

```ts title="lib/s3-client.ts"
"use client";

import { createS3Client } from "@dimah-s3/react";

export const s3Client = createS3Client();
export const S3Provider = s3Client.Provider;
```

```tsx title="app/layout.tsx"
import { S3Provider } from "@/lib/s3-client";
import { fa } from "@/lib/translations-fa";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="fa" dir="rtl">
      <body>
        <S3Provider translations={fa}>{children}</S3Provider>
      </body>
    </html>
  );
}
```

Vite / other all-client apps can pass `translations` on `<s3Client.Provider>` instead.

Missing keys stay English.

## API errors [#api-errors]

The server always responds in English. With `@dimah-s3/ui` or the React hooks you do not need anything else — the same `translations` map localizes those messages.


# Amazon S3 (https://s3.dimah.dev/docs/providers/aws-s3)



Amazon S3 is fully supported with all native features (presigned POST, presigned PUT, object ACLs, and multipart uploads).

<div className="fd-steps">
  <div className="fd-step">
    ## Bucket CORS configuration [#1-bucket-cors-configuration]

    Because uploads occur directly from the browser to your AWS S3 bucket, you must configure a CORS policy on your S3 bucket:

    ```json
    [
      {
        "AllowedOrigins": ["https://your-app.example", "http://localhost:3000"],
        "AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
        "AllowedHeaders": ["*"],
        "ExposeHeaders": ["ETag", "Content-Type"],
        "MaxAgeSeconds": 3000
      }
    ]
    ```
  </div>

  <div className="fd-step">
    ## Server configuration [#2-server-configuration]

    ```ts title="lib/s3.ts"
    import { S3Client } from "@aws-sdk/client-s3";
    import { dimahS3, route } from "@dimah-s3/server";

    export const awsS3 = new S3Client({
      region: process.env.S3_REGION!,
      credentials: {
        accessKeyId: process.env.S3_ACCESS_KEY_ID!,
        secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
      },
    });

    export const s3 = dimahS3({
      client: awsS3,
      bucket: process.env.S3_BUCKET!,
      routes: {
        avatar: route({
          upload: {
            fileTypes: ["image/*"],
            maxFileSize: 2 * 1024 * 1024,
          },
        }),
      },
    });
    ```
  </div>
</div>

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I make uploaded files publicly accessible?">
    Set `upload: { acl: "public-read" }` in your route definition (ensure Object Ownership and ACLs are enabled in your S3 bucket settings):

    ```ts
    avatar: route({
      upload: {
        acl: "public-read",
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
      },
    });
    ```
  </Accordion>

  <Accordion title="How do I troubleshoot 403 Forbidden CORS errors?">
    Ensure `AllowedOrigins` includes your frontend port (e.g. `http://localhost:3000`) and that `ExposeHeaders` contains `ETag` and `Content-Type`.
  </Accordion>
</Accordions>


# MinIO (https://s3.dimah.dev/docs/providers/minio)



MinIO is a lightweight, self-hosted S3-compatible object store.

## Configuration [#configuration]

Set `forcePathStyle: true` on the S3Client so object URLs format as `http://host:9000/bucket/key`:

```ts title="lib/s3.ts"
import { S3Client } from "@aws-sdk/client-s3";
import { dimahS3, route } from "@dimah-s3/server";

export const awsS3 = new S3Client({
  region: "us-east-1",
  endpoint: process.env.S3_ENDPOINT || "http://localhost:9000",
  forcePathStyle: true, // Required for MinIO
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID || "minioadmin",
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "minioadmin",
  },
});

export const s3 = dimahS3({
  client: awsS3,
  bucket: "my-bucket",
  routes: {
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
      },
    }),
  },
});
```

## MinIO CORS configuration [#minio-cors-configuration]

Set the `MINIO_API_CORS_ALLOW_ORIGIN` environment variable on your MinIO server:

```bash
MINIO_API_CORS_ALLOW_ORIGIN=https://your-app.example
```

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="Why is my browser upload failing with a network error on localhost?">
    Ensure `S3_ENDPOINT` points to the MinIO API port (typically `9000`), not the web console port (typically `9001`).
  </Accordion>
</Accordions>


# Bring your own backend (https://s3.dimah.dev/docs/react/custom-backend)



Implement `S3Api` with [`defineApi`](https://s3.dimah.dev/docs/react/setup). Routes, transport, and
server framework are up to you. They do not need to match
[`@dimah-s3/server`](https://s3.dimah.dev/docs/server).

Each `S3Api` method must accept the documented input and return JSON with
**every field** in the response tables. Missing fields break the hooks.
Every request includes a required `route` name. Upload and multipart init
do **not** send `key` — your backend generates it and returns it.

Input types: `Parameters<S3Api["upload"]>`. Response types are exported from
`@dimah-s3/core`.

## Flows [#flows]

| Flow          | Methods                                                             | Doc                                               |
| ------------- | ------------------------------------------------------------------- | ------------------------------------------------- |
| Simple upload | `upload` → browser uploads to S3 → `confirm`                        | [Upload](https://s3.dimah.dev/docs/react/custom-backend/upload)       |
| Download      | `download`                                                          | [Download](https://s3.dimah.dev/docs/react/custom-backend/download)   |
| Delete        | `delete`                                                            | [Delete](https://s3.dimah.dev/docs/react/custom-backend/delete)       |
| Multipart     | `init` → `signPart` → `complete` or `abort` · `listParts` to resume | [Multipart](https://s3.dimah.dev/docs/react/custom-backend/multipart) |

## Skeleton [#skeleton]

Merge the methods from each page into one `defineApi` call:

```ts title="lib/s3-api.ts"
import { defineApi } from "@dimah-s3/react";

export const api = defineApi({
  async upload(payload) {
    /* see Upload */
  },
  async confirm(payload) {
    /* see Upload */
  },
  async download(payload) {
    /* see Download */
  },
  async delete(payload) {
    /* see Delete */
  },
  multipart: {
    async init(payload) {
      /* see Multipart */
    },
    async signPart(payload) {
      /* see Multipart */
    },
    async listParts(payload) {
      /* see Multipart */
    },
    async complete(payload) {
      /* see Multipart */
    },
    async abort(payload) {
      /* see Multipart */
    },
  },
});
```


# Delete (https://s3.dimah.dev/docs/react/custom-backend/delete)



`delete` removes one object. Unlike upload and download, this is a direct
mutation — not a presigned client-to-S3 call.

The payload is a single object: `route` and `key`.

<AutoTypeTable path="packages/core/src/types/requests.ts" name="DeletePayload" />

<AutoTypeTable path="packages/core/src/types/responses.ts" name="DeleteResponse" />

```ts
import type { DeletePayload, DeleteResponse } from "@dimah-s3/core";
```

## Example [#example]

Add this to the [`defineApi`](https://s3.dimah.dev/docs/react/custom-backend) skeleton.
[`@dimah-s3/server`](https://s3.dimah.dev/docs/server) uses `DELETE /api/s3/delete?route=…&key=…`.

```ts
async delete(payload) {
  const params = new URLSearchParams({
    route: payload.route,
    key: payload.key,
  });

  const res = await fetch(`/api/files/delete?${params}`, { method: "DELETE" });
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}
```


# Download (https://s3.dimah.dev/docs/react/custom-backend/download)



`download` returns a GET URL. The default is a short-lived S3 URL. When
the route uses `download.mode: "proxy"`, the URL is same-origin and the
server streams the body.

The payload is a single object: `route`, `key`, and optional `fileName` /
`disposition`.

<AutoTypeTable path="packages/core/src/types/requests.ts" name="DownloadPayload" />

<AutoTypeTable path="packages/core/src/types/responses.ts" name="DownloadPresignResponse" />

```ts
import type { DownloadPayload, DownloadPresignResponse } from "@dimah-s3/core";
```

## Example [#example]

Add this to the [`defineApi`](https://s3.dimah.dev/docs/react/custom-backend) skeleton.
[`@dimah-s3/server`](https://s3.dimah.dev/docs/server) uses
`GET /api/s3/presign/download?route=…&key=…`.

```ts
async download(payload) {
  const params = new URLSearchParams({
    route: payload.route,
    key: payload.key,
  });
  if (payload.fileName) params.set("fileName", payload.fileName);

  const res = await fetch(`/api/files/presign/download?${params}`);
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}
```


# Multipart (https://s3.dimah.dev/docs/react/custom-backend/multipart)



Split a large file into parts. Each part is a presigned PUT; your backend
orchestrates init, signing, complete, and abort.

**Flow:** `init` → `signPart` (per part) → `complete` or `abort` · `listParts`
to resume.

## Init [#init]

Same payload shape as [simple upload](https://s3.dimah.dev/docs/react/custom-backend/upload).

<AutoTypeTable path="packages/core/src/types/requests.ts" name="MultipartInitPayload" />

<AutoTypeTable path="packages/core/src/types/responses.ts" name="MultipartInitResponse" />

## Sign part [#sign-part]

<AutoTypeTable path="packages/core/src/types/requests.ts" name="MultipartSignPartPayload" />

<AutoTypeTable path="packages/core/src/types/responses.ts" name="MultipartPartResponse" />

## List parts [#list-parts]

<AutoTypeTable path="packages/core/src/types/requests.ts" name="MultipartListPartsPayload" />

<AutoTypeTable path="packages/core/src/types/responses.ts" name="MultipartListPartsResponse" />

## Complete [#complete]

<AutoTypeTable path="packages/core/src/types/requests.ts" name="MultipartCompletePayload" />

<AutoTypeTable path="packages/core/src/types/responses.ts" name="MultipartCompleteResponse" />

## Abort [#abort]

Same payload shape as list-parts.

<AutoTypeTable path="packages/core/src/types/requests.ts" name="MultipartAbortPayload" />

<AutoTypeTable path="packages/core/src/types/responses.ts" name="MultipartAbortResponse" />

```ts
import type {
  MultipartInitPayload,
  MultipartInitResponse,
  MultipartSignPartPayload,
  MultipartPartResponse,
  MultipartListPartsPayload,
  MultipartListPartsResponse,
  MultipartCompletePayload,
  MultipartCompleteResponse,
  MultipartAbortPayload,
  MultipartAbortResponse,
} from "@dimah-s3/core";
```

## Example [#example]

Add this to the [`defineApi`](https://s3.dimah.dev/docs/react/custom-backend) skeleton.
[`@dimah-s3/server`](https://s3.dimah.dev/docs/server) mounts these under
`/api/s3/presign/multipart/*`.

```ts
const base = "/api/files/presign/multipart";

async function post<T>(url: string, body: unknown): Promise<T> {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

multipart: {
  init: (payload) => post(`${base}/init`, payload),
  signPart: (payload) => post(`${base}/part`, payload),
  async listParts(payload) {
    const params = new URLSearchParams({
      route: payload.route,
      key: payload.key,
      uploadId: payload.uploadId,
    });
    const res = await fetch(`${base}/parts?${params}`);
    if (!res.ok) throw new Error(await res.text());
    return res.json();
  },
  complete: (payload) => post(`${base}/complete`, payload),
  abort: (payload) => post(`${base}/abort`, payload),
}
```


# Upload (https://s3.dimah.dev/docs/react/custom-backend/upload)



1. **`upload`** — your backend returns a presigned URL (POST fields or PUT headers).
2. The browser uploads directly to S3.
3. **`confirm`** — your backend verifies the object (typically `HeadObject`) and returns metadata.

Large files: [Multipart](https://s3.dimah.dev/docs/react/custom-backend/multipart).

<Callout>
  `upload` does not receive `key`. Generate it on the server and return it in
  the presign response. `confirm` sends that stored `key` plus the same `route`.
</Callout>

## Presign [#presign]

<AutoTypeTable path="packages/core/src/types/requests.ts" name="UploadPayload" />

Return **all** of these fields. `fields` when `method` is `"POST"`; `headers`
when `method` is `"PUT"`.

<AutoTypeTable path="packages/core/src/types/responses.ts" name="UploadPresignResponse" />

## Confirm [#confirm]

<AutoTypeTable path="packages/core/src/types/requests.ts" name="ConfirmPayload" />

`contentLength` and `metadata` are required. `eTag` and `contentType` come
from HeadObject when available.

<AutoTypeTable path="packages/core/src/types/responses.ts" name="UploadConfirmResponse" />

```ts
import type {
  UploadPayload,
  UploadPresignResponse,
  ConfirmPayload,
  UploadConfirmResponse,
} from "@dimah-s3/core";
```

## Example [#example]

Add these to the [`defineApi`](https://s3.dimah.dev/docs/react/custom-backend) skeleton. Paths
are yours — [`@dimah-s3/server`](https://s3.dimah.dev/docs/server) uses
`/api/s3/presign/upload` and `/api/s3/presign/upload/confirm`.

```ts
async function post<T>(url: string, body: unknown): Promise<T> {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

const upload = (payload) => post("/api/files/presign/upload", payload);
const confirm = (payload) => post("/api/files/presign/upload/confirm", payload);
```


# Forms (https://s3.dimah.dev/docs/react/forms)



Keep upload state and form state separate:

* `useUpload` owns browser `File` objects, validation, progress, retries, and confirmation.
* The form owns a serializable value—usually the confirmed S3 object keys.

Only write a key into the form after `onSuccess` runs. At that point the server
has accepted the key and verified the uploaded object with `HeadObject`.

## Upload flow [#upload-flow]

<Flow
  label="Upload field lifecycle"
  steps="[
  { name: &#x22;Select files&#x22;, kind: &#x22;client&#x22;, note: &#x22;UploadDropzone&#x22; },
  { name: &#x22;Presign&#x22;, kind: &#x22;server&#x22;, note: &#x22;guards and server-owned key&#x22; },
  { name: &#x22;Upload bytes&#x22;, kind: &#x22;s3&#x22;, note: &#x22;direct browser transfer&#x22; },
  { name: &#x22;Confirm&#x22;, kind: &#x22;server&#x22;, note: &#x22;HeadObject verification&#x22; },
  { name: &#x22;onSuccess&#x22;, kind: &#x22;hook&#x22;, note: &#x22;confirmed UploadResult[]&#x22; },
  {
    name: &#x22;Set field value&#x22;,
    kind: &#x22;client&#x22;,
    note: &#x22;serializable object keys&#x22;,
  },
]"
/>

`useUpload` remains the source of truth for progress and file UI. The form only
stores the value that your application submits.

***

## Choose a field value [#choose-a-field-value]

For most forms, store object keys:

```ts
type FormValues = {
  objectKeys: string[];
};
```

Map confirmed results to that value in `onSuccess`:

```ts
onSuccess: (results) => {
  form.setValue(
    "objectKeys",
    results.map((result) => result.key),
  );
};
```

If the submission also needs verified size, content type, or filename, store
`UploadResult[]` instead:

```ts
import type { UploadResult } from "@dimah-s3/react";

type FormValues = {
  attachments: UploadResult[];
};
```

Do not store `File[]` in a payload that will be sent to your server. `File`
objects are browser-only and do not represent a completed upload.

***

## React Hook Form [#react-hook-form]

This example uploads immediately after selection and stores the confirmed keys
in React Hook Form.

```tsx title="components/attachment-form.tsx"
"use client";

import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useFormatDimahError, useUpload } from "@dimah-s3/react";
import { UploadDropzone } from "@dimah-s3/ui";

const formSchema = z.object({
  title: z.string().min(1, "Title is required."),
  objectKeys: z.array(z.string()).min(1, "Upload at least one file."),
});

type FormValues = z.infer<typeof formSchema>;

export function AttachmentForm() {
  const formatError = useFormatDimahError();
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      title: "",
      objectKeys: [],
    },
  });

  const upload = useUpload({
    route: "attachments",
    maxFiles: 5,
    onUploadStart: () => {
      form.clearErrors("objectKeys");
    },
    onSuccess: (results) => {
      form.setValue(
        "objectKeys",
        results.map((result) => result.key),
        {
          shouldDirty: true,
          shouldValidate: true,
        },
      );
    },
    onError: (error) => {
      form.setError("objectKeys", {
        type: "upload",
        message: formatError(error),
      });
    },
  });

  const reset = () => {
    form.reset();
    upload.reset();
  };

  return (
    <form
      aria-busy={upload.isPending}
      onSubmit={form.handleSubmit((values) => {
        console.log(values);
      })}
    >
      <Controller
        name="objectKeys"
        control={form.control}
        render={({ fieldState }) => (
          <div>
            <span>Files</span>
            <UploadDropzone upload={upload} />
            {fieldState.error ? (
              <p role="alert">{fieldState.error.message}</p>
            ) : null}
          </div>
        )}
      />

      <button type="submit" disabled={upload.isPending}>
        Submit
      </button>
      <button type="button" onClick={reset}>
        Reset
      </button>
    </form>
  );
}
```

The `Controller` registers the upload field and exposes its validation state.
`UploadDropzone` still renders from `upload`, so progress and per-file status do
not need to be copied into React Hook Form.

***

## TanStack Form [#tanstack-form]

The same boundary applies to TanStack Form: write confirmed keys with
`setFieldValue`, and render transfer errors from `upload.error`.

```tsx title="components/attachment-form.tsx"
"use client";

import { useForm } from "@tanstack/react-form";
import { useFormatDimahError, useUpload } from "@dimah-s3/react";
import { UploadDropzone } from "@dimah-s3/ui";

export function AttachmentForm() {
  const formatError = useFormatDimahError();
  const form = useForm({
    defaultValues: {
      objectKeys: [] as string[],
    },
    onSubmit: ({ value }) => {
      console.log(value);
    },
  });

  const upload = useUpload({
    route: "attachments",
    maxFiles: 5,
    onSuccess: (results) => {
      form.setFieldValue(
        "objectKeys",
        results.map((result) => result.key),
      );
    },
  });

  const reset = () => {
    form.reset();
    upload.reset();
  };

  return (
    <form
      aria-busy={upload.isPending}
      onSubmit={(event) => {
        event.preventDefault();
        event.stopPropagation();
        form.handleSubmit();
      }}
    >
      <form.Field
        name="objectKeys"
        validators={{
          onSubmit: ({ value }) =>
            value.length > 0 ? undefined : "Upload at least one file.",
        }}
      >
        {(field) => {
          const error =
            upload.error != null
              ? formatError(upload.error)
              : field.state.meta.errors[0];

          return (
            <div>
              <span>Files</span>
              <UploadDropzone upload={upload} />
              {error ? <p role="alert">{error}</p> : null}
            </div>
          );
        }}
      </form.Field>

      <button type="submit" disabled={upload.isPending}>
        Submit
      </button>
      <button type="button" onClick={reset}>
        Reset
      </button>
    </form>
  );
}
```

You can use a Standard Schema validator such as Zod at the form level instead.
That does not change the upload integration.

***

## Single-file fields [#single-file-fields]

For an avatar or cover image, store one nullable key and limit intake to one
file:

```ts
const upload = useUpload({
  route: "avatar",
  maxFiles: 1,
  onSuccess: (results) => {
    form.setValue("avatarKey", results[0]?.key ?? null);
  },
});
```

***

## Integration rules [#integration-rules]

1. Disable submission while `upload.isPending` is `true`.
2. Set the field value only from `onSuccess`.
3. Surface `onError` or `upload.error` next to the field, even when also using a
   toast.
4. Call both `form.reset()` and `upload.reset()` when resetting the form.
5. Render progress, previews, and transfer status from `upload.files`, not from
   duplicated form state.

<Callout>
  `maxFiles` is a client-side intake limit on `useUpload`. Server routes enforce
  each file's type and size, but they do not enforce a form-level attachment
  count. Validate the submitted key count in your application endpoint.
</Callout>

***

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="Can I use UploadButton instead of UploadDropzone?">
    Yes. Both components accept the same `UseUploadReturn`, so the form wiring does
    not change:

    ```tsx
    <UploadButton upload={upload} label="Attach files" />
    ```
  </Accordion>

  <Accordion title="Can I wait until form submission to upload?">
    Yes, with a custom file picker. Keep selected `File` objects in local client
    state, then pass them to `upload.handleFiles()` from your submit flow.

    The built-in `UploadButton` and `UploadDropzone` use eager uploads by design.
    Eager uploads are the simpler default because the form already has confirmed
    keys when validation and submission run.
  </Accordion>

  <Accordion title="What happens if the user abandons the form?">
    A successfully confirmed upload remains in S3 even if the form is never
    submitted. Delete it explicitly when the user removes or resets the field, or
    track draft attachments in your application and clean them up on a schedule.

    `purgeStalePendingObjects` only cleans up uploads that never completed S3
    confirmation. It does not remove successfully uploaded but unreferenced
    objects.
  </Accordion>
</Accordions>


# Helpers (https://s3.dimah.dev/docs/react/helpers)



`@dimah-s3/react` and `@dimah-s3/core` export utilities for formatting file sizes, progress labels, hashing, and error localization.

## Progress and speed formatting [#progress-and-speed-formatting]

```ts
import { formatUploadProgress, formatSpeed, formatEta } from "@dimah-s3/react";

formatUploadProgress(1_200_000, 2_000_000, 60); // "1.2 MB / 2 MB (60%)"
formatSpeed(1_200_000); // "1.2 MB/s"
formatEta(800_000, 200_000); // "4s"
```

***

## File size and validation [#file-size-and-validation]

```ts
import { formatFileSize, truncateFileName, validateFile } from "@dimah-s3/core";

formatFileSize(1_500_000); // "1.4 MB"
truncateFileName("my-avatar-profile-picture-long-name.png", 20); // "my-avatar-pro… .png"

const error = validateFile(file, {
  accept: ["image/*"],
  maxFileSize: 2 * 1024 * 1024,
});
// returns null if valid, or { code: "FILE_TYPE_NOT_ALLOWED", message }
```

***

## Checksums and public URLs [#checksums-and-public-urls]

```ts
import {
  buildPublicObjectUrl,
  matchesMagicBytes,
  sha256File,
} from "@dimah-s3/core";

// SHA-256 base64 digest
const hash = await sha256File(file);

// Public CDN URL helper
const url = buildPublicObjectUrl({
  baseUrl: "https://cdn.example.com",
  key: "avatar/uuid/avatar.png",
});
```

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I format API errors in toast notifications?">
    Use `useFormatDimahError`:

    ```tsx
    import { isAPIError, useFormatDimahError } from "@dimah-s3/react";

    export function AvatarUploader() {
      const formatError = useFormatDimahError();

      const upload = useUpload({
        route: "avatar",
        onError: (err) => {
          toast.error(formatError(err));
        },
      });
    }
    ```
  </Accordion>
</Accordions>


# useDelete (https://s3.dimah.dev/docs/react/hooks/delete)



`useDelete` coordinates object deletion with your server API, featuring built-in support for confirmation dialog flows, single-step deletion, and batch operations.

```tsx title="components/delete-avatar.tsx"
"use client";

import { useDelete } from "@dimah-s3/react";

export function DeleteAvatar({ avatarKey }: { avatarKey: string }) {
  const {
    isConfirming,
    requestDelete,
    confirmDelete,
    cancelDelete,
    isDeleting,
  } = useDelete({
    route: "avatar",
    onSuccess: (key) => {
      console.log("Deleted avatar key:", key);
    },
  });

  if (isConfirming) {
    return (
      <div className="flex items-center gap-2">
        <span className="text-sm">Delete avatar?</span>
        <button
          type="button"
          onClick={() => void confirmDelete()}
          disabled={isDeleting}
          className="text-destructive font-medium"
        >
          {isDeleting ? "Deleting…" : "Confirm"}
        </button>
        <button type="button" onClick={cancelDelete}>
          Cancel
        </button>
      </div>
    );
  }

  return (
    <button type="button" onClick={() => requestDelete(avatarKey)}>
      Delete Avatar
    </button>
  );
}
```

***

## Deletion workflows [#deletion-workflows]

<div className="fd-steps">
  <div className="fd-step">
    ### Two-step confirmation flow [#1-two-step-confirmation-flow]

    Use `requestDelete(key)` to enter the `confirming` state for that specific key. This keeps the pending key in state until the user approves (`confirmDelete()`) or cancels (`cancelDelete()`).

    ```tsx
    const { isConfirming, requestDelete, confirmDelete, cancelDelete } = useDelete({
      route: "avatar",
    });
    ```
  </div>

  <div className="fd-step">
    ### Immediate deletion (`remove`) [#2-immediate-deletion-remove]

    If your UI manages its own modal dialog or requires direct execution without internal confirmation state, call `remove(key)`:

    ```tsx
    const { remove, isDeleting } = useDelete({ route: "avatar" });

    await remove(avatarKey);
    ```
  </div>

  <div className="fd-step">
    ### Batch deletion (`removeMany`) [#3-batch-deletion-removemany]

    Delete multiple keys in a single request via S3 `DeleteObjects`:

    ```tsx
    const { removeMany } = useDelete({ route: "avatar" });

    await removeMany([key1, key2, key3]);
    ```

    ***
  </div>
</div>

## Type reference [#type-reference]

```ts
import type {
  UseDeleteOptions,
  UseDeleteReturn,
  UseDeleteState,
  DeletePhase,
  DeleteHooks,
} from "@dimah-s3/react";
```

### UseDeleteOptions [#usedeleteoptions]

<AutoTypeTable path="packages/react/src/hooks/use-delete.ts" name="UseDeleteOptions" />

***

### UseDeleteReturn [#usedeletereturn]

<AutoTypeTable path="packages/react/src/hooks/use-delete.ts" name="UseDeleteReturn" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I ensure only the object owner can delete it?">
    Authorization runs securely on your server in `delete.guard`:

    ```ts title="lib/s3.ts"
    avatar: route({
      delete: {
        guard: async ({ key, request }) => {
          const session = await getSession(request);
          if (!session) throw errors.unauthorized();

          const isOwner = await checkOwnership(session.userId, key);
          if (!isOwner) throw errors.forbidden();
        },
        onDeleted: async ({ key, request }) => {
          // Database record cleanup
        },
      },
    });
    ```
  </Accordion>

  <Accordion title="How do I clear error states after a failed deletion?">
    Call `reset()` to return the hook back to the `idle` phase:

    ```tsx
    const { reset, error } = useDelete({ route: "avatar" });

    if (error) {
      return (
        <div>
          <p className="text-destructive">{error.message}</p>
          <button type="button" onClick={reset}>
            Try Again
          </button>
        </div>
      );
    }
    ```
  </Accordion>
</Accordions>


# useDownload (https://s3.dimah.dev/docs/react/hooks/download)



`useDownload` requests signed GET URLs from your server and handles browser-native downloads or client-side fetch streaming with live progress tracking.

```tsx title="components/download-avatar.tsx"
"use client";

import { useDownload } from "@dimah-s3/react";

export function DownloadAvatar({ avatarKey }: { avatarKey: string }) {
  const { download, isPending } = useDownload({
    route: "avatar",
  });

  return (
    <button
      type="button"
      onClick={() => void download(avatarKey)}
      disabled={isPending}
      className="btn"
    >
      {isPending ? "Generating link…" : "Download Avatar"}
    </button>
  );
}
```

***

## Download modes [#download-modes]

<div className="fd-steps">
  <div className="fd-step">
    ### Browser navigation (`mode: "navigate"`, default) [#1-browser-navigation-mode-navigate-default]

    Requests a presigned GET URL and navigates the browser directly to S3. S3 immediately responds with the binary file stream and triggers the browser's native file save dialog.

    ```tsx
    const { download, presign, phase, isPending } = useDownload({
      route: "avatar",
      onInitiated: (key) => {
        console.log("Browser handed download URL for:", key);
      },
    });

    // Trigger download
    await download(avatarKey);

    // Or get the presigned URL directly without triggering browser navigation
    const { url, expiresIn } = await presign(avatarKey);
    ```
  </div>

  <div className="fd-step">
    ### Fetch streaming (`mode: "fetch"`) [#2-fetch-streaming-mode-fetch]

    Fetches the object bytes through a client-side `fetch` stream. Provides fine-grained byte progress, speed calculation, and abortable cancellation.

    ```tsx
    const { download, cancel, progress, isDownloading, isPending } = useDownload({
      route: "document",
      mode: "fetch",
      onProgress: (key, progress) => {
        console.log(
          `Downloaded ${progress.percent}% (${progress.loaded}/${progress.total})`,
        );
      },
      onSuccess: (key, fileName) => {
        console.log(`Saved ${fileName} to disk`);
      },
    });

    return (
      <div>
        <button onClick={() => void download(documentKey)} disabled={isPending}>
          {isDownloading ? `Downloading: ${progress.percent}%` : "Download File"}
        </button>
        {isDownloading && (
          <button type="button" onClick={cancel}>
            Cancel
          </button>
        )}
      </div>
    );
    ```

    ***
  </div>
</div>

## Inline preview with `useObjectUrl` [#inline-preview-with-useobjecturl]

For displaying private S3 files inline (e.g. `<img>`, `<video>`, `<audio>`, or `<iframe>`), use `useObjectUrl`. It requests a signed URL with `disposition: "inline"` and automatically caches the signed URL in memory until shortly before its expiration window.

```tsx title="components/avatar-image.tsx"
"use client";

import { useObjectUrl } from "@dimah-s3/react";

export function AvatarImage({ avatarKey }: { avatarKey: string }) {
  const { url, isLoading, refresh } = useObjectUrl({
    route: "avatar",
    objectKey: avatarKey,
    disposition: "inline",
  });

  if (isLoading)
    return <div className="size-16 rounded-full bg-muted animate-pulse" />;
  if (!url) return null;

  return (
    <img
      src={url}
      alt="User Avatar"
      className="size-16 rounded-full object-cover"
    />
  );
}
```

***

## Type reference [#type-reference]

```ts
import type {
  UseNavigateDownloadOptions,
  UseNavigateDownloadReturn,
  UseFetchDownloadOptions,
  UseFetchDownloadReturn,
  UseObjectUrlOptions,
  UseObjectUrlReturn,
} from "@dimah-s3/react";
```

### UseNavigateDownloadOptions [#usenavigatedownloadoptions]

<AutoTypeTable path="packages/react/src/hooks/use-download.ts" name="UseNavigateDownloadOptions" />

***

### UseFetchDownloadOptions [#usefetchdownloadoptions]

<AutoTypeTable path="packages/react/src/hooks/use-download.ts" name="UseFetchDownloadOptions" />

***

### UseNavigateDownloadReturn [#usenavigatedownloadreturn]

<AutoTypeTable path="packages/react/src/hooks/use-download.ts" name="UseNavigateDownloadReturn" />

***

### UseFetchDownloadReturn [#usefetchdownloadreturn]

<AutoTypeTable path="packages/react/src/hooks/use-download.ts" name="UseFetchDownloadReturn" />

***

### UseObjectUrlOptions [#useobjecturloptions]

<AutoTypeTable path="packages/react/src/hooks/use-object-url.ts" name="UseObjectUrlOptions" />

***

### UseObjectUrlReturn [#useobjecturlreturn]

<AutoTypeTable path="packages/react/src/hooks/use-object-url.ts" name="UseObjectUrlReturn" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I specify a custom download filename from the client?">
    Pass the filename as the second argument to `download` or `presign`:

    ```tsx
    await download(avatarKey, "custom-avatar-name.png");
    ```
  </Accordion>

  <Accordion title="Can multiple buttons share a single useDownload instance?">
    Yes. `useDownload` tracks `objectKey` internally so loading states, errors, and progress indicators stay scoped to the button matching the active key.
  </Accordion>

  <Accordion title="How does useObjectUrl cache presigned URLs?">
    `useObjectUrl` caches signed URLs in memory indexed by route, key, disposition, and filename. The cached URL is returned instantly on subsequent renders until 15 seconds before its `expiresIn` TTL. Calling `refresh()` invalidates the cache and requests a fresh URL.
  </Accordion>
</Accordions>


# useUpload (https://s3.dimah.dev/docs/react/hooks/upload)



`useUpload` manages the complete client-side upload lifecycle: file selection, validation against server constraints, presigned URL acquisition, chunked progress tracking, and final server confirmation.

```tsx title="components/avatar-uploader.tsx"
"use client";

import { useUpload } from "@dimah-s3/react";

export function AvatarUploader() {
  const upload = useUpload({
    route: "avatar",
    onSuccess: (results) => {
      console.log("Uploaded object key:", results[0]?.key);
    },
  });

  return (
    <div
      {...upload.getRootProps()}
      className="border border-dashed p-6 rounded-lg text-center cursor-pointer hover:border-primary transition-colors"
    >
      <input {...upload.getInputProps()} />
      {upload.isUploading ? (
        <p className="text-sm font-medium">
          Uploading: {upload.progress.percent}%
        </p>
      ) : (
        <p className="text-sm text-muted-foreground">
          Click or drop avatar here
        </p>
      )}
    </div>
  );
}
```

***

## File selection and intake [#file-selection-and-intake]

`useUpload` provides native bindings for drag-and-drop surfaces and file input elements:

* **`getRootProps()`**: Spread onto your container element to bind drag-and-drop and click-to-browse handlers.
* **`getInputProps()`**: Spread onto a hidden `<input type="file" />`.
* **`open()`**: Programmatically trigger the OS file dialog from a custom button.
* **`handleFiles(files)`**: Manually submit `File`, `File[]`, or `FileList` (e.g. from clipboard paste, webcam capture, or canvas blob).

```tsx
const upload = useUpload({
  route: "avatar",
  noClick: true, // Prevents container click from opening file picker
});

return (
  <div {...upload.getRootProps()}>
    <input {...upload.getInputProps()} />
    <button type="button" onClick={() => upload.open()}>
      Choose File
    </button>
  </div>
);
```

***

## Multi-file batch uploads [#multi-file-batch-uploads]

Set `maxFiles` to allow multiple file selections. You can control concurrency with `concurrentFiles`:

```tsx
const upload = useUpload({
  route: "avatar",
  maxFiles: 5,
  concurrentFiles: 2,
  onFileSuccess: (file, result) => {
    console.log(`Uploaded ${file.name} to ${result.key}`);
  },
  onSuccess: (results) => {
    console.log("All uploads completed:", results);
  },
});
```

***

## Progress and state tracking [#progress-and-state-tracking]

The hook surfaces detailed reactive state for UI rendering:

* **`phase`**: Current upload phase (`idle` | `validating` | `presigning` | `uploading` | `finalizing` | `success` | `error`).
* **`progress`**: Aggregate progress object containing `loaded`, `total`, `percent`, and instantaneous `speed` (bytes/sec).
* **`files`**: Per-file state array containing individual progress, status (`pending`, `uploading`, `success`, `error`), and `previewUrl`.
* **`file`**: Convenience shorthand for `files[0]` when `maxFiles` is 1.
* **`isUploading`**: Boolean flag indicating active byte transfer (`phase === "uploading"`).
* **`isPending`**: Boolean flag indicating in-flight operation from presign to confirmation.

***

## Type reference [#type-reference]

```ts
import type {
  UseUploadOptions,
  UseUploadReturn,
  UploadProgress,
  UploadResult,
  UploadFileState,
} from "@dimah-s3/react";
```

### UseUploadOptions [#useuploadoptions]

<AutoTypeTable path="packages/react/src/hooks/use-upload.ts" name="UseUploadOptions" />

***

### UseUploadReturn [#useuploadreturn]

<AutoTypeTable path="packages/react/src/hooks/use-upload.ts" name="UseUploadReturn" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="What if the route catalog fails to load?">
    `useUpload` still works. The server enforces type and size on presign.
    `policy.catalogStatus` is `"error"` and `policy.catalogError` is set so
    you can toast or pass explicit `accept` / `maxFileSize` on the hook.
    In development, a one-time console warning is also printed.

    Custom backends that do not implement `GET /routes` should pass those
    constraints on `useUpload` and ignore the catalog status.
  </Accordion>

  <Accordion title="How do I display a local image preview before uploading?">
    Use `previewUrl` on individual items in the `upload.files` array or create an object URL from `upload.file`:

    ```tsx
    export function AvatarWithPreview() {
      const upload = useUpload({ route: "avatar" });
      const preview = upload.file?.previewUrl;

      return (
        <div>
          <div {...upload.getRootProps()}>
            <input {...upload.getInputProps()} />
            {preview ? (
              <img
                src={preview}
                alt="Selected avatar"
                className="size-20 rounded-full object-cover"
              />
            ) : (
              <button type="button">Select avatar</button>
            )}
          </div>
          {upload.isUploading && <span>{upload.progress.percent}%</span>}
        </div>
      );
    }
    ```
  </Accordion>

  <Accordion title="How do I pass custom metadata to the server on upload?">
    Pass static `uploadOptions` or a dynamic `getUploadOptions` function:

    ```tsx
    const upload = useUpload({
      route: "avatar",
      getUploadOptions: (file) => ({
        metadata: {
          originalName: file.name,
          uploadedAt: new Date().toISOString(),
        },
      }),
    });
    ```
  </Accordion>

  <Accordion title="How do I pause, resume, or cancel in-flight uploads?">
    Use `cancel()` to abort the upload and clean up temporary parts, or `detach()` when an `uploadStore` is configured to pause for later resumption:

    ```tsx
    const upload = useUpload({
      route: "avatar",
      uploadStore: localStorageStore,
    });

    // Abort immediately
    upload.cancel();

    // Soft-stop to resume on next visit
    upload.detach();
    ```
  </Accordion>
</Accordions>


# Setup (https://s3.dimah.dev/docs/react/setup)



## Install [#install]

<CodeBlockTabs defaultValue="npm">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="npm">
      npm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="pnpm">
      pnpm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="yarn">
      yarn
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="bun">
      bun
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="npm">
    ```bash
    npm i @dimah-s3/react
    ```
  </CodeBlockTab>

  <CodeBlockTab value="pnpm">
    ```bash
    pnpm add @dimah-s3/react
    ```
  </CodeBlockTab>

  <CodeBlockTab value="yarn">
    ```bash
    yarn add @dimah-s3/react
    ```
  </CodeBlockTab>

  <CodeBlockTab value="bun">
    ```bash
    bun add @dimah-s3/react
    ```
  </CodeBlockTab>
</CodeBlockTabs>

<div className="fd-steps">
  <div className="fd-step">
    ## Create client & provider [#1-create-client--provider]

    Create the typed client instance using `createS3Client`.

    <Tabs items="[&#x22;@dimah-s3/server&#x22;, &#x22;Custom backend&#x22;]">
      <Tab value="@dimah-s3/server">
        ```ts title="lib/s3-client.ts"
        "use client";

        import { createS3Client } from "@dimah-s3/react";

        export const s3Client = createS3Client();
        export const S3Provider = s3Client.Provider;
        ```

        To configure custom base paths, headers, or credentials:

        ```ts
        export const s3Client = createS3Client({
          basePath: "/api/s3",
          credentials: "include",
          headers: async () => ({
            Authorization: `Bearer ${await getAuthToken()}`,
          }),
        });
        ```
      </Tab>

      <Tab value="Custom backend">
        If you are using a custom backend API instead of `@dimah-s3/server`, implement `S3Api` using `defineApi`:

        ```tsx title="components/s3-provider.tsx"
        "use client";

        import { S3Provider } from "@dimah-s3/react";
        import { api } from "@/lib/s3-api";

        export function AppS3Provider({ children }: { children: React.ReactNode }) {
          return <S3Provider api={api}>{children}</S3Provider>;
        }
        ```

        See [Custom Backend](https://s3.dimah.dev/docs/react/custom-backend) for protocol specifications.
      </Tab>
    </Tabs>
  </div>

  <div className="fd-step">
    ## Mount S3Provider [#2-mount-s3provider]

    Mount `<S3Provider>` near your application root:

    <Tabs items="[&#x22;Next.js (App Router)&#x22;, &#x22;Vite / SPA&#x22;]">
      <Tab value="Next.js (App Router)">
        ```tsx title="app/layout.tsx"
        import { S3Provider } from "@/lib/s3-client";

        export default function RootLayout({
          children,
        }: {
          children: React.ReactNode;
        }) {
          return (
            <html lang="en">
              <body>
                <S3Provider>{children}</S3Provider>
              </body>
            </html>
          );
        }
        ```
      </Tab>

      <Tab value="Vite / SPA">
        ```tsx title="src/main.tsx"
        import React from "react";
        import ReactDOM from "react-dom/client";
        import { S3Provider } from "./lib/s3-client";
        import { App } from "./app";

        ReactDOM.createRoot(document.getElementById("root")!).render(
          <React.StrictMode>
            <S3Provider>
              <App />
            </S3Provider>
          </React.StrictMode>,
        );
        ```
      </Tab>
    </Tabs>
  </div>

  <div className="fd-step">
    ## Call your first hook [#3-call-your-first-hook]

    Use `useUpload` with a route name matching your server config:

    ```tsx title="app/avatar-uploader.tsx"
    "use client";

    import { useUpload } from "@dimah-s3/react";

    export function AvatarUploader() {
      const upload = useUpload({
        route: "avatar",
      });

      return (
        <div
          {...upload.getRootProps()}
          className="border p-4 rounded text-center cursor-pointer"
        >
          <input {...upload.getInputProps()} />
          {upload.isUploading
            ? `Uploading: ${upload.progress.percent}%`
            : "Click or drop avatar here"}
        </div>
      );
    }
    ```

    ***
  </div>
</div>

## TypeScript route inference [#typescript-route-inference]

Enable autocompletion and type checking for route names across hooks and components:

```ts title="lib/s3-routes.ts"
import type { InferS3Routes } from "@dimah-s3/core";
import type { s3 } from "@/lib/s3";

declare module "@dimah-s3/core" {
  interface DimahS3Routes extends Record<InferS3Routes<typeof s3>, true> {}
}
```

***

## Type reference [#type-reference]

```ts
import type {
  CreateS3ClientOptions,
  CreateS3ClientResult,
  ReactS3Client,
} from "@dimah-s3/react";
```

### CreateS3ClientOptions [#creates3clientoptions]

<AutoTypeTable path="packages/core/src/create-s3-client.ts" name="CreateS3ClientOptions" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I automatically sync file constraints from the server?">
    By default, hooks fetch constraints (`fileTypes`, `maxFileSize`) automatically from `GET /routes` (`api.catalog()`). You can omit `accept` and `maxFileSize` on `useUpload` so the server remains the single source of truth.
  </Accordion>

  <Accordion title="How do I pass auth tokens or cookies to the API?">
    Pass an async `headers` function or `credentials: "include"` in `createS3Client`:

    ```ts
    export const s3Client = createS3Client({
      credentials: "include",
      headers: async () => ({
        Authorization: `Bearer ${getStoredToken()}`,
      }),
    });
    ```
  </Accordion>
</Accordions>


# TanStack Query (https://s3.dimah.dev/docs/react/tanstack-query)



`useUpload` already owns in-flight state (`isPending`, progress, errors,
retries). Wrapping `handleFiles` in `useMutation` duplicates that and
drops per-file progress.

Use TanStack Query for **lists and records**. Let `onSuccess` on the
upload hook invalidate those queries after confirm.

## Invalidate after confirm [#invalidate-after-confirm]

```tsx title="components/avatar-uploader.tsx"
"use client";

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useUpload } from "@dimah-s3/react";
import { UploadButton } from "@dimah-s3/ui";

async function fetchProfile() {
  const response = await fetch("/api/profile");
  return response.json() as Promise<{ avatarUrl: string | null }>;
}

async function saveAvatarKey(key: string) {
  await fetch("/api/profile", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ avatarKey: key }),
  });
}

export function AvatarUploader() {
  const queryClient = useQueryClient();
  const profile = useQuery({
    queryKey: ["profile"],
    queryFn: fetchProfile,
  });

  const upload = useUpload({
    route: "avatar",
    onSuccess: async (results) => {
      const key = results[0]?.key;
      if (!key) return;
      await saveAvatarKey(key);
      await queryClient.invalidateQueries({ queryKey: ["profile"] });
    },
  });

  return (
    <div>
      {profile.data?.avatarUrl ? (
        <img src={profile.data.avatarUrl} alt="" />
      ) : null}
      <UploadButton upload={upload} />
    </div>
  );
}
```

`onSuccess` runs after the server `HeadObject` confirm. That is the
right time to persist the key and refresh cached profile data.

## Forms [#forms]

For form fields that store object keys, see [Forms](https://s3.dimah.dev/docs/react/forms).
The same rule applies: write keys in `onSuccess`, then let the form
submit the serializable value.

## What not to do [#what-not-to-do]

```tsx
// Avoid — loses progress and double-tracks pending state
const mutation = useMutation({
  mutationFn: (file: File) => upload.handleFiles(file),
});
```

`handleFiles` resolves even when validation fails (the hook sets
`upload.error` instead of throwing). A mutation would report success.
Keep Query for the data you read back; keep `useUpload` for the
transfer.


# UI Setup (https://s3.dimah.dev/docs/react/ui)



[`@dimah-s3/ui`](https://www.npmjs.com/package/@dimah-s3/ui) provides accessible, theme-ready UI controls built on top of [shadcn](https://ui.shadcn.com) design tokens and `@dimah-s3/react` hooks.

<div className="fd-steps">
  <div className="fd-step">
    ## Installation [#1-installation]

    <Tabs items="[&#x22;npm package&#x22;, &#x22;shadcn registry&#x22;]">
      <Tab value="npm package">
        <CodeBlockTabs defaultValue="npm">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npm i @dimah-s3/ui shadcn
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm add @dimah-s3/ui shadcn
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn add @dimah-s3/ui shadcn
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun add @dimah-s3/ui shadcn
            ```
          </CodeBlockTab>
        </CodeBlockTabs>
      </Tab>

      <Tab value="shadcn registry">
        ```json title="components.json"
        {
          "registries": {
            "@dimah-s3": "https://s3.dimah.dev/r/{name}.json"
          }
        }
        ```

        <CodeBlockTabs defaultValue="npm">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npx shadcn@latest add @dimah-s3/upload-button
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm dlx shadcn@latest add @dimah-s3/upload-button
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn dlx shadcn@latest add @dimah-s3/upload-button
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun x shadcn@latest add @dimah-s3/upload-button
            ```
          </CodeBlockTab>
        </CodeBlockTabs>
      </Tab>
    </Tabs>
  </div>

  <div className="fd-step">
    ## Import CSS stylesheet [#2-import-css-stylesheet]

    Import the UI stylesheet in your global CSS to enable animations and design tokens:

    ```css title="app/globals.css"
    @import "shadcn/tailwind.css";
    @import "@dimah-s3/ui/styles.css";

    /* + shadcn theme variables (`--primary`, `--muted`, …) */

    /* Optional: theme dimah-s3 independently of the rest of the app
    @theme {
      --color-dimah-s3-primary: oklch(0.65 0.15 150);
    }
    */

    ```
  </div>

  <div className="fd-step">
    ## Mount Toaster [#3-mount-toaster]

    Mount `<Toaster />` next to `<S3Provider>` in your root layout:

    ```tsx title="app/layout.tsx"
    import { Toaster } from "@dimah-s3/ui";
    import { S3Provider } from "@/lib/s3-client";

    export default function RootLayout({
      children,
    }: {
      children: React.ReactNode;
    }) {
      return (
        <html lang="en">
          <body>
            <S3Provider>{children}</S3Provider>
            <Toaster />
          </body>
        </html>
      );
    }
    ```

    ***
  </div>
</div>

## Explore UI components [#explore-ui-components]

<Cards>
  <Card title="Upload Button" href="/docs/react/ui/components/upload-button" description="Trigger native file picker with inline progress rows." />

  <Card title="Upload Dropzone" href="/docs/react/ui/components/upload-dropzone" description="Drag-and-drop surface for single and multi-file queue." />

  <Card title="Download Button" href="/docs/react/ui/components/download-button" description="One-click presigned download trigger." />

  <Card title="Progress Download Button" href="/docs/react/ui/components/progress-download-button" description="Live stream progress indicator with cancellation." />

  <Card title="Delete Button" href="/docs/react/ui/components/delete-button" description="Confirmation dialog and delete executor." />

  <Card title="Theming" href="/docs/react/ui/customization/theming" description="Customize colors with Tailwind and shadcn variables." />
</Cards>

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="Can I use shadcn components without installing the @dimah-s3/ui package?">
    Yes. Use the shadcn registry command (`npx shadcn@latest add @dimah-s3/upload-button`) to copy component source directly into your codebase.
  </Accordion>
</Accordions>


# Attachment (https://s3.dimah.dev/docs/react/ui/components/attachment)





`FileAttachment` and `StatusAttachment` are status rows built on [shadcn Attachment](https://ui.shadcn.com/docs/components/attachment).

## Preview [#preview]

<AttachmentPlaygroundPreview />

## Install [#install]

Complete [UI setup](https://s3.dimah.dev/docs/react/ui) first — stylesheet and toaster. Then
add this component:

<Tabs items="[&#x22;npm package&#x22;, &#x22;shadcn registry&#x22;]">
  <Tab value="npm package">
    ```tsx
    import { FileAttachment, StatusAttachment } from "@dimah-s3/ui";
    ```
  </Tab>

  <Tab value="shadcn registry">
    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npx shadcn@latest add @dimah-s3/file-attachment
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm dlx shadcn@latest add @dimah-s3/file-attachment
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn dlx shadcn@latest add @dimah-s3/file-attachment
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun x shadcn@latest add @dimah-s3/file-attachment
        ```
      </CodeBlockTab>
    </CodeBlockTabs>
  </Tab>
</Tabs>

***

## Usage [#usage]

```tsx
"use client";

import { useUpload } from "@dimah-s3/react";
import { UploadButton } from "@dimah-s3/ui";

// Don't use FileAttachment or StatusAttachment directly.
// Upload, download, and delete components already render them.

export function AvatarUploader() {
  const upload = useUpload({
    route: "avatar",
  });

  return <UploadButton upload={upload} />;
}
```

For custom chrome, compose [shadcn Attachment](https://ui.shadcn.com/docs/components/attachment) with the headless hooks — see [Custom UI](https://s3.dimah.dev/docs/react/ui/customization/custom-ui).

***

## Props [#props]

```ts
import type { FileAttachmentProps, StatusAttachmentProps } from "@dimah-s3/ui";
```

### FileAttachment [#fileattachment]

<AutoTypeTable path="packages/ui/src/components/dimah-s3/attachment/file-attachment.tsx" name="FileAttachmentProps" />

### StatusAttachment [#statusattachment]

<AutoTypeTable path="packages/ui/src/components/dimah-s3/attachment/status-attachment.tsx" name="StatusAttachmentProps" />


# Delete Button (https://s3.dimah.dev/docs/react/ui/components/delete-button)





`DeleteButton` removes an object from S3 after displaying a confirmation dialog.

## Preview [#preview]

<DemoPreview name="delete-button-demo.tsx">
  <DeleteButtonDemo />
</DemoPreview>

## Install [#install]

Complete [UI setup](https://s3.dimah.dev/docs/react/ui) first — stylesheet and toaster. Then
add this component:

<Tabs items="[&#x22;npm package&#x22;, &#x22;shadcn registry&#x22;]">
  <Tab value="npm package">
    ```tsx
    import { DeleteButton } from "@dimah-s3/ui";
    ```
  </Tab>

  <Tab value="shadcn registry">
    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npx shadcn@latest add @dimah-s3/delete-button
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm dlx shadcn@latest add @dimah-s3/delete-button
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn dlx shadcn@latest add @dimah-s3/delete-button
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun x shadcn@latest add @dimah-s3/delete-button
        ```
      </CodeBlockTab>
    </CodeBlockTabs>
  </Tab>
</Tabs>

***

## Usage [#usage]

```tsx
"use client";

import { useDelete } from "@dimah-s3/react";
import { DeleteButton } from "@dimah-s3/ui";

export function AvatarDeleter({ avatarKey }: { avatarKey: string }) {
  const del = useDelete({
    route: "avatar",
    onSuccess: (key) => {
      console.log("Avatar removed:", key);
    },
  });

  return (
    <DeleteButton delete={del} objectKey={avatarKey} variant="destructive" />
  );
}
```

***

## Props [#props]

```ts
import type { DeleteButtonProps } from "@dimah-s3/ui";
```

<AutoTypeTable path="packages/ui/src/components/dimah-s3/delete/delete-button.tsx" name="DeleteButtonProps" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I dismiss the status row after delete?">
    Completed and failed attachments include a dismiss control that calls `reset()` on the `useDelete` return. You can also call `reset()` yourself.
  </Accordion>

  <Accordion title="How do I customize the confirmation dialog text?">
    Use `title` and `description` props:

    ```tsx
    <DeleteButton
      delete={del}
      objectKey={avatarKey}
      title="Remove profile picture?"
      description="This action cannot be undone. Your profile will revert to the default avatar."
    />
    ```
  </Accordion>

  <Accordion title="How do I perform deletion without the confirmation dialog?">
    Use the headless `useDelete` hook and call `remove(objectKey)` directly:

    ```tsx
    const { remove } = useDelete({ route: "avatar" });

    <button onClick={() => remove(avatarKey)}>Quick Delete</button>;
    ```
  </Accordion>
</Accordions>


# Download Button (https://s3.dimah.dev/docs/react/ui/components/download-button)





`DownloadButton` requests a presigned GET URL and triggers a native browser download.

## Preview [#preview]

<DemoPreview name="download-button-demo.tsx">
  <DownloadButtonDemo />
</DemoPreview>

## Install [#install]

Complete [UI setup](https://s3.dimah.dev/docs/react/ui) first — stylesheet and toaster. Then
add this component:

<Tabs items="[&#x22;npm package&#x22;, &#x22;shadcn registry&#x22;]">
  <Tab value="npm package">
    ```tsx
    import { DownloadButton } from "@dimah-s3/ui";
    ```
  </Tab>

  <Tab value="shadcn registry">
    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npx shadcn@latest add @dimah-s3/download-button
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm dlx shadcn@latest add @dimah-s3/download-button
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn dlx shadcn@latest add @dimah-s3/download-button
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun x shadcn@latest add @dimah-s3/download-button
        ```
      </CodeBlockTab>
    </CodeBlockTabs>
  </Tab>
</Tabs>

***

## Usage [#usage]

```tsx
"use client";

import { useDownload } from "@dimah-s3/react";
import { DownloadButton } from "@dimah-s3/ui";

export function AvatarDownloader({ avatarKey }: { avatarKey: string }) {
  const download = useDownload({ route: "avatar" });

  return (
    <DownloadButton
      download={download}
      objectKey={avatarKey}
      variant="outline"
    />
  );
}
```

***

## Props [#props]

```ts
import type { DownloadButtonProps } from "@dimah-s3/ui";
```

<AutoTypeTable path="packages/ui/src/components/dimah-s3/download/download-button.tsx" name="DownloadButtonProps" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I dismiss the status row after download?">
    Completed (fetch mode) and failed attachments include a dismiss control that calls `download.reset()`. You can also call `reset()` yourself.
  </Accordion>

  <Accordion title="Can multiple list items share a single useDownload hook?">
    Yes. `useDownload` tracks `objectKey` internally so loading states and status toasts only affect the button matching the active key.
  </Accordion>

  <Accordion title="How do I show download progress instead of a spinner?">
    Use the [`ProgressDownloadButton`](https://s3.dimah.dev/docs/react/ui/components/progress-download-button) component paired with `useDownload({ route, mode: "fetch" })`.
  </Accordion>
</Accordions>


# Progress Download Button (https://s3.dimah.dev/docs/react/ui/components/progress-download-button)





`ProgressDownloadButton` streams the download in the browser via fetch, displaying real-time progress percentages with cancel capability.

## Preview [#preview]

<DemoPreview name="progress-download-demo.tsx">
  <ProgressDownloadDemo />
</DemoPreview>

## Install [#install]

Complete [UI setup](https://s3.dimah.dev/docs/react/ui) first — stylesheet and toaster. Then
add this component:

<Tabs items="[&#x22;npm package&#x22;, &#x22;shadcn registry&#x22;]">
  <Tab value="npm package">
    ```tsx
    import { ProgressDownloadButton } from "@dimah-s3/ui";
    ```
  </Tab>

  <Tab value="shadcn registry">
    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npx shadcn@latest add @dimah-s3/progress-download-button
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm dlx shadcn@latest add @dimah-s3/progress-download-button
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn dlx shadcn@latest add @dimah-s3/progress-download-button
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun x shadcn@latest add @dimah-s3/progress-download-button
        ```
      </CodeBlockTab>
    </CodeBlockTabs>
  </Tab>
</Tabs>

***

## Usage [#usage]

```tsx
"use client";

import { useDownload } from "@dimah-s3/react";
import { ProgressDownloadButton } from "@dimah-s3/ui";

export function DocumentDownloader({ documentKey }: { documentKey: string }) {
  const download = useDownload({ route: "document", mode: "fetch" });

  return <ProgressDownloadButton download={download} objectKey={documentKey} />;
}
```

***

## Props [#props]

```ts
import type { ProgressDownloadButtonProps } from "@dimah-s3/ui";
```

<AutoTypeTable path="packages/ui/src/components/dimah-s3/download/progress-download-button.tsx" name="ProgressDownloadButtonProps" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I dismiss the status row after download?">
    Completed and failed attachments include a dismiss control that calls `download.reset()`. You can also call `reset()` yourself.
  </Accordion>

  <Accordion title="How does cancellation work?">
    Clicking the button while an active download is in progress immediately aborts the underlying fetch stream and resets the button state.
  </Accordion>

  <Accordion title="Does this work with private buckets?">
    Yes. `useDownload` requests a signed URL before initiating the fetch stream, respecting any route guards or bucket policies.
  </Accordion>
</Accordions>


# Upload Button (https://s3.dimah.dev/docs/react/ui/components/upload-button)





`UploadButton` connects directly to a `useUpload` hook and triggers the native OS file picker.

## Preview [#preview]

<DemoPreview name="upload-button-demo.tsx">
  <UploadButtonDemo />
</DemoPreview>

## Install [#install]

Complete [UI setup](https://s3.dimah.dev/docs/react/ui) first — stylesheet and toaster. Then
add this component:

<Tabs items="[&#x22;npm package&#x22;, &#x22;shadcn registry&#x22;]">
  <Tab value="npm package">
    ```tsx
    import { UploadButton } from "@dimah-s3/ui";
    ```
  </Tab>

  <Tab value="shadcn registry">
    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npx shadcn@latest add @dimah-s3/upload-button
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm dlx shadcn@latest add @dimah-s3/upload-button
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn dlx shadcn@latest add @dimah-s3/upload-button
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun x shadcn@latest add @dimah-s3/upload-button
        ```
      </CodeBlockTab>
    </CodeBlockTabs>
  </Tab>
</Tabs>

***

## Usage [#usage]

```tsx
"use client";

import { useUpload } from "@dimah-s3/react";
import { UploadButton } from "@dimah-s3/ui";

export function AvatarUploader() {
  const upload = useUpload({
    route: "avatar",
  });

  return <UploadButton upload={upload} label="Choose Avatar" />;
}
```

***

## Props [#props]

```ts
import type { UploadButtonProps } from "@dimah-s3/ui";
```

<AutoTypeTable path="packages/ui/src/components/dimah-s3/upload/upload-button.tsx" name="UploadButtonProps" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I dismiss the status row after upload?">
    Completed and failed attachments include a dismiss control that calls `upload.reset()`. You can also call `reset()` yourself.
  </Accordion>

  <Accordion title="How do I disable the inline attachment status list?">
    Pass `status={false}`:

    ```tsx
    <UploadButton upload={upload} status={false} />
    ```
  </Accordion>

  <Accordion title="How do I enable toast notifications on completion?">
    Pass `toast={true}` (make sure `<Toaster />` is mounted near your app root):

    ```tsx
    <UploadButton upload={upload} toast={true} />
    ```
  </Accordion>
</Accordions>


# Upload Dropzone (https://s3.dimah.dev/docs/react/ui/components/upload-dropzone)





`UploadDropzone` provides an accessible drag-and-drop file target bound to `useUpload`.

## Preview [#preview]

<DemoPreview name="upload-dropzone-demo.tsx">
  <UploadDropzoneDemo />
</DemoPreview>

## Install [#install]

Complete [UI setup](https://s3.dimah.dev/docs/react/ui) first — stylesheet and toaster. Then
add this component:

<Tabs items="[&#x22;npm package&#x22;, &#x22;shadcn registry&#x22;]">
  <Tab value="npm package">
    ```tsx
    import { UploadDropzone } from "@dimah-s3/ui";
    ```
  </Tab>

  <Tab value="shadcn registry">
    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npx shadcn@latest add @dimah-s3/upload-dropzone
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm dlx shadcn@latest add @dimah-s3/upload-dropzone
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn dlx shadcn@latest add @dimah-s3/upload-dropzone
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun x shadcn@latest add @dimah-s3/upload-dropzone
        ```
      </CodeBlockTab>
    </CodeBlockTabs>
  </Tab>
</Tabs>

***

## Usage [#usage]

```tsx
"use client";

import { useUpload } from "@dimah-s3/react";
import { UploadDropzone } from "@dimah-s3/ui";

export function AvatarDropzone() {
  const upload = useUpload({
    route: "avatar",
  });

  return <UploadDropzone upload={upload} />;
}
```

***

## Props [#props]

```ts
import type { UploadDropzoneProps } from "@dimah-s3/ui";
```

<AutoTypeTable path="packages/ui/src/components/dimah-s3/upload/upload-dropzone.tsx" name="UploadDropzoneProps" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I separate the dropzone area from the uploaded files status list?">
    Pass `status={false}` to `UploadDropzone` and render `UploadStatus` separately:

    ```tsx
    import { UploadDropzone, UploadStatus } from "@dimah-s3/ui";

    export function CustomDropzone() {
      const upload = useUpload({ route: "gallery", maxFiles: 5 });

      return (
        <div className="space-y-4">
          <UploadDropzone upload={upload} status={false} />
          <UploadStatus upload={upload} />
        </div>
      );
    }
    ```
  </Accordion>

  <Accordion title="How do I provide custom interior placeholder content?">
    Pass React children to `UploadDropzone`:

    ```tsx
    <UploadDropzone upload={upload}>
      <div className="flex flex-col items-center justify-center p-6 text-muted-foreground">
        <p className="text-sm font-medium">Drag avatar image here</p>
        <p className="text-xs">PNG, JPG or WebP up to 2MB</p>
      </div>
    </UploadDropzone>
    ```
  </Accordion>
</Accordions>


# Custom UI (https://s3.dimah.dev/docs/react/ui/customization/custom-ui)





Combine `@dimah-s3/react` hooks with your own components or custom shadcn primitives.

## Preview [#preview]

<DemoPreview name="custom-upload-demo.tsx">
  <CustomUploadDemo />
</DemoPreview>

## Example: Headless Avatar Uploader [#example-headless-avatar-uploader]

```tsx
"use client";

import { useUpload } from "@dimah-s3/react";
import { Button } from "@/components/ui/button";

export function CustomAvatarPicker() {
  const upload = useUpload({
    route: "avatar",
    noDrag: true,
    noClick: true,
  });

  return (
    <div className="flex items-center gap-4">
      <input {...upload.getInputProps()} />
      <Button type="button" onClick={() => upload.open()}>
        {upload.isUploading
          ? `Uploading ${upload.progress.percent}%`
          : "Select Avatar"}
      </Button>
    </div>
  );
}
```

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I access low-level state like transfer speed or bytes remaining?">
    `upload.progress` exposes detailed progress metrics:

    ```tsx
    const { progress } = useUpload({ route: "avatar" });

    console.log(progress.loaded, progress.total, progress.percent);
    ```
  </Accordion>
</Accordions>


# Theming (https://s3.dimah.dev/docs/react/ui/customization/theming)



`@dimah-s3/ui` components use CSS variable-based tokens (`--color-dimah-s3-*`) that map directly to standard shadcn theme tokens by default.

## Token mapping [#token-mapping]

| Token                                 | Default Value               |
| ------------------------------------- | --------------------------- |
| `--color-dimah-s3-background`         | `var(--background)`         |
| `--color-dimah-s3-foreground`         | `var(--foreground)`         |
| `--color-dimah-s3-card`               | `var(--card)`               |
| `--color-dimah-s3-primary`            | `var(--primary)`            |
| `--color-dimah-s3-primary-foreground` | `var(--primary-foreground)` |
| `--color-dimah-s3-muted`              | `var(--muted)`              |
| `--color-dimah-s3-muted-foreground`   | `var(--muted-foreground)`   |
| `--color-dimah-s3-destructive`        | `var(--destructive)`        |
| `--color-dimah-s3-border`             | `var(--border)`             |
| `--color-dimah-s3-ring`               | `var(--ring)`               |

***

## Customizing colors [#customizing-colors]

To customize styling for dimah-s3 components independently from your global theme, override the tokens in your CSS:

```css title="app/globals.css"
@import "@dimah-s3/ui/styles.css";

@theme {
  --color-dimah-s3-primary: oklch(0.65 0.15 150);
  --color-dimah-s3-destructive: oklch(0.55 0.2 25);
}
```

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I style a specific upload area differently?">
    Scope the CSS variables to a container class:

    ```css
    .avatar-panel {
      --color-dimah-s3-primary: oklch(0.6 0.14 250);
    }
    ```
  </Accordion>
</Accordions>


# Upload Store (https://s3.dimah.dev/docs/react/upload-store)



An `UploadStore` persists `uploadId` and part state so in-flight multipart uploads can resume after a browser reload.

```ts title="lib/upload-store.ts"
import { createLocalStorageStore } from "@dimah-s3/react";

export const localStorageStore = createLocalStorageStore();
```

```tsx title="app/video-uploader.tsx"
"use client";

import { useUpload } from "@dimah-s3/react";
import { localStorageStore } from "@/lib/upload-store";

export function VideoUploader() {
  const upload = useUpload({
    route: "video",
    multipart: true,
    uploadStore: localStorageStore,
  });

  return (
    <div
      {...upload.getRootProps()}
      className="border p-4 rounded text-center cursor-pointer"
    >
      <input {...upload.getInputProps()} />
      {upload.isUploading
        ? `Uploading: ${upload.progress.percent}%`
        : "Upload large video"}
    </div>
  );
}
```

Resume keys are uniquely hashed by `${route}:${file.name}:${file.size}:${file.lastModified}`.

***

## Built-in stores [#built-in-stores]

* `createLocalStorageStore()` — persists in browser `localStorage` across page reloads.
* `createMemoryStore()` — in-memory store for unit tests or SSR.

```ts
import { createLocalStorageStore, createMemoryStore } from "@dimah-s3/react";

const memoryStore = createMemoryStore();
const localStore = createLocalStorageStore();
```

***

## Custom upload store interface [#custom-upload-store-interface]

```ts
import type { UploadStore, StoredUpload } from "@dimah-s3/react";
```

<AutoTypeTable path="packages/react/src/types/upload-store.ts" name="UploadStore" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="Does the upload store work with the @dimah-s3/db plugin?">
    Yes. When using `@dimah-s3/db`, the server tracks pending multipart uploads in your database. You can implement a store `get` method to fetch active multipart upload IDs across devices.
  </Accordion>

  <Accordion title="When should I enable uploadStore?">
    Use `uploadStore` for routes with large files (videos, archives, large datasets) where users may refresh or lose connection mid-upload. Small single-part uploads do not need a store.
  </Accordion>
</Accordions>


# Errors (https://s3.dimah.dev/docs/server/errors)



All failed requests return JSON `{ message, code?, params? }` and standard HTTP status codes.

Use the `errors` helper to throw standard library errors:

```ts
import { APIError, errors, isAPIError, isS3ErrorCode } from "@dimah-s3/server";

// Standard helpers
throw errors.unauthorized();
throw errors.forbidden();
throw errors.objectNotFound();
throw errors.payloadTooLarge();
throw errors.featureDisabled("download");
throw errors.unknownRoute("avatar");
throw errors.fileTypeNotAllowed("application/zip");

// Custom APIError
throw new APIError("BAD_REQUEST", {
  message: "Invalid file payload.",
});
```

Catch and inspect errors:

```ts
if (isAPIError(err)) {
  console.log(err.status, err.statusCode, err.code, err.message);
}

if (isS3ErrorCode(err, "OBJECT_NOT_FOUND")) {
  // Handle missing object specifically
}
```

***

## Error catalog [#error-catalog]

| `code`                   | HTTP | Trigger                                                                        |
| ------------------------ | ---- | ------------------------------------------------------------------------------ |
| `NOT_FOUND`              | 404  | Unknown API path                                                               |
| `UNKNOWN_ROUTE`          | 404  | Target `route` is not registered on the server (`params.route`)                |
| `FEATURE_DISABLED`       | 404  | Target operation (`upload`, `download`, `delete`) is not enabled on this route |
| `OBJECT_NOT_FOUND`       | 404  | Object key or multipart upload does not exist                                  |
| `UNAUTHORIZED`           | 401  | Thrown by your auth guards (`errors.unauthorized()`)                           |
| `FORBIDDEN`              | 403  | Guard rejection or generic unhandled `Error` inside guards                     |
| `CONFLICT`               | 409  | Resource conflict in lifecycle hooks                                           |
| `PAYLOAD_TOO_LARGE`      | 413  | File size exceeds route `maxFileSize` (at presign, part upload, or HeadObject) |
| `FILE_TYPE_NOT_ALLOWED`  | 400  | File type not allowed by route `fileTypes`                                     |
| `VALIDATION_ERROR`       | 400  | Invalid payload shape or malformed checksum                                    |
| `INVALID_KEY`            | 400  | Unsafe key or key outside route `keyPrefix`                                    |
| `MULTIPART_PART_MISSING` | 400  | Complete payload references missing part numbers                               |
| `S3_NETWORK_ERROR`       | 502  | S3 storage service unreachable                                                 |
| `INTERNAL_ERROR`         | 500  | Unhandled server exception or failed `on*` hook                                |

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I display friendly error messages on the client?">
    Use `useFormatDimahError` from `@dimah-s3/react` to map error codes to localized user-facing strings:

    ```tsx
    import { isAPIError, useFormatDimahError } from "@dimah-s3/react";

    const formatError = useFormatDimahError();

    try {
      await download(key);
    } catch (err) {
      if (isAPIError(err)) {
        toast.error(formatError(err));
      }
    }
    ```
  </Accordion>
</Accordions>


# Delete Hooks (https://s3.dimah.dev/docs/server/hooks/delete)



Enable object deletion with `delete: true` or a `DeleteConfig` object on a [named route](https://s3.dimah.dev/docs/server/routes).

<Callout>
  Unlike upload and download, object deletion is executed directly on your
  server using `DeleteObjectCommand` after passing `guard` checks. The client
  never deletes from S3 directly.
</Callout>

```ts title="lib/s3.ts"
import { S3Client } from "@aws-sdk/client-s3";
import { dimahS3, errors, route } from "@dimah-s3/server";

export const awsS3 = new S3Client({/* env */});

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  routes: {
    avatar: route({
      delete: {
        guard: async ({ key, request }) => {
          const session = await getSession(request);
          if (!session) throw errors.unauthorized();

          const isOwner = await checkAvatarOwnership(session.userId, key);
          if (!isOwner) throw errors.forbidden();
        },
        onDeleted: async ({ key, request }) => {
          const session = await getSession(request);
          await db.user.update({
            where: { id: session.userId },
            data: { avatarKey: null },
          });
        },
      },
    }),
  },
});
```

***

## Delete lifecycle flow [#delete-lifecycle-flow]

<Flow
  label="Delete flow"
  steps="[
  { name: &#x22;delete.guard&#x22;, kind: &#x22;hook&#x22; },
  { name: &#x22;DeleteObject in S3&#x22;, kind: &#x22;s3&#x22; },
  { name: &#x22;onDeleted&#x22;, kind: &#x22;hook&#x22;, note: &#x22;Database cleanup&#x22; },
]"
/>

***

## Type reference [#type-reference]

```ts
import type {
  DeleteGuardContext,
  DeleteOnDeletedContext,
} from "@dimah-s3/server";
```

### DeleteGuardContext [#deleteguardcontext]

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="DeleteGuardContext" />

***

### DeleteOnDeletedContext [#deleteondeletedcontext]

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="DeleteOnDeletedContext" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I support batch deletion for multiple files?">
    Batch deletion (`deleteMany` / `removeMany`) runs `delete.guard` for each target key, calls S3 `DeleteObjects`, and triggers `onDeleted` for all successfully removed keys:

    ```ts title="Client usage"
    const { removeMany } = useDelete({ route: "avatar" });
    await removeMany([key1, key2]);
    ```
  </Accordion>

  <Accordion title="What happens if the S3 delete fails?">
    If S3 returns an error during deletion, `onDeleted` does not execute and the endpoint returns an appropriate error response (`502 S3_NETWORK_ERROR` or `INTERNAL_ERROR`).
  </Accordion>
</Accordions>


# Download Hooks (https://s3.dimah.dev/docs/server/hooks/download)



Enable download operations with `download: true` or a `DownloadConfig` object on a [named route](https://s3.dimah.dev/docs/server/routes).

```ts title="lib/s3.ts"
import { S3Client } from "@aws-sdk/client-s3";
import { dimahS3, errors, route } from "@dimah-s3/server";

export const awsS3 = new S3Client({/* env */});

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  routes: {
    avatar: route({
      download: {
        disposition: "inline",
        guard: async ({ key, request }) => {
          const session = await getSession(request);
          if (!session) throw errors.unauthorized();
        },
      },
    }),
  },
});
```

***

## Download lifecycle flow [#download-lifecycle-flow]

<Flow
  label="Download flow"
  steps="[
  { name: &#x22;download.resolve&#x22;, kind: &#x22;hook&#x22; },
  { name: &#x22;download.guard&#x22;, kind: &#x22;hook&#x22; },
  { name: &#x22;onPresigned&#x22;, kind: &#x22;hook&#x22; },
  { name: &#x22;Fetch from S3&#x22;, kind: &#x22;s3&#x22; },
]"
/>

1. **`resolve`**: Optionally rewrites download `fileName`, `disposition` (`inline` vs `attachment`), or `expiresIn`.
2. **`guard`**: Validates request permissions for the specified `key`.
3. **`onPresigned`**: Receives the signed URL and expiration details.

***

## Type reference [#type-reference]

```ts
import type {
  DownloadGuardContext,
  DownloadOnPresignedContext,
  DownloadResolveInfo,
} from "@dimah-s3/server";
```

### DownloadGuardContext [#downloadguardcontext]

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="DownloadGuardContext" />

***

### DownloadOnPresignedContext [#downloadonpresignedcontext]

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="DownloadOnPresignedContext" />

***

### DownloadResolveInfo [#downloadresolveinfo]

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="DownloadResolveInfo" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I change the filename on download dynamically?">
    Use `download.resolve` to set the downloaded file name per request:

    ```ts
    avatar: route({
      download: {
        resolve: async ({ key, request }) => {
          const user = await getUserByKey(key);
          return {
            fileName: `${user.username}-avatar.png`,
            disposition: "attachment",
          };
        },
      },
    });
    ```
  </Accordion>

  <Accordion title="How do I stream files via same-origin proxy without direct bucket access?">
    Set `mode: "proxy"`. The client receives a same-origin URL (`/api/s3/file?key=...`) and your server streams the response:

    ```ts
    avatar: route({
      download: {
        mode: "proxy",
      },
    });
    ```
  </Accordion>
</Accordions>


# Multipart Hooks (https://s3.dimah.dev/docs/server/hooks/multipart)



Enable multipart support on large-file routes by setting `upload.multipart: true` or passing a `MultipartConfig` object.

```ts title="lib/s3.ts"
import { S3Client } from "@aws-sdk/client-s3";
import { dimahS3, route } from "@dimah-s3/server";

export const awsS3 = new S3Client({/* env */});

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  routes: {
    video: route({
      upload: {
        fileTypes: ["video/*"],
        maxFileSize: 500 * 1024 * 1024, // 500MB
        multipart: true,
        onConfirmed: async ({ key, contentLength }) => {
          // Triggered on successful multipart completion
        },
      },
    }),
  },
});
```

***

## Multipart lifecycle flow [#multipart-lifecycle-flow]

<Flow
  label="Multipart upload flow"
  steps="[
  { name: &#x22;upload.guard&#x22;, kind: &#x22;hook&#x22;, note: &#x22;Init validation&#x22; },
  { name: &#x22;CreateMultipartUpload&#x22;, kind: &#x22;s3&#x22; },
  { name: &#x22;multipart.guard&#x22;, kind: &#x22;hook&#x22;, note: &#x22;Authorizes each part&#x22; },
  { name: &#x22;CompleteMultipartUpload&#x22;, kind: &#x22;s3&#x22; },
  { name: &#x22;HeadObject&#x22;, kind: &#x22;server&#x22; },
  { name: &#x22;onConfirmed&#x22;, kind: &#x22;hook&#x22;, note: &#x22;Final metadata&#x22; },
]"
/>

***

## Type reference [#type-reference]

```ts
import type {
  MultipartConfig,
  MultipartOnInitContext,
  MultipartGuardContext,
  MultipartOnAbortContext,
  MultipartOnListContext,
} from "@dimah-s3/server";
```

### MultipartConfig [#multipartconfig]

<AutoTypeTable path="packages/server/src/types/config.ts" name="MultipartConfig" />

***

### MultipartOnInitContext [#multipartoninitcontext]

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="MultipartOnInitContext" />

***

### MultipartGuardContext [#multipartguardcontext]

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="MultipartGuardContext" />

***

### MultipartOnAbortContext [#multipartonabortcontext]

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="MultipartOnAbortContext" />

***

### MultipartOnListContext [#multipartonlistcontext]

<AutoTypeTable path="packages/server/src/types/hook-contexts.ts" name="MultipartOnListContext" />

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="Does multipart upload require a second confirm call?">
    No. The final assembly step (`multipart.complete`) internally runs `upload.confirmGuard`, queries `HeadObject`, and calls `upload.onConfirmed`.
  </Accordion>

  <Accordion title="How do I enforce part size and total size bounds?">
    The server automatically validates each part size during `signPart` and sums uploaded part sizes before calling `CompleteMultipartUpload`. If total parts exceed `maxFileSize`, the upload is aborted with `PAYLOAD_TOO_LARGE`.
  </Accordion>
</Accordions>


# Plugins (https://s3.dimah.dev/docs/server/plugins)



Use `definePlugin` to create reusable server extensions (like [`db()`](https://s3.dimah.dev/docs/db)). Plugins can contribute lifecycle hooks, HTTP endpoints under your `basePath`, and typed instance context (`s3[id]`).

```ts title="lib/s3-audit-plugin.ts"
import { definePlugin, createS3Endpoint } from "@dimah-s3/server";

export const auditPlugin = definePlugin({
  id: "audit",
  hooks: {
    upload: {
      onConfirmed: async ({ key }) => {
        console.log("[audit] Upload confirmed:", key);
      },
    },
  },
  endpoints: {
    stats: createS3Endpoint("/audit/stats", { method: "GET" }, async () => ({
      status: "ok",
    })),
  },
  context: {
    log: (msg: string) => console.log("[audit]", msg),
  },
});
```

Register the plugin in your instance:

```ts title="lib/s3.ts"
import { dimahS3, route } from "@dimah-s3/server";
import { auditPlugin } from "./s3-audit-plugin";

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  plugins: [auditPlugin],
  routes: {
    avatar: route({ upload: true }),
  },
});

// Access plugin context
s3.audit.log("Storage ready");
```

***

## Hook execution order [#hook-execution-order]

* **Guards**: Plugins execute first in registration order, followed by user config guards.
* **Lifecycle hooks (`onConfirmed`, `onDeleted`)**: User config hooks execute first, followed by plugin hooks.

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I create a client-side companion for my server plugin?">
    Use `defineClientPlugin` from `@dimah-s3/core`:

    ```ts
    import { defineClientPlugin, pluginPath } from "@dimah-s3/core";

    export function auditClient() {
      return defineClientPlugin({
        id: "audit",
        getActions: ($fetch) => ({
          getStats: () => $fetch(pluginPath("audit", "stats"), { method: "GET" }),
        }),
      });
    }
    ```
  </Accordion>

  <Accordion title="Can a route opt out of a global plugin?">
    Yes. Set `plugins: { [pluginId]: false }` in your route definition:

    ```ts
    avatar: route({
      upload: true,
      plugins: { audit: false },
    }),
    ```
  </Accordion>
</Accordions>
