Uploads and image delivery in Next.js with Dreep

Install the dreep package, keep DREEP_API_KEY on the server, and add a route handler that checks the signed-in user before calling dreep.presignUpload. The browser PUTs the file to the returned uploadUrl with progress, a server action calls confirmUpload, and next/image renders through a custom loader that adds width, quality and format to the Dreep URL.

The request flow

  1. Client component

    Sends name, type and size

  2. Route handler

    Authorises, calls presignUpload

  3. PUT to uploadUrl

    XHR with progress events

  4. Server action

    Checks ownership, confirmUpload

  5. next/image

    Custom loader builds Dreep URLs

The API key only exists in route handlers and server actions. The browser holds a single-use upload URL that expires after 10 minutes.

Why the guide is shaped this way

The key never reaches the bundle

The SDK throws if constructed in a browser, and lib/dreep.ts imports server-only, so a stray client import fails the build instead of leaking a full-project credential.

Your rules decide who uploads

Dreep validates file type, the 500 MB ceiling and that Content-Type and Content-Length match the presign. Who may upload, how much and where is your route handler's job.

Progress without a proxy

Bytes go from the browser to Dreep's upload URL, not through your Next.js server, so serverless body limits and function timeouts don't apply to the file.

One original, every size

next/image asks the loader for each width in your srcset. Dreep generates each variant on first request and serves it from cache afterwards.

The guide, file by file

Eight files for Next.js 15 with the App Router. getCurrentUser and db stand in for your own auth and database; everything prefixed dreep is the real SDK API (npm install dreep server-only).

1. Environment variables

Two server-only values from the API Keys page. Never prefix them with NEXT_PUBLIC_, which would inline them into client JavaScript.

.env.local
DREEP_API_KEY=drp_live_xxxxx
# Only needed if you serve assets from signed folders
DREEP_SIGNING_SECRET=xxxxx

2. A server-only client

One shared instance. The server-only import turns an accidental client import into a build error.

lib/dreep.ts
import "server-only";
import { Dreep } from "dreep";

export const dreep = new Dreep({
  apiKey: process.env.DREEP_API_KEY!,
  signingSecret: process.env.DREEP_SIGNING_SECRET,
});

3. Route handler: authorise, then presign

Reject anonymous users, enforce your own type and size policy, and record which user owns the upload id so the confirm step can check it.

app/api/uploads/presign/route.ts
import { NextResponse } from "next/server";
import { dreep } from "@/lib/dreep";
import { getCurrentUser } from "@/lib/auth"; // your auth
import { db } from "@/lib/db";

const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/heic"];
const MAX_BYTES = 20 * 1024 * 1024;

export const POST = async (request: Request) => {
  const user = await getCurrentUser();
  if (!user) {
    return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
  }

  const { filename, contentType, sizeBytes } = await request.json();
  if (!ALLOWED_TYPES.includes(contentType) || sizeBytes > MAX_BYTES) {
    return NextResponse.json({ error: "File not allowed" }, { status: 400 });
  }

  const upload = await dreep.presignUpload({
    filename,
    contentType,
    sizeBytes,
    folder: `users/${user.id}`,
  });

  await db.pendingUpload.create({ data: { id: upload.id, userId: user.id } });

  return NextResponse.json({ id: upload.id, uploadUrl: upload.uploadUrl });
};

4. Client component: PUT with progress

fetch can't report upload progress, so use XMLHttpRequest. The Content-Type header must equal the type you presigned, and the browser sets Content-Length from the File.

app/components/PhotoUploader.tsx
"use client";

import { useState } from "react";
import { confirmPhoto } from "@/app/actions/confirmPhoto";

const putWithProgress = (url: string, file: File, onProgress: (n: number) => void) =>
  new Promise<void>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("PUT", url);
    xhr.setRequestHeader("Content-Type", file.type);
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
    };
    xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(`PUT ${xhr.status}`)));
    xhr.onerror = () => reject(new Error("Network error"));
    xhr.send(file);
  });

const PhotoUploader = () => {
  const [progress, setProgress] = useState(0);

  const onChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;

    const res = await fetch("/api/uploads/presign", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ filename: file.name, contentType: file.type, sizeBytes: file.size }),
    });
    const { id, uploadUrl } = await res.json();

    await putWithProgress(uploadUrl, file, setProgress);
    await confirmPhoto(id);
  };

  return (
    <label>
      <input type="file" accept="image/*" onChange={onChange} />
      <progress value={progress} max={100} />
    </label>
  );
};

export default PhotoUploader;

5. Server action: check ownership, confirm

Until confirm runs, the asset is pending and excluded from listings. Confirm only ids this user presigned, then store the asset id, url and dimensions in your own database.

app/actions/confirmPhoto.ts
"use server";

