2.0 beta — new releases can include breaking changes. Stable 1.x docs
dimah-s3v2.0.0-beta.0

Server Setup

Install `@dimah-s3/server`, set the ticket secret, configure S3, and mount your route handler.

Install

npm i @dimah-s3/server@beta @aws-sdk/client-s3

Set the ticket secret

Presign returns an upload ticket that the server signs with a secret. Set DIMAH_S3_SECRET to a random value of at least 32 characters, the same on every instance:

.env
# openssl rand -base64 32
DIMAH_S3_SECRET="…"

dimahS3() reads it on the first upload. You can pass secret instead. When neither is set, uploads return 500 INTERNAL_ERROR, unless NODE_ENV is development or test: then a random secret is used and a warning is logged.

Configure S3 and routes

Pass your @aws-sdk/client-s3 instance to dimahS3 and define named routes.

Operations (upload, download, delete, and upload.multipart) are off by default. Turn on only what each route needs.

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!,
  },
  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,
      delete: true,
    }),
  },
});

requestChecksumCalculation

Recent AWS SDK versions default to "WHEN_SUPPORTED", which adds checksum parameters that a presigned browser upload cannot match. The server checks each client before its first presign. Any value other than "WHEN_REQUIRED" is logged as a config error and the request returns 500 INTERNAL_ERROR.

Uploading more than 5 GiB

A single presigned PUT or POST cannot exceed 5 GiB. Larger files return FILE_TOO_LARGE. Turn on upload.multipart for them. The route's maxFileSize still applies.


Mount route handler

Adapters are available for all major JavaScript runtimes:

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);

Cache Components

Next.js 16.4 enables Cache Components on new apps. This handler reads the request, so presign, download, and delete run on each request. Leave the handler uncached. A use cache wrapper would reuse one presign across requests.

Node, Express, and Fastify

These adapters stream the request body from req, so the body cap is counted as bytes arrive, and stream response bodies such as a /file download to res. When the client disconnects before the response finishes, the request signal aborts, and so does the S3 call behind it.

Behind a proxy that sets X-Forwarded-Proto and X-Forwarded-Host, pass trustProxy so the request URL uses them:

toNodeHandler(s3, { trustProxy: true });
toExpressHandler(s3, { trustProxy: true });
toFastifyHandler(s3, { trustProxy: true });

Turn it on only behind a proxy that sets those headers. Clients can send them too.


Server-side API (s3.api)

s3.api has the same methods and payloads as the browser client, and runs them in process with the same guards and hooks. The last argument is { headers, signal, request, locals }; signal aborts the call and its S3 requests, like the browser option:

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(
    { route: "avatar", key },
    { headers: await headers() }, 
  );
}
  • headers — Copied onto a synthetic GET request to baseURL, or to http://localhost plus basePath. Guards read cookies and auth headers from it. The request has no query or body.
  • request — The request hooks see. Pass the incoming one when you have it; it is used as is, and headers is ignored.
  • locals — Trusted values that seed locals before any guard runs. HTTP requests can never set them.
jobs/cleanup.ts
await s3.api.deleteMany(
  { route: "files", keys },
  { locals: { userId: job.ownerId } }, 
);

Plugin endpoints, such as the db() list, are HTTP only. Plugins expose their server-side API on the instance instead (s3.db).

Server-side uploads (s3.put)

s3.put uploads from the server through the route's upload policy. It runs upload.object, upload.guard, upload.onStart (kind: "server"), the PutObject, and upload.onConfirmed. confirmGuard does not run.

const object = await s3.put(
  {
    route: "avatar",
    name: "profile.png",
    type: "image/png",
    body: imageBuffer,
  },
  { headers: await headers() }, 
);
// object: { key, bucket, name, size, type, eTag, metadata, … }

body can be a Blob, a ReadableStream (pass size with it), a Uint8Array, an ArrayBuffer, or a string. The object gets a Content-Disposition with the file name. metadata is written to S3 on top of upload.object().metadata. clientMetadata reaches the hooks and is never written.


Type reference

import type {
  DimahS3Config,
  PutInput,
  S3ServerCallOptions,
} from "@dimah-s3/server";

DimahS3Config

Prop

Type


S3ServerCallOptions

Prop

Type


PutInput

Prop

Type

Frequently asked questions

On this page