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
Client component
Sends name, type and size
Route handler
Authorises, calls presignUpload
PUT to uploadUrl
XHR with progress events
Server action
Checks ownership, confirmUpload
next/image
Custom loader builds Dreep URLs
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.
DREEP_API_KEY=drp_live_xxxxx
# Only needed if you serve assets from signed folders
DREEP_SIGNING_SECRET=xxxxx2. A server-only client
One shared instance. The server-only import turns an accidental client import into a build error.
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.
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.
"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.
"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.
"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.
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.
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 browserup, err := client.PresignUpload(ctx, dreep.PresignOptions{
ContentType: "image/png",
SizeBytes: 482113,
Filename: "avatar.png",
Destination: dreep.Destination{Folder: "users/" + userID},
})
// return up.ID and up.UploadURL; later client.ConfirmUpload(ctx, up.ID, nil)curl -X POST https://api.dreep.cloud/api/v1/upload/presign \
-H "Authorization: Bearer $DREEP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filename":"avatar.png","contentType":"image/png","sizeBytes":482113,"folder":"users/42"}'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
JavaScript SDK
Every method and option in the dreep package.
Secure file uploads
What Dreep validates and what your backend still owns.
Image CDN API
The URL parameters the loader is setting.
Pricing
Free tier with 100 image transformations a month.
Documentation
Presigned uploads, signed URLs and transforms.
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.