Sometimes you want a link that downloads a file instead of opening it in the browser. You're sharing a PDF, an image, or a zip archive and you want your users to save it to their device. Here's how to do it.
Before You Start: Your File Must Be Hosted Online
A question that comes up a lot is whether you can create a download link that points to a file on your own computer, something like C:\Users\me\Documents\report.pdf or /home/me/files/report.pdf. You can't. A link on a website can only point to files that are accessible on the internet through a URL. Your visitors have no way to reach files on your local hard drive.
Before you can create a download link, your file needs to be hosted somewhere publicly accessible:
- Your own web server - upload the file to your site's directory and link to it with a relative path like
/files/report.pdf - A cloud storage service - upload to Amazon S3, Cloudflare R2, or a service like Smmall Cloud and use the public URL
- A CDN or static hosting provider - services like Netlify, Vercel, or GitHub Pages can serve static files
Once your file has a public URL, you're ready to create a download link.
Part 1: HTML Download Links
The download Attribute
HTML5 added the download attribute to anchor tags. It tells the browser to download the linked file instead of navigating to it:
<a href="/files/report.pdf" download>Download Report</a>
When a user clicks this link, the browser saves report.pdf to their downloads folder instead of opening it in a new tab.
Custom Filenames
You can suggest a filename by giving the download attribute a value:
<a href="/files/report-q4-2026-final-v3.pdf" download="Q4-Report.pdf"> Download Report </a>
The browser saves the file as Q4-Report.pdf regardless of what it's called on the server.
The Same-Origin Limitation
There's a catch: the download attribute only works for same-origin URLs. If your file is hosted on a different domain (like a CDN or cloud storage bucket), the browser ignores the download attribute and navigates to the file instead.
<!-- This WON'T force a download because it's a different origin --> <a href="https://cdn.example.com/files/report.pdf" download> Download Report </a>
This is a security restriction in browsers. For cross-origin files, you need a server-side solution, which is what Part 2 covers.
Using download with Blob URLs
If you're generating files on the client side (exporting data as CSV, for example), you can create a blob URL and use the download attribute on it:
const data = "Name,Email\nJohn,john@example.com"; const blob = new Blob([data], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "contacts.csv"; a.click(); URL.revokeObjectURL(url);
This works because blob URLs are always same-origin.
Part 2: Forcing Downloads from S3 and R2
When your files live in Amazon S3 or Cloudflare R2, the download attribute won't help because the files are on a different origin. The fix is to set the Content-Disposition header on the file itself.
What is Content-Disposition?
Content-Disposition is an HTTP response header that tells the browser how to handle a file. It has two values:
inline- display the file in the browser (the default)attachment- download the file to disk
Content-Disposition: attachment; filename="report.pdf"
When a browser sees this header, it triggers a download regardless of the file type or which domain is serving it.
Setting Content-Disposition on S3
You can set Content-Disposition when uploading a file to S3:
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3"); const client = new S3Client({ region: "us-east-1" }); await client.send(new PutObjectCommand({ Bucket: "my-bucket", Key: "files/report.pdf", Body: fileBuffer, ContentType: "application/pdf", ContentDisposition: 'attachment; filename="report.pdf"', }));
Every request for this object will now trigger a download.
Setting Content-Disposition on Cloudflare R2
R2 uses the same S3-compatible API, so the code is the same:
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3"); const client = new S3Client({ region: "auto", endpoint: "https://<ACCOUNT_ID>.r2.cloudflarestorage.com", credentials: { accessKeyId: R2_ACCESS_KEY, secretAccessKey: R2_SECRET_KEY, }, }); await client.send(new PutObjectCommand({ Bucket: "my-bucket", Key: "files/report.pdf", Body: fileBuffer, ContentType: "application/pdf", ContentDisposition: 'attachment; filename="report.pdf"', }));
Using Presigned URLs with Content-Disposition
You might not want to set Content-Disposition permanently on an object. Say you want the same file to display inline on a preview page but download when someone clicks a download button. Presigned URLs let you override the response headers per request:
const { GetObjectCommand } = require("@aws-sdk/client-s3"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const command = new GetObjectCommand({ Bucket: "my-bucket", Key: "files/report.pdf", ResponseContentDisposition: 'attachment; filename="report.pdf"', }); const downloadUrl = await getSignedUrl(client, command, { expiresIn: 3600, });
This generates a temporary URL that forces a download. The same object can still be viewed inline through a regular URL or a presigned URL without the ResponseContentDisposition override.
This works the same way with both S3 and R2.
Using Content-Disposition with Cloudflare Workers
If you're serving files through a Cloudflare Worker (common with R2), you can set the header dynamically in the response:
export default { async fetch(request, env) { const url = new URL(request.url); const object = await env.MY_BUCKET.get(url.pathname.slice(1)); if (!object) { return new Response("Not Found", { status: 404 }); } const headers = new Headers(); headers.set("Content-Type", object.httpMetadata?.contentType || "application/octet-stream"); // Force download const filename = url.pathname.split("/").pop(); headers.set("Content-Disposition", `attachment; filename="${filename}"`); return new Response(object.body, { headers }); }, };
This gives you full control. You could check for a ?download query parameter and only set the attachment header when the user actually wants to download.
Summary
| Method | Works Cross-Origin? | Server Required? |
|---|---|---|
download attribute | No | No |
Content-Disposition on upload | Yes | Yes (at upload time) |
| Presigned URL with override | Yes | Yes (at URL generation) |
| Worker/proxy header injection | Yes | Yes (at serve time) |
For same-origin files, the download attribute is the simplest option. For files on S3 or R2, use Content-Disposition. You can set it at upload time, override it with presigned URLs, or inject it dynamically with a worker.





