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
Build it step by step.
-
1Install the NuGet packages
SelectPdf.Universalis the library. The native package carries the Chromium engine for one platform: add the one for the platform you run on.dotnet add package SelectPdf.Universal dotnet add package SelectPdf.Universal.Native.win-x64
dotnet add package SelectPdf.Universal dotnet add package SelectPdf.Universal.Native.linux-x64
dotnet add package SelectPdf.Universal dotnet add package SelectPdf.Universal.Native.linux-arm64
dotnet add package SelectPdf.Universal dotnet add package SelectPdf.Universal.Native.osx-arm64
-
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.cswith the code below.dotnet new web -n PdfSample cd PdfSample
-
3Map a POST endpoint
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
PdfRequestrecord, and theCancellationTokenis the request's: it fires when the caller disconnects. -
4Create a converter for the request
HtmlToPdf converter = new HtmlToPdf(); converter.Options.PdfPageSize = PdfPageSize.A4; converter.Options.DenyLocalFileAccess = true; // the HTML comes from the caller: no file:// accessOne
HtmlToPdfper request: a converter runs one conversion at a time.DenyLocalFileAccessstops the HTML from reading files on the server throughfile://addresses, which matters when the HTML comes from the caller. -
5Convert without blocking a thread
PdfDocument doc = await converter.ConvertHtmlStringAsync(request.Html, cancel); byte[] pdf = doc.Save(); doc.Close();ConvertHtmlStringAsyncrenders the HTML and returns thePdfDocument.Save()without a file name returns the PDF as bytes. -
6Return it as a download
return Results.File(pdf, "application/pdf", "document.pdf"); }); app.Run(); record PdfRequest(string Html);
Results.Filesends the bytes with theapplication/pdfcontent type; the file name makes the browser download it asdocument.pdf. Leave the name out to show the PDF in the browser instead. -
7Run it
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.pdfThe request returns
document.pdf. Without a license key, every page carries a trial watermark; setGlobalProperties.LicenseKeybefore the first conversion to remove it.
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.
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.
Invoice.razor
The invoice template the variation renders: plain HTML and CSS with Razor parameters.
@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);
}
The PDF it produces.
Download document.pdf ↓ the file this sample produced, unedited
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.
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();
}
}
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.
- 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
Questions, answered.
Short answers about this guide. The SelectPdf.Universal documentation covers every class and option.