Building Secure, Scalable Downloads with Cloudflare R2 + Next.js for Our “Free Website” Campaign

Building Secure, Scalable Downloads with Cloudflare R2 + Next.js for Our “Free Website” Campaign

September 1, 2025
MD Pabel

By the founder & developer at 3Zero Digital

We’re launching a new initiative at 3zerodigital: free websites for freelancers and f-commerce owners, a Google Maps scraper (1-year license), plus two free eBooks—Personal Branding and Website Security. To deliver these assets reliably through our Next.js site (3zerosite), we needed file storage that’s private, fast, global, and cost-effective.

We first considered hosting downloads via headless WooCommerce. It works, but for our use case, Cloudflare R2 was a better fit: S3-compatible APIs, private by default, and zero egress fees. Below is our end-to-end setup—copy-paste ready for teams building secure downloads with Next.js.


TL;DR Architecture

  • Files (ZIP/PDF/PNG/ZIP, etc.) live in a private Cloudflare R2 bucket.
  • We store only a fileKey (object path, e.g. templates/landing-kit-v1.zip) in our DB—never a public URL.
  • On click, the browser calls our Next.js API route:
    1. Verify session & entitlement (does this user “own” the file?).
    2. Mint a short-lived presigned URL.
    3. 302 redirect the browser to R2 (no proxying through our server).

Step 1 — Create the R2 Bucket and API Keys

  1. In the Cloudflare Dashboard → R2, create a bucket (e.g., my-prod-templates).
  2. Go to R2 → API Tokens and create an Account API token:
    • App token (read-only): Object Read on your bucket (used by Next.js to presign downloads).
    • Admin token (optional): Object Read & Write (for uploads/migrations).
  3. Note your Account ID. R2’s S3 endpoint looks like:https://<ACCOUNT_ID>.r2.cloudflarestorage.com

Cloudflare R2 API Tokens

.env

# .env.local
CF_R2_ACCOUNT_ID=xxxxxxxxxxxxxxxxxxxx
CF_R2_BUCKET=my-prod-templates
CF_R2_ACCESS_KEY_ID=AKIA... # app (read-only) token
CF_R2_SECRET_ACCESS_KEY=... # app (read-only) token

# optional (if you keep a separate admin token for uploads)
CF_R2_ADMIN_ACCESS_KEY_ID=...
CF_R2_ADMIN_SECRET_ACCESS_KEY=...

Step 2 — Install the SDK and Create an R2 Client

npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// lib/r2.ts
import { S3Client } from '@aws-sdk/client-s3';

export const r2 = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.CF_R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.CF_R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.CF_R2_SECRET_ACCESS_KEY!,
  },
});

Step 3 — Store fileKey in the Database (Prisma)

Add a fileKey to your content model (we use Template):

model Template {
  id          String   @id @default(cuid())
  name        String   @unique
  slug        String
  fileKey     String   // e.g. "templates/landing-kit-v1.zip"
  liveUrl     String
  description String?
  origPrice   Float
  salePrice   Float
  images      String[]
  deleted     Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  // ...
}

What is fileKey? It’s the object path inside your R2 bucket—not a URL and not the bucket name. Example: templates/decoupled-architecture.png. You decide it at upload time (dashboard, script, or CLI) and store that string in the DB.


Step 4 — Entitlement Check (Do They Have Access?)

We verify that the signed-in user owns the template via a paid or active order. Customize to your business rules.

// lib/entitlements.ts
import prisma from '@/../prisma/db';

export async function entitledOrderIdForTemplate(opts: {
  userId: string;
  templateId: string;
}) {
  const order = await prisma.order.findFirst({
    where: {
      userId: opts.userId,
      TemplateOrderItem: {
        some: { templateId: opts.templateId }
      },
      OR: [
        { paymentStatus: 'PAID' },
        { status: { in: ['CONFIRMED', 'IN_PROGRESS', 'COMPLETED', 'FREE'] } },
      ],
      NOT: {
        status: { in: ['CANCELLED', 'REFUNDED'] }
      },
    },
    select: { id: true },
  });

  return order?.id ?? null;
}

Step 5 — The Secure Download API (Presigned URL → 302 Redirect)

In Next.js App Router, params is a Promise—make sure to await it. We HEAD first to verify the key and content type, then mint a short-lived URL.

// app/api/downloads/template/[templateId]/route.ts
export const runtime = 'nodejs';

import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
import prisma from '@/../prisma/db';
import { entitledOrderIdForTemplate } from '@/lib/entitlements';
import { r2 } from '@/lib/r2';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import path from 'node:path';

function safe(s: string) {
  return s.replace(/[^a-z0-9\-_. ]/gi, '_');
}

