Quickstart
A working S3 upload in minutes — new app or existing project.
Create a new project
Scaffold a full-stack project with preconfigured S3 routes and UI. create
also writes a generated DIMAH_S3_SECRET to .env.
npx @dimah-s3/cli@beta createManual installation
Install dependencies
npm i @dimah-s3/server@beta @dimah-s3/core@beta @dimah-s3/react@beta @fuma-translate/react @aws-sdk/client-s3@dimah-s3/react needs React 19.2 or later.
Configure S3 and routes
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"
# Signs upload tickets. Generate one with: openssl rand -base64 32
DIMAH_S3_SECRET="your-generated-secret"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!,
},
// The SDK default (WHEN_SUPPORTED) breaks presigned uploads.
requestChecksumCalculation: "WHEN_REQUIRED",
});
export const s3 = dimahS3({
client: awsS3,
bucket: process.env.S3_BUCKET!,
routes: {
avatar: route({
upload: {
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024, // 2MB
},
download: true,
}),
},
});Set bucket CORS for your origin — Amazon S3, Cloudflare R2, or MinIO.
Upload secret
dimahS3() reads DIMAH_S3_SECRET to sign upload
tickets. Use at least 32 characters and the same
value on every server instance, or pass secret yourself. When it is missing,
development and test fall back to a random secret with a warning; anywhere
else, uploads return 500.
Mount the route handler
import { toNextJsHandler } from "@dimah-s3/server/next";
import { s3 } from "@/lib/s3";
export const { GET, POST, PUT, PATCH, DELETE } = toNextJsHandler(s3);Cache Components
Next.js 16.4 enables Cache Components on new apps. This handler reads the request, so it runs on each request. Keep the handler as written. Server Setup explains why it must stay uncached.
Other runtimes: Server Setup.
Create the client and provider
"use client";
import { createS3Client } from "@dimah-s3/react";
export const s3Client = createS3Client();
export const S3Provider = s3Client.Provider;Wrap your application
npm i @dimah-s3/ui@beta shadcn@import "shadcn/tailwind.css";
@import "@dimah-s3/ui/styles.css";
/* shadcn theme variables (`--primary`, `--muted`, …) */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>
);
}Add an upload button
"use client";
import { useUpload } from "@dimah-s3/react";
import { Upload, UploadToasts, UploadTrigger } from "@dimah-s3/ui";
export default function Page() {
const upload = useUpload({ route: "avatar" });
return (
<>
<Upload upload={upload}>
<UploadTrigger />
</Upload>
<UploadToasts upload={upload} />
</>
);
}Selecting a file presigns a secure URL and uploads directly to your bucket.
UploadToasts reports progress, success, and errors for this hook. See
Upload for the dropzone and status parts.
Frequently asked questions
Add a guard on the route or instance. Throw s3Error("UNAUTHORIZED") or s3Error("FORBIDDEN") when the session is missing or not allowed:
import { dimahS3, route, s3Error } from "@dimah-s3/server";
export const s3 = dimahS3({
// ...
routes: {
avatar: route({
guard: async ({ request }) => {
const session = await getSession(request);
if (!session) throw s3Error("UNAUTHORIZED");
},
upload: {
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024,
},
}),
},
});Persist trusted object metadata in onConfirmed, after the server has verified
the stored object with HeadObject. It can run more than once for one upload,
so make the write idempotent:
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 },
});
},
},
});For a form, onSuccess runs once per confirmed file and can place its key in
local form state before your application submits it to its own API:
const upload = useUpload({
route: "avatar",
onSuccess: (result) => {
setValue("avatarKey", result.key);
},
});Pass an S3Client for your R2 account. Uploads use presigned PUT by default,
which R2 supports. Do not set method: "POST": R2 has no presigned POST.
export const s3 = dimahS3({
client: r2Client,
bucket: process.env.R2_BUCKET!,
routes: {
avatar: route({
upload: {
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024,
},
}),
},
});See the Cloudflare R2 guide for full details.
Use useObjectUrl to generate a temporary display URL, or construct your CDN URL if the bucket is public:
"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"
/>
);
}