import { dreep } from "@/lib/dreep";
import { getCurrentUser } from "@/lib/auth";
import { db } from "@/lib/db";

export const confirmPhoto = async (id: string) => {
  const user = await getCurrentUser();
  const pending = await db.pendingUpload.findUnique({ where: { id } });
  if (!user || pending?.userId !== user.id) throw new Error("Not your upload");

  const asset = await dreep.confirmUpload({ id });

  await db.photo.create({
    data: { userId: user.id, assetId: asset.id, url: asset.url, width: asset.width, height: asset.height },
  });
};

6. A next/image loader that builds Dreep URLs

Point Next.js at a loader file. It runs in the browser too, so it does plain URL work instead of importing the SDK. Dreep uses full parameter names and has no automatic format, so pick one explicitly.

lib/dreepLoader.ts
"use client";

import type { ImageLoaderProps } from "next/image";

const dreepLoader = ({ src, width, quality }: ImageLoaderProps) => {
  const url = new URL(src);
  url.searchParams.set("width", String(width));
  url.searchParams.set("quality", String(quality ?? 80));
  url.searchParams.set("format", "webp");
  return url.toString();
};

export default dreepLoader;

// next.config.ts
// images: { loader: "custom", loaderFile: "./lib/dreepLoader.ts" }

7. Render public images in a server component

Pass the stored asset.url as src. Next.js calls the loader once per srcset width, and each width becomes its own cached Dreep variant.

app/photos/[id]/page.tsx
import Image from "next/image";
import { db } from "@/lib/db";

const PhotoPage = async ({ params }: { params: Promise<{ id: string }> }) => {
  const { id } = await params;
  const photo = await db.photo.findUniqueOrThrow({ where: { id } });

  return (
    <Image
      src={photo.url}
      alt=""
      width={photo.width}
      height={photo.height}
      sizes="(max-width: 768px) 100vw, 768px"
    />
  );
};

export default PhotoPage;

8. Signed URLs for private assets

For assets in a Signed folder, sign on the server with the transform you want baked in. The signature covers those parameters, so mark the image unoptimized to stop the loader adding more.

app/contracts/[id]/page.tsx
import Image from "next/image";
import { dreep } from "@/lib/dreep";
import { db } from "@/lib/db";

const ContractPage = async ({ params }: { params: Promise<{ id: string }> }) => {
  const { id } = await params;
  const scan = await db.scan.findUniqueOrThrow({ where: { id } });

  const src = dreep.signedUrl(
    { id: scan.assetId, url: scan.url },
    { expiresIn: 900, transform: { width: 1200, format: "webp" } },
  );

  return <Image src={src} alt="Contract scan" width={1200} height={1600} unoptimized />;
};

export default ContractPage;

Presigning from a different backend

If your Next.js front end talks to a Go service or another API, the presign step moves there. The client component and loader stay the same.

const upload = await dreep.presignUpload({
  filename: "avatar.png",
  contentType: "image/png",
  sizeBytes: 482113,
  folder: `users/${user.id}`,
});
// return { id: upload.id, uploadUrl: upload.uploadUrl } to the browser

Who this is for

  • App Router projects that need user uploads without running file bytes through serverless functions.
  • Teams that want next/image srcsets backed by real resized variants instead of the built-in optimiser.
  • Apps mixing public images with private documents that need short-lived signed links.

When Dreep is not the right fit

  • Static sites with a handful of images in public/: the built-in optimiser is enough.
  • Apps that need upload webhooks: Dreep has none, so confirm from your own code as shown.
  • Pure client-side apps with no server: the API key must live on a server.

Frequently asked questions

Can I call the Dreep SDK from a client component?

No. The API key is a full-project credential and the SDK throws if it detects a browser. Call it from route handlers, server actions or server components, and give the browser only the presigned uploadUrl.

Why not upload through a server action with FormData?

Server actions and serverless functions have request body limits and timeouts. Presigning keeps your function to a small JSON request while the file, up to 500 MB, goes straight to Dreep's upload URL.

Does the custom loader replace the Next.js image optimiser?

Yes. With loader set to custom, Next.js stops resizing images itself and asks the loader for a URL per width. Dreep generates each size on first request and caches it.

Which format should the loader request?

Dreep has no automatic format option, so choose one. webp is a safe default for current browsers; avif gives smaller files at higher encode cost. Quality defaults to 80 if you omit it.

How do I know when a video is ready to play?

Heavy video jobs such as HLS or re-encodes return 202 while processing. There are no webhooks, so request the same URL again until it returns 200.

What happens if the user never confirms the upload?

The asset stays pending and is excluded from media listings. The upload URL itself is single-use and expires after 10 minutes.

Related

Ship uploads in your Next.js app this afternoon

Create a free project, copy the API key into .env.local, and drop in the route handler, action and loader above.