Learn how to download a PDF file on button click in ASP.NET using C# and JavaScript. Step-by-step tutorial with 3 easy working methods and code examples.
If you prefer video tutorials, you can watch the full example here showing how to download a PDF file on button click in ASP.NET using C# and JavaScript.
Table of Contents
When You Need to Download PDF on Button Click in ASP.NET
Server-side PDF downloads are one of those features that shows up in almost every business application. The implementation looks trivial on the surface, but the details — content-type headers, Content-Disposition, CORS policy, blob handling in JavaScript — trip up developers every time.
Here are the typical use cases where you need to trigger a PDF download from ASP.NET:
All examples use ASP.NET Core 6+ with Minimal APIs or MVC controllers. The JavaScript examples work in any modern browser.
Method 1 – Download PDF Using ASP.NET Controller FileResult
Return File from Controller in ASP.NET — FileResult
FileResult and ASP.NET handles all the headers for you. Works with files on disk, byte arrays, or streams.How FileResult Works
ASP.NET Core provides several FileResult subtypes depending on where your PDF lives. All of them set Content-Type: application/pdf and the Content-Disposition header that tells the browser to download rather than display the file.
| Return Type | Source | Helper Method |
|---|---|---|
| PhysicalFileResult | File path on disk | PhysicalFile() |
| FileContentResult | Byte array in memory | File(byte[], …) |
| FileStreamResult | Any Stream | File(stream, …) |
| VirtualFileResult | wwwroot / static files | File(virtualPath, …) |
Return PDF from Physical File Path
[ApiController]
[Route("api/[controller]")]
public class ReportsController : ControllerBase
{
[HttpGet]
[Route("download-invoice/{id}")]
public IActionResult DownloadInvoice(int id)
{
var filePath = Path.Combine(
Directory.GetCurrentDirectory(),
"Files", $"invoice-{id}.pdf"
);
if (!System.IO.File.Exists(filePath))
return NotFound($"Invoice {id} not found.");
// Sets Content-Type and Content-Disposition automatically
return PhysicalFile(
filePath,
"application/pdf",
$"invoice-{id}.pdf" // <-- download filename shown in browser
);
}
}HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename=invoice-42.pdf
Content-Length: 84320Return PDF from Byte Array (generated on the fly)
When you generate a PDF in memory — using a library like iTextSharp, QuestPDF, or PdfSharpCore — return the byte array directly:
[HttpGet("download-report")]
public IActionResult DownloadReport()
{
// Generate your PDF — using QuestPDF, iText, etc.
byte[] pdfBytes = GenerateReportPdf();
return File(
pdfBytes,
"application/pdf",
"monthly-report.pdf"
);
}
private byte[] GenerateReportPdf()
{
// Placeholder — replace with your PDF library
using var stream = new MemoryStream();
// document.GeneratePdf(stream);
return stream.ToArray();
}Return PDF from Stream
[HttpGet("download-stream")]
public IActionResult DownloadStream()
{
var stream = new FileStream(
"/data/reports/q1.pdf",
FileMode.Open,
FileAccess.Read
);
// ASP.NET Core disposes the stream after the response is sent
return File(stream, "application/pdf", "q1-report.pdf");
}Pros
+ Handles headers automatically
+ Works with files, byte arrays, streams
+ Built into ASP.NET Core
Cons
– No progress indication without extra work
– Can’t show custom UI before download starts
Method 2 – Download PDF Using JavaScript Fetch API
JavaScript Download File from API — Fetch + Blob
When do you need this?
Authorization header. The Fetch API approach lets you add any headers you need before downloading.Typical scenario: protected invoice download with Bearer token
<a href> would redirect unauthenticated. The Fetch approach attaches the token, receives the binary response, and triggers the browser’s download dialog — all without leaving the page.The Fetch + Blob Pattern Explained
- Fetch the endpoint — include any headers (Authorization, custom headers, etc.)
- Call
response.blob()— reads the response body as a binary Blob object - Create an object URL —
URL.createObjectURL(blob)generates a temporary browser URL pointing to the blob - Simulate a click — create a hidden
<a>, sethref+download, click it programmatically - Revoke the URL — call
URL.revokeObjectURL()to free memory
async function downloadPdf(invoiceId) {
const token = getAuthToken(); // your JWT or session token
try {
const response = await fetch(`/api/reports/download-invoice/${invoiceId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/pdf'
}
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Download failed: ${error}`);
}
// Step 2 — read as binary blob
const blob = await response.blob();
// Step 3 — create a temporary object URL
const url = URL.createObjectURL(blob);
// Step 4 — trigger browser download dialog
const link = document.createElement('a');
link.href = url;
link.download = `invoice-${invoiceId}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Step 5 — free memory
URL.revokeObjectURL(url);
} catch (err) {
console.error('PDF download error:', err);
showErrorToast(err.message);
}
}
// Wire up to your button
document
.getElementById('download-btn')
.addEventListener('click', () => downloadPdf(42));TypeScript Version
async function downloadPdf(invoiceId: number): Promise<void> {
const response: Response = await fetch(
`/api/reports/download-invoice/${invoiceId}`,
{ headers: { 'Authorization': `Bearer ${getToken()}` } }
);
if (!response.ok) throw new Error('Download failed');
const blob: Blob = await response.blob();
const url: string = URL.createObjectURL(blob);
const link: HTMLAnchorElement = document.createElement('a');
Object.assign(link, { href: url, download: `invoice-${invoiceId}.pdf` });
link.click();
URL.revokeObjectURL(url);
}Pros
+ No page navigation — stays on current page
+ Can show loading spinner during fetch
+ Can inspect response headers before saving
Cons
– Breaks for very large files (>500 MB)
– More code than a simple link
Method 3 – Download PDF Using Direct Link (Anchor Tag)
ASP.NET Download File — Simplest Method
download attribute pointing to a public file or a controller endpoint. Zero JavaScript required. Works for static files and unauthenticated API routes.Static File in wwwroot
<!-- File lives at wwwroot/files/terms.pdf -->
<a href="/files/terms.pdf"
download="terms-and-conditions.pdf">
Download Terms & Conditions
</a>
<!-- Styled as a button -->
<a href="/files/terms.pdf"
download
class="btn btn-primary">
📥 Download PDF
</a>Link to a Controller Endpoint
You can also point the anchor directly at your FileResult controller action. The browser will follow the link, receive the file response, and trigger the download dialog — no JavaScript needed:
<!-- Razor syntax -->
<a asp-controller="Reports"
asp-action="DownloadInvoice"
asp-route-id="@Model.InvoiceId"
download>
Download Invoice #@Model.InvoiceId
</a>
<!-- Plain HTML -->
<a href="/api/reports/download-invoice/42" download>
Download Invoice #42
</a>The download attribute only works same-origin.
api.example.com serving a page on app.example.com), the browser ignores the download attribute and opens the file inline. Use the Fetch + blob approach for cross-origin downloads.Pros
+ No JavaScript required at all
+ Native browser download handling
+ Works with right-click → Save As
Cons
– Cross-origin
download attribute is ignored– No JS control over the download flow
Full Working Example Download PDF File on Button Click (ASP.NET Core + JavaScript)
This example combines all three methods into one runnable project — a controller with three endpoints and an HTML page with buttons for each download strategy.
ASP.NET Core Controller — All Three Endpoints
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class PdfController : ControllerBase
{
private readonly IWebHostEnvironment _env;
public PdfController(IWebHostEnvironment env) => _env = env;
// ① FileResult — returns file from disk
[HttpGet("invoice/{id:int}")]
public IActionResult GetInvoice(int id)
{
var path = Path.Combine(_env.ContentRootPath, "Pdfs", $"invoice-{id}.pdf");
if (!System.IO.File.Exists(path))
return NotFound(new { message = $"Invoice {id} not found" });
return PhysicalFile(path, "application/pdf", $"invoice-{id}.pdf");
}
// ② Fetch API — protected endpoint (requires auth in real apps)
[HttpGet("report")]
public IActionResult GetReport()
{
byte[] pdfBytes = GenerateSamplePdf();
return File(pdfBytes, "application/pdf", "monthly-report.pdf");
}
// ③ Direct link — serves from wwwroot/pdfs/
// (handled by ASP.NET static file middleware — no action needed)
private byte[] GenerateSamplePdf()
{
// Replace with QuestPDF, iText7, PdfSharpCore, etc.
// Returning a minimal valid PDF byte sequence here:
return System.Text.Encoding.ASCII.GetBytes(
"%PDF-1.4\n%EOF" // minimal placeholder
);
}
}var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy
.WithOrigins("https://yourfrontend.com")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
var app = builder.Build();
app.UseCors();
app.UseStaticFiles(); // serves wwwroot/pdfs/*.pdf for Method 3
app.UseAuthorization();
app.MapControllers();
app.Run();HTML + JavaScript — All Three Buttons
<body>
<!-- Method 1: Navigate to controller endpoint -->
<a href="/api/pdf/invoice/42">
<button>① Download via FileResult</button>
</a>
<!-- Method 2: Fetch API + blob -->
<button id="fetch-btn">② Download via Fetch API</button>
<!-- Method 3: Direct anchor link -->
<a href="/pdfs/sample.pdf" download="sample.pdf">
<button>③ Download via Direct Link</button>
</a>
<script>
document
.getElementById('fetch-btn')
.addEventListener('click', async () => {
const btn = document.getElementById('fetch-btn');
btn.textContent = 'Downloading...';
btn.disabled = true;
try {
const res = await fetch('/api/pdf/report');
if (!res.ok) throw new Error('Server error');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = Object.assign(
document.createElement('a'),
{ href: url, download: 'monthly-report.pdf' }
);
a.click();
URL.revokeObjectURL(url);
} catch (e) {
alert('Download failed: ' + e.message);
} finally {
btn.textContent = '② Download via Fetch API';
btn.disabled = false;
}
});
</script>
</body>Common Problems When Downloading PDF Files in ASP.NET
These are the problems that show up in production — usually after everything worked fine in development.
File Opens in Browser Instead of Downloading
Content-Disposition is set to inline instead of attachment.FIX
PhysicalFile() or File(), passing a filename as the third argument automatically sets Content-Disposition: attachment. If you’re setting headers manually, make sure you use attachment:// ❌ Wrong — browser will try to display the PDF inline
Response.Headers.Add("Content-Disposition", "inline; filename=report.pdf");
// ✅ Correct — browser will download the file
Response.Headers.Add("Content-Disposition", "attachment; filename=report.pdf");
// ✅ Best — use the built-in helper (handles encoding)
return PhysicalFile(path, "application/pdf", "report.pdf");Wrong Content-Type — Browser Doesn't Recognise the File
Content-Type is set to application/octet-stream or omitted entirely, the browser downloads the file but saves it without an extension or with a generic name. Some browsers may refuse to open it.FIX
application/pdf as the MIME type for PDF files:// ❌ Wrong — generic binary type loses PDF association
return File(bytes, "application/octet-stream", "report.pdf");
// ✅ Correct
return File(bytes, "application/pdf", "report.pdf");File Download Returns JSON Error Instead of PDF
{"message": "Unauthorized"} instead of binary PDF data. Calling response.blob() on it gives you a broken file that can’t be opened.fix
response.ok and response.headers.get('Content-Type') before calling .blob():const response = await fetch('/api/pdf/report');
if (!response.ok) {
// Parse error JSON, don't call .blob()
const err = await response.json();
throw new Error(err.message ?? 'Download failed');
}
const contentType = response.headers.get('Content-Type');
if (!contentType?.includes('application/pdf')) {
throw new Error(`Unexpected response type: ${contentType}`);
}
const blob = await response.blob(); // safe to call nowCORS Issues — Fetch API Blocked in Browser
Access to fetch at '…' from origin '…' has been blocked by CORS policy. This means your ASP.NET app isn’t returning the required CORS headers.FIX
Program.cs and make sure the CORS middleware runs before routing:builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
{
policy
.WithOrigins("https://app.yourdomain.com")
.AllowAnyHeader()
.AllowAnyMethod()
// Required for the browser to expose Content-Disposition
.WithExposedHeaders("Content-Disposition");
});
});
// CORS must come before UseRouting / MapControllers
app.UseCors("AllowFrontend");
app.MapControllers();Don’t forget WithExposedHeaders("Content-Disposition").
Conclusion — When to Use Each ASP.NET PDF Download Method
| Method | Best For | Auth Headers | Complexity |
|---|---|---|---|
| ① FileResult | Direct links, Razor pages, simple APIs | Cookies only | Low |
| ② Fetch + Blob | SPAs, protected endpoints, JWT auth | Any header | Medium |
| ③ Anchor tag | Public static files, simplest case | None | Minimal |
All three methods ultimately do the same thing — deliver a PDF to the user’s browser — but they differ in who controls the download flow. The anchor tag delegates everything to the browser. The FileResult controller puts ASP.NET in charge. The Fetch API puts JavaScript in charge, which is the right choice whenever you need auth headers, a loading state, or any logic between “button clicked” and “file saved.”
Start with the simplest method that meets your requirements. If your file is public and static, use an anchor tag. If you’re returning a file from a controller in an MVC app, use FileResult. If you’re building a React or Vue SPA with JWT authentication, reach for Fetch + blob. Don’t over-engineer it — but do handle the common failure modes around Content-Disposition, MIME types, and CORS before you ship.