export async function GET(
  _req: Request,
  ctx: { params: Promise<{ templateId: string }> }
) {
  const { templateId } = await ctx.params;
  const session = await auth.api.getSession({ headers: await headers() });
  const userId = session?.user?.id;

  if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const orderId = await entitledOrderIdForTemplate({ userId, templateId });
  if (!orderId) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });

  const tpl = await prisma.template.findUnique({
    where: { id: templateId },
    select: { name: true, fileKey: true },
  });

  if (!tpl?.fileKey) return NextResponse.json({ error: 'Not found' }, { status: 404 });

  // HEAD to verify the object & get MIME type
  const head = await r2.send(new HeadObjectCommand({
    Bucket: process.env.CF_R2_BUCKET!,
    Key: tpl.fileKey,
  })).catch(() => null);

  if (!head) return NextResponse.json({ error: 'File missing or access denied' }, { status: 404 });

  const ext = path.extname(tpl.fileKey) || '';
  const downloadName = (tpl.name ? safe(tpl.name) : path.basename(tpl.fileKey, ext)) + ext;
  const contentType = head.ContentType || 'application/octet-stream';

  const cmd = new GetObjectCommand({
    Bucket: process.env.CF_R2_BUCKET!,
    Key: tpl.fileKey,
    ResponseContentDisposition: `attachment; filename="${downloadName}"`,
    ResponseContentType: contentType,
  });

  const ttl = Number(process.env.DOWNLOAD_TTL_SECONDS ?? 60); // configurable
  const signed = await getSignedUrl(r2, cmd, { expiresIn: ttl });

  const res = NextResponse.redirect(signed, 302);
  res.headers.set('Cache-Control', 'no-store');
  return res;
}

Note on big files (e.g., 500 MB): This 302 → R2 approach works efficiently. If you see retries failing on slow networks, increase DOWNLOAD_TTL_SECONDS in your environment—no code changes required.


Step 6 — A Nicer “Download” Button (Spinner UX)

// components/dashboard/downloads/download-button.tsx
'use client';

import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Loader2, Download as DownloadIcon } from 'lucide-react';

export default function DownloadButton({ href }: { href: string }) {
  const [loading, setLoading] = React.useState(false);

  return (
    <Button
      size="sm"
      onClick={(e) => {
        e.preventDefault();
        setLoading(true);
        window.location.href = href; // browser handles the file
        setTimeout(() => setLoading(false), 8000); // best-effort fallback
      }}
      disabled={loading}
      aria-busy={loading}
    >
      {loading ? (
        <>
          <Loader2 className="mr-2 h-4 w-4 animate-spin" />
          Preparing…
        </>
      ) : (
        <>
          <DownloadIcon className="mr-2 h-4 w-4" />
          Download
        </>
      )}
    </Button>
  );
}

Use it in your Downloads page:

<DownloadButton href={`/api/downloads/template/${templateId}`} />

3Zero Digital Downloads


Why We Chose R2 Over Alternatives

  • Simplicity: One small API route; browser downloads directly from R2.
  • Security: Private bucket, no public file URLs, access controlled by our app.
  • Performance: We do not proxy or stream through Next.js—no server timeouts or memory spikes.
  • Cost: Zero egress fees; ideal for our campaign’s freebies and assets.

R2 Pricing


SEO Notes for This Post

  • Slug: /blog/cloudflare-r2-nextjs-secure-downloads
  • Meta title: Secure, Private Downloads in Next.js with Cloudflare R2 (Our 3zerodigital Setup)
  • Meta description: How 3zerodigital delivers secure downloads for our free website campaign using Cloudflare R2 and Next.js. Private bucket, presigned URLs, and copy-paste code.
  • Keywords: cloudflare r2 nextjs, s3 compatible r2, presigned url next.js, secure downloads next.js, private file delivery, prisma entitlement
  • Internal links: Link to the campaign landing page and the Downloads page.

FAQ

Can I keep WooCommerce in the stack?
Yes. If you already manage products/orders in Woo, keep it. Use this R2 route purely for file delivery to reduce egress cost and keep URLs private.

What about very large files?
The 302 → R2 flow scales. If users on slow networks report retries failing, increase DOWNLOAD_TTL_SECONDS (e.g., 900 for 15 minutes) in your env—no code changes.

How do we migrate existing public links?
Upload files to R2, copy the resulting object key (e.g., templates/myfile.zip), save it to Template.fileKey, and update the UI to call the secure API route.


Wrap-Up

For our “Free Website for Everyone” campaign, Cloudflare R2 + Next.js gives us a secure, fast, and inexpensive download pipeline. If you need private downloads at scale—without surprise egress fees—this pattern works out of the box.

About the Author

MD Pabel

MD Pabel

MD Pabel is the Founder and CEO of 3Zero Digital, a leading agency specializing in custom web development, WordPress security, and malware removal. With over 7+ Years years of experience, he has completed more than3200+ projects, served over 2300+ clients, and resolved4500+ cases of malware and hacked websites.