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

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.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *