§HOW-TO GUIDE · C# · .NET 8 & 10

Return a PDF from an ASP.NET Core endpoint in C#

Convert inside the endpoint with ConvertHtmlStringAsync, then return the bytes with Results.File. The conversion holds no thread while the page renders, and each request gets its own converter.

Compiled and run: Linux ARM64 · Linux x64 · macOS ARM64 · Windows x64

Video walkthrough · 3:23 · Watch on YouTube · Short · 1:00
§STEP BY STEP

Build it step by step.

  1. 1Install the NuGet packages

    SelectPdf.Universal is the library. The native package carries the Chromium engine for one platform: add the one for the platform you run on.

    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.win-x64
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.linux-x64
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.linux-arm64
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.osx-arm64
  2. 2Create a web app

    Any .NET 8 or .NET 10 project works. To follow along, create an empty ASP.NET Core app, run the install commands above inside its folder, then replace Program.cs with the code below.

    terminal
    dotnet new web -n PdfSample
    cd PdfSample
  3. 3Map a POST endpoint

    Program.cs · lines 1–7
    using SelectPdf.Universal;
    
    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    
    // POST /pdf with {"html": "..."} returns the HTML rendered as a PDF download
    app.MapPost("/pdf", async (PdfRequest request, CancellationToken cancel) =>

    A minimal API app with one endpoint. The JSON body binds to the PdfRequest record, and the CancellationToken is the request's: it fires when the caller disconnects.

  4. 4Create a converter for the request

    Program.cs · lines 9–11
        HtmlToPdf converter = new HtmlToPdf();
        converter.Options.PdfPageSize = PdfPageSize.A4;
        converter.Options.DenyLocalFileAccess = true;   // the HTML comes from the caller: no file:// access

    One HtmlToPdf per request: a converter runs one conversion at a time. DenyLocalFileAccess stops the HTML from reading files on the server through file:// addresses, which matters when the HTML comes from the caller.

  5. 5Convert without blocking a thread

    Program.cs · lines 13–15
        PdfDocument doc = await converter.ConvertHtmlStringAsync(request.Html, cancel);
        byte[] pdf = doc.Save();
        doc.Close();

    ConvertHtmlStringAsync renders the HTML and returns the PdfDocument. Save() without a file name returns the PDF as bytes.

  6. 6Return it as a download

    Program.cs · lines 17–22
        return Results.File(pdf, "application/pdf", "document.pdf");
    });
    
    app.Run();
    
    record PdfRequest(string Html);

    Results.File sends the bytes with the application/pdf content type; the file name makes the browser download it as document.pdf. Leave the name out to show the PDF in the browser instead.

  7. 7Run it

    terminal
    dotnet run
    
    # in a second terminal, with the address dotnet run prints:
    curl -X POST http://localhost:5000/pdf -H "Content-Type: application/json" \
         -d '{"html":"<h1>Quote #2207</h1>"}' -o document.pdf

    The request returns document.pdf. Without a license key, every page carries a trial watermark; set GlobalProperties.LicenseKey before the first conversion to remove it.

§COMPLETE PROGRAM

The complete Program.cs.

This is the exact file our samples pipeline compiled and ran on Linux ARM64 and Linux x64 and macOS ARM64 and Windows x64 to produce the output on this page.

Program.cs
using SelectPdf.Universal;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// POST /pdf with {"html": "..."} returns the HTML rendered as a PDF download
app.MapPost("/pdf", async (PdfRequest request, CancellationToken cancel) =>
{
    HtmlToPdf converter = new HtmlToPdf();
    converter.Options.PdfPageSize = PdfPageSize.A4;
    converter.Options.DenyLocalFileAccess = true;   // the HTML comes from the caller: no file:// access

    PdfDocument doc = await converter.ConvertHtmlStringAsync(request.Html, cancel);
    byte[] pdf = doc.Save();
    doc.Close();

    return Results.File(pdf, "application/pdf", "document.pdf");
});

app.Run();

record PdfRequest(string Html);

Open the project on GitHub → this program and the variations below, ready to dotnet run

§SUPPORTING FILES

Supporting files.

Invoice.razor

The invoice template the variation renders: plain HTML and CSS with Razor parameters.

