DEV Community

nareshipme
nareshipme

Posted on

Direct-to-R2 Uploads with Presigned URLs in Next.js 15

When building file upload features in web apps, you have two options:

  1. Proxy through your server — client → your API → storage
  2. Presigned URLs — client uploads directly to storage (your server just mints a short-lived URL)

Option 2 is almost always better for large files. Your server never touches the bytes, you save bandwidth costs, and uploads are faster. Here's how to wire it up with Cloudflare R2 and Next.js 15.


What is a Presigned URL?

A presigned URL is a time-limited, signed URL that grants the holder permission to perform a specific operation on a storage bucket — typically a PUT (upload) or GET (download).

Your server generates the URL using its secret credentials, then hands it to the client. The client uploads directly to R2, and your server credentials are never exposed.

Client -> POST /api/projects/:id/upload  -> Server
Server -> PutObjectCommand (signed)      -> R2
Server -> { uploadUrl, key }             -> Client
Client -> PUT uploadUrl (with file)      -> R2 directly
Enter fullscreen mode Exit fullscreen mode

Setting Up the R2 Client

Cloudflare R2 is S3-compatible, so you can use the AWS SDK:

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Enter fullscreen mode Exit fullscreen mode
// src/lib/r2.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

export const r2Client = new S3Client({
  region: "auto",
  endpoint: process.env.R2_ENDPOINT, // https://<account-id>.r2.cloudflarestorage.com
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});
Enter fullscreen mode Exit fullscreen mode

The key difference from AWS S3: set region: "auto" and point endpoint at your R2 account URL.


Generating the Presigned URL

export async function getPresignedUploadUrl(
  filename: string,
  userId: string,
  expiresIn = 3600 // 1 hour
) {
  const ext = filename.split(".").pop() ?? "mp4";

  // Namespace by userId to avoid collisions
  const key = `uploads/${userId}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;

  const command = new PutObjectCommand({
    Bucket: process.env.R2_BUCKET_NAME!,
    Key: key,
    ContentType: `video/${ext}`,
  });

  const uploadUrl = await getSignedUrl(r2Client, command, { expiresIn });

  return { uploadUrl, key };
}
Enter fullscreen mode Exit fullscreen mode

A few things to note:

  • Namespace by useruploads/{userId}/... makes it easy to scope access per user later
  • Random key — prevents filename collisions and guessable URLs
  • expiresIn — keep this short. 1 hour is generous; 15 minutes is safer for most use cases

The API Route (Next.js 15 App Router)

// src/app/api/projects/[id]/upload/route.ts
import { auth } from "@clerk/nextjs/server";
import { supabaseAdmin } from "@/lib/supabase";
import { getPresignedUploadUrl } from "@/lib/r2";

export async function POST(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { userId } = await auth();
  if (!userId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { id } = await params;

  // Verify the project belongs to this user
  const { data: project, error } = await supabaseAdmin
    .from("projects")
    .select("id, user_id")
    .eq("id", id)
    .single();

  if (error || !project) {
    return Response.json({ error: "Project not found" }, { status: 404 });
  }
  if (project.user_id !== userId) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  const { filename } = await request.json();
  if (!filename) {
    return Response.json({ error: "filename is required" }, { status: 400 });
  }

  const { uploadUrl, key } = await getPresignedUploadUrl(filename, userId);

  return Response.json({ uploadUrl, key }, { status: 200 });
}
Enter fullscreen mode Exit fullscreen mode

Next.js 15 gotcha: params is now a Promise — you must await it. This trips up a lot of people migrating from Next.js 14.


The Client Upload

Once you have the presigned URL, uploading is a plain fetch PUT:

async function uploadFile(projectId: string, file: File) {
  // Step 1: Get the presigned URL from your API
  const res = await fetch(`/api/projects/${projectId}/upload`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ filename: file.name, contentType: file.type }),
  });

  const { uploadUrl, key } = await res.json();

  // Step 2: Upload directly to R2
  await fetch(uploadUrl, {
    method: "PUT",
    body: file,
    headers: { "Content-Type": file.type },
  });

  return key; // Store this key in your DB to reference the file later
}
Enter fullscreen mode Exit fullscreen mode

Your server never sees the file bytes. Clean.


Testing This with Vitest

// src/lib/__tests__/r2.test.ts
import { describe, it, expect, vi } from "vitest";

vi.mock("@aws-sdk/s3-request-presigner", () => ({
  getSignedUrl: vi.fn().mockResolvedValue("https://r2.example.com/signed-url"),
}));

describe("getPresignedUploadUrl", () => {
  it("returns a signed URL and key for a valid filename", async () => {
    const { getPresignedUploadUrl } = await import("../r2");
    const result = await getPresignedUploadUrl("video.mp4", "user_123");

    expect(result.uploadUrl).toContain("https://");
    expect(result.key).toMatch(/^uploads\/user_123\/.+\.mp4$/);
  });

  it("throws when filename is missing", async () => {
    const { getPresignedUploadUrl } = await import("../r2");
    await expect(getPresignedUploadUrl("", "user_123")).rejects.toThrow(
      "filename is required"
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

The key insight: mock getSignedUrl at the module level — you don't want real SDK calls in unit tests.


Security Considerations

Always verify ownership before minting a URL. The example above checks project.user_id !== userId before calling getPresignedUploadUrl. Without this check, any authenticated user could get an upload URL for someone else's project.

Store the key, not the URL. The presigned URL expires. Store the key in your database, and generate fresh presigned download URLs on demand.

Validate file types server-side. The client sends contentType, but don't trust it blindly. Check file extensions against an allowlist before minting the URL.


Summary

Presigned URLs add a small amount of complexity (two-step flow) but the tradeoff is worth it almost every time for file-heavy apps:

  • No bandwidth cost on your server
  • Scales without touching your API layer
  • R2's S3-compatible API means the AWS SDK just works — swap the endpoint and you're done

If you're building on Next.js 15 App Router with Clerk auth, the pattern above is the cleanest approach I've found.

Top comments (0)