Download PDF File on Button Click in ASP.NET(C# + JavaScript) – 3 Easy Ways

|

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.

FileResult Controller
JavaScript Fetch API
Direct Anchor Link

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.

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:

Invoice generation
Report exports
Contract downloads
Ticket / receipt PDFs
Shipping labels
Signed document
Three different methods — three different trade-offs. Use FileResult when a direct controller endpoint is all you need. Use Fetch API + blob when you need to pass auth headers or handle the response in JavaScript before downloading. Use a direct anchor link when the file is public and simplicity wins.

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

The cleanest approach. Your controller action returns a 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 TypeSourceHelper Method
PhysicalFileResultFile path on diskPhysicalFile()
FileContentResultByte array in memoryFile(byte[], …)
FileStreamResultAny StreamFile(stream, …)
VirtualFileResultwwwroot / static filesFile(virtualPath, …)

Return PDF from Physical File Path

C# — MVC Controller
[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 Response Headers
HTTP/1.1 200 OK
Content-Type:        application/pdf
Content-Disposition: attachment; filename=invoice-42.pdf
Content-Length:      84320

Return PDF from Byte Array (generated on the fly)

When you generate a PDF in memory — using a library like iTextSharpQuestPDF, or PdfSharpCore — return the byte array directly:

C# — FileContentResult from byte array
[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

C# — FileStreamResult
[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

+ Simple — one method call
+ Handles headers automatically
+ Works with files, byte arrays, streams
+ Built into ASP.NET Core

Cons

– Requires full page navigation or direct link
– 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

Fetches the PDF as a binary blob in JavaScript, creates a temporary object URL, and triggers a click on a hidden anchor. Essential when you need to send auth tokens or handle the response before saving.

When do you need this?

If your endpoint is protected by a JWT bearer token, you can’t use a simple link or form — the browser won’t attach the Authorization header. The Fetch API approach lets you add any headers you need before downloading.

Typical scenario: protected invoice download with Bearer token

User clicks “Download Invoice” on a dashboard. The API endpoint requires a valid JWT. A plain <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

  1. Fetch the endpoint — include any headers (Authorization, custom headers, etc.)
  2. Call response.blob() — reads the response body as a binary Blob object
  3. Create an object URL — URL.createObjectURL(blob) generates a temporary browser URL pointing to the blob
  4. Simulate a click — create a hidden <a>, set href + download, click it programmatically
  5. Revoke the URL — call URL.revokeObjectURL() to free memory
JavaScript — Fetch + Blob download
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

TypeScript — typed Fetch download
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

+ Works with protected endpoints (JWT, cookies)
+ No page navigation — stays on current page
+ Can show loading spinner during fetch
+ Can inspect response headers before saving

Cons

– Entire file buffered in browser memory
– Breaks for very large files (>500 MB)
– More code than a simple link

ASP.NET Download File — Simplest Method

An HTML anchor tag with a 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

HTML — direct link to static PDF
<!-- 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 View / HTML — link to controller
<!-- 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.

If your API is on a different domain (e.g. 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

+ Simplest possible implementation
+ No JavaScript required at all
+ Native browser download handling
+ Works with right-click → Save As

Cons

– Can’t attach custom auth headers
– 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

C# — PdfController.cs
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
        );
    }
}
Program.cs — Enable Static Files + CORS
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

HTML + JavaScript — index.html
<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

The browser receives a PDF and opens it in its built-in viewer instead of triggering a download dialog. This happens when Content-Disposition is set to inline instead of attachment.

FIX

When using 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:
C# — manual Content-Disposition
// ❌ 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

If 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

Always use application/pdf as the MIME type for PDF files:
C# — correct MIME type
// ❌ 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

Your Fetch call receives a JSON response like {"message": "Unauthorized"} instead of binary PDF data. Calling response.blob() on it gives you a broken file that can’t be opened.

fix

Always check response.ok and response.headers.get('Content-Type') before calling .blob():
JavaScript — validate before 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 now

 CORS Issues — Fetch API Blocked in Browser

Your Fetch call works in Postman but fails in the browser with: 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

Configure CORS in Program.cs and make sure the CORS middleware runs before routing:
C# — CORS configuration for PDF downloads
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"). 

 Without it, the browser receives the header but JavaScript can’t read it — so you can’t extract the server-suggested filename from the response.

Conclusion — When to Use Each ASP.NET PDF Download Method

MethodBest ForAuth HeadersComplexity
① FileResultDirect links, Razor pages, simple APIsCookies onlyLow
② Fetch + BlobSPAs, protected endpoints, JWT authAny headerMedium
③ Anchor tagPublic static files, simplest caseNoneMinimal

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.

0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x