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:
- Verify session & entitlement (does this user “own” the file?).
- Mint a short-lived presigned URL.
- 302 redirect the browser to R2 (no proxying through our server).
Step 1 — Create the R2 Bucket and API Keys
- In the Cloudflare Dashboard → R2, create a bucket (e.g.,
my-prod-templates
). - 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).
- Note your Account ID. R2’s S3 endpoint looks like:
https://<ACCOUNT_ID>.r2.cloudflarestorage.com
.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}`} />
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.
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.