Invoice.razor
@namespace PdfSample
<html>
<head>
    <style>
        body { font-family: sans-serif; margin: 40px; }
        table { width: 100%; border-collapse: collapse; }
        td, th { border-bottom: 1px solid #ddd; padding: 8px; text-align: left; }
        .total { font-weight: bold; }
    </style>
</head>
<body>
    <h1>Invoice #@Number</h1>
    <p>Billed to @Customer</p>
    <table>
        <tr><th>Item</th><th>Amount</th></tr>
        @foreach (var line in Lines)
        {
            <tr><td>@line.Item</td><td>@line.Amount.ToString("C")</td></tr>
        }
        <tr class="total"><td>Total</td><td>@Lines.Sum(l => l.Amount).ToString("C")</td></tr>
    </table>
</body>
</html>

@code {
    [Parameter] public int Number { get; set; }
    [Parameter] public string Customer { get; set; } = "";
    [Parameter] public List<InvoiceLine> Lines { get; set; } = [];

    public record InvoiceLine(string Item, decimal Amount);
}
§OUTPUT

The PDF it produces.

First page of document.pdf, the PDF this sample produces
document.pdf, page 1

Download document.pdf ↓ the file this sample produced, unedited

§VARIATIONS

Variations you will need next.

Each variation is a separate file in the same project, compiled and run with the program above.

Render a Razor component, such as an invoice, to PDF

HtmlRenderer renders a Razor component to an HTML string outside of any HTTP request, so Invoice.razor (below) is the template and its parameters are the data. In an endpoint, take HtmlRenderer from dependency injection after builder.Services.AddRazorComponents(), render the component, and convert the string as above.

RazorInvoice.cs
using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using PdfSample;   // Invoice.razor
using SelectPdf.Universal;

static class RazorInvoice
{
    public static async Task Run()
    {
        CultureInfo.CurrentCulture = new CultureInfo("en-US");

        // render the Invoice.razor component to an HTML string, no browser or HTTP request involved
        var services = new ServiceCollection().AddLogging().BuildServiceProvider();
        await using var renderer = new HtmlRenderer(services, services.GetRequiredService<ILoggerFactory>());

        string html = await renderer.Dispatcher.InvokeAsync(async () =>
        {
            var parameters = ParameterView.FromDictionary(new Dictionary<string, object?>
            {
                ["Number"] = 1042,
                ["Customer"] = "Contoso Ltd",
                ["Lines"] = new List<Invoice.InvoiceLine> { new("Consulting", 1200m), new("Support plan", 300m) },
            });
            HtmlRootComponent output = await renderer.RenderComponentAsync<Invoice>(parameters);
            return output.ToHtmlString();
        });

        // then convert it like any other HTML string
        HtmlToPdf converter = new HtmlToPdf();
        PdfDocument doc = await converter.ConvertHtmlStringAsync(html);
        doc.Save("invoice-1042.pdf");
        doc.Close();
    }
}
§TROUBLESHOOTING

Common problems and fixes.

The browser shows the PDF instead of downloading it

Results.File with a file name sends Content-Disposition: attachment. Without a name, the browser opens the PDF inline.

Conversions queue up under load

Conversions run in parallel up to HtmlToPdfOptions.MaximumConcurrentConversions; the rest wait their turn in order. Size the server for the number of conversions you expect at once.

The HTML comes from users

Keep DenyLocalFileAccess on, and set Options.JavaScriptEnabled = false when the pages need no scripts. The security recommendations in the documentation list the rest.

§GOOD TO KNOW

Good to know.

  • An HtmlToPdf object runs one conversion at a time: create a converter for each conversion, as the endpoint does per request. Source
  • While a page loads and renders, an asynchronous conversion holds no thread, so many conversions can be in flight at once. Source
  • When the HTML comes from users, disable access to local files; GlobalProperties.ForceDenyLocalFileAccess does it for every conversion. Source
?FAQ

Questions, answered.

Short answers about this guide. The SelectPdf.Universal documentation covers every class and option.

Convert the HTML to a PdfDocument, call Save() to get the bytes, and return Results.File(bytes, "application/pdf", "name.pdf") from a minimal API endpoint, or File(...) from a controller action.
Yes. ConvertHtmlStringAsync and ConvertUrlAsync hold no thread while the page renders, and they accept a CancellationToken, so a request the caller abandons stops converting.
Yes. Render the component to an HTML string with HtmlRenderer, then convert the string with ConvertHtmlStringAsync. The variation on this page renders an invoice that way.
No. An HtmlToPdf object runs one conversion at a time, so create one per request; it is a lightweight object.