HTML to PDF in C#: From Razor Markup to a Streamed Invoice
Blog / HTML to PDF
HTML to PDF

HTML to PDF in C#: From Razor Markup to a Streamed Invoice

Demetra Theodorou · Oct 21, 2026

Turn Razor markup or any HTML into a PDF from C# and stream it from an ASP.NET Core endpoint. Page setup, running headers, data templates and error handling, with every sample run before publishing.

If you write C#, you have probably already tried to turn a Razor view or a chunk of HTML into a PDF and discovered that .NET has no renderer of its own. This tutorial walks the whole path from one URL conversion to an ASP.NET Core endpoint that streams invoices to a browser, with page setup, running headers, data-driven templates, pages that need JavaScript to finish, and the failure cases.

The PDF itself is produced by the HTML to PDF API, so Chrome runs on PDFGate's servers and your application only exchanges HTTP messages with it. Each block of code below was built and executed before the page went live, and both ASP.NET endpoints answered real curl requests.

Key takeaways

  • Two async calls do the work: GeneratePdfAsync renders, GetFileAsync downloads a Stream. No browser or PDF library in your deployment.
  • Set PageSizeType explicitly, the default is Ledger. Margins are CSS lengths, headers and footers are their own HTML with page-number classes.
  • A chart or a fetched table is only captured if the renderer waits for it. Have the page add a marker element and name it in WaitForSelector.
  • In ASP.NET Core, register one PdfGateClient as a singleton and return the stream with Results.File, or hand back a pre-signed link.

The options in .NET

.NET has no built-in HTML renderer, so every approach brings a browser engine along in some form. The common choices:

  • wkhtmltopdf wrappers such as DinkToPdf and Rotativa. They are easy to start with but depend on an engine that was archived in 2023, so modern CSS such as grid and flexbox is unreliable and there are no security updates.
  • PuppeteerSharp or Playwright for .NET. Real Chrome output, but you download a browser of a few hundred megabytes onto every server, keep it patched, and give it memory. Fine for a single app, painful across a fleet or in a container with a small image.
  • Commercial libraries such as IronPDF, Syncfusion or Aspose. Good output, but a per-developer or per-server licence and a large dependency.
  • A conversion API.Your process stays free of rendering engines and licences, and what comes back is Chrome's print output. You pay with one network round trip per document and a subscription.

What follows uses the API. The Puppeteer comparison is the place to look if you are deciding between that and hosting Chrome in your own cluster.

Install the .NET SDK

PdfGate.net is built for .NET Standard 2.0, which makes it usable from .NET 6, 8 and 10 and from .NET Framework 4.6.1 upwards. The package pulls in System.Text.Json and nothing else.

1dotnet add package PdfGate.net

Create a key in the dashboard. The prefix decides the environment. A test_ key sends every request to the sandbox, where output is watermarked and free, and a live_ key goes to production. Keep the key out of source and read it from PDFGATE_API_KEY or user secrets, as the samples do.

Convert a URL to PDF

A conversion is two awaits. GeneratePdfAsync renders the page and hands back a PdfGateDocumentResponse whose Id identifies the stored file. GetFileAsync opens that file as a Stream.

1using PdfGate.net;2using PdfGate.net.Models;34using var client = new PdfGateClient(5    Environment.GetEnvironmentVariable("PDFGATE_API_KEY")!);67PdfGateDocumentResponse page = await client.GeneratePdfAsync(new GeneratePdfRequest8{9    Url = "https://en.wikipedia.org/wiki/Portable_Document_Format",10    PageSizeType = GeneratePdfPageSizeType.A4,11});1213await using (Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = page.Id }))14await using (var file = File.Create("wikipedia.pdf"))15{16    await pdf.CopyToAsync(file);17}18Console.WriteLine($"URL -> {page.Id} ({page.Size} bytes)");
  • Only public URLs can be rendered. Anything on localhost or a private network comes back as a 400 without a render attempt, so for local pages send the HTML instead.
  • Ledger is what you get when PageSizeType is left unset, which is rarely intended. The enum lists A0 to A6, Letter, Legal, Ledger and Tabloid, and Width plus Height in pixels handle custom sizes.
  • Wrap the stream and the output file in await using so both are released when the copy finishes. The 23 page Wikipedia article is about 1.4 MB, and Size tells you the byte count before you download.
  • PdfGateClient owns an HttpClient and implements IDisposable. One instance per application is the right shape, and every method has an overload that accepts a CancellationToken.

Convert an HTML string

When the document is yours, skip the URL and put the markup in Html. A raw string literal keeps a whole HTML file readable inside C#, and a <style> block is the easiest way to carry the CSS with it. Public stylesheets, images and web fonts referenced from the markup are fetched during the render.

1const string invoiceHtml = """2    <!DOCTYPE html>3    <html>4    <head>5      <meta charset="utf-8">6      <style>7        body { font-family: Helvetica, Arial, sans-serif; color: #1f2933; }8        h1 { color: #0055d4; }9        table { width: 100%; border-collapse: collapse; margin-top: 24px; }10        th { background: #0055d4; color: white; text-align: left; padding: 8px; }11        td { padding: 8px; border-bottom: 1px solid #e4e7eb; }12        .total { text-align: right; font-size: 18px; margin-top: 16px; }13      </style>14    </head>15    <body>16      <h1>Invoice #2026-0142</h1>17      <p>Billed to Acme GmbH, 21 October 2026</p>18      <table>19        <tr><th>Item</th><th>Qty</th><th>Price</th></tr>20        <tr><td>API plan, Business</td><td>1</td><td>EUR 79.00</td></tr>21        <tr><td>Extra documents</td><td>2,000</td><td>EUR 20.00</td></tr>22      </table>23      <p class="total">Total EUR 99.00</p>24    </body>25    </html>26    """;2728PdfGateDocumentResponse invoice = await client.GeneratePdfAsync(new GeneratePdfRequest29{30    Html = invoiceHtml,31    PageSizeType = GeneratePdfPageSizeType.A4,32    Margin = new GeneratePdfPageMargin { Top = "20mm", Bottom = "20mm", Left = "15mm", Right = "15mm" },33    PrintBackground = true,34});3536await using (Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = invoice.Id }))37await using (var file = File.Create("invoice.pdf"))38{39    await pdf.CopyToAsync(file);40}41Console.WriteLine($"HTML -> {invoice.Id} ({invoice.Size} bytes)");
  • GeneratePdfPageMargin holds four strings in CSS units, so millimetres, inches and pixels are all valid.
  • Chrome strips background colours in print unless told otherwise, which turns a blue table header white. PrintBackground = true keeps them.
  • For a wide table, set Orientation to Landscape. For a page that only looks right with its screen stylesheet, set EmulateMediaType to Screen.
  • Raw string literals arrived with C# 11, so a project on .NET 6 needs a verbatim string or an embedded resource instead.

Every layout parameter is covered in page size, margins and orientation.

Headers, footers and page numbers

Running headers and footers are separate HTML fragments in Header and Footer, repeated on each page. The renderer looks for five class names inside them and fills in the content: date, title, url, pageNumber and totalPages. Reserve space with Margin.Top and Margin.Bottom, because a header with no top margin has nowhere to go and the request is refused.

1PdfGateDocumentResponse report = await client.GeneratePdfAsync(new GeneratePdfRequest2{3    Url = "https://en.wikipedia.org/wiki/Portable_Document_Format",4    PageSizeType = GeneratePdfPageSizeType.A4,5    Header = """6        <div style="width: 100%; font-size: 10px; padding: 0 15mm; color: #52606d;">7          Portable Document Format, exported <span class="date"></span>8        </div>9        """,10    Footer = """11        <div style="width: 100%; font-size: 10px; padding: 0 15mm; text-align: right; color: #52606d;">12          Page <span class="pageNumber"></span> of <span class="totalPages"></span>13        </div>14        """,15    Margin = new GeneratePdfPageMargin { Top = "25mm", Bottom = "20mm" },16});1718await using (Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = report.Id }))19await using (var file = File.Create("header-footer.pdf"))20{21    await pdf.CopyToAsync(file);22}23Console.WriteLine($"Header/footer -> {report.Id}");
  • Chrome renders header templates at a very small default size, so declare font-size inline.
  • The fragment is rendered as its own document. Styles from the page body do not reach it, which is why the sample inlines everything.
  • Give the fragment horizontal padding equal to the page margins. It spans the full paper width, not the content width.

Logos, background colours and web fonts inside headers follow extra rules, described in headers and footers.

Templates with your own data

An invoice, a contract or a report starts as objects in your code. The simplest template in C# is an interpolated raw string, with string.Join for repeating rows and the usual format specifiers for numbers and dates.

1var order = new Order("2026-0142", "Acme GmbH",2[3    new OrderLine("API plan, Business", 1, 79.00m),4    new OrderLine("Extra documents", 2000, 0.01m),5]);67string rows = string.Join("\n", order.Lines.Select(line =>8    $"<tr><td>{line.Description}</td><td>{line.Quantity:N0}</td><td>EUR {line.Quantity * line.UnitPrice:N2}</td></tr>"));910string html = $"""11    <h1>Invoice #{order.Number}</h1>12    <p>Billed to {order.Customer}</p>13    <table>14      <tr><th>Item</th><th>Qty</th><th>Amount</th></tr>15      {rows}16    </table>17    <p>Total EUR {order.Lines.Sum(l => l.Quantity * l.UnitPrice):N2}</p>18    """;1920PdfGateDocumentResponse templated = await client.GeneratePdfAsync(new GeneratePdfRequest21{22    Html = html,23    PageSizeType = GeneratePdfPageSizeType.A4,24});2526await using (Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = templated.Id }))27await using (var file = File.Create("template.pdf"))28{29    await pdf.CopyToAsync(file);30}31Console.WriteLine($"Template -> {templated.Id}");3233record Order(string Number, string Customer, List<OrderLine> Lines);34record OrderLine(string Description, int Quantity, decimal UnitPrice);
  • Anything that came from a user must be encoded with WebUtility.HtmlEncode before it goes into the string. A customer name containing <script> is otherwise rendered as markup.
  • For larger documents, use the templating you already have. In ASP.NET Core, render a Razor view to a string with IRazorViewEngine and send that. Scriban and Handlebars.Net work the same way outside a web project.
  • Number and date formatting follow the current culture. Set CultureInfo explicitly if the server and the reader are in different countries.

For anything bigger than a receipt, the markup belongs in a Razor view, the same one that could serve the invoice as a web page. ASP.NET Core has no built-in way to render a view to a string from a minimal API, but IRazorViewEngine makes it a short service. The view takes a typed model, and @ output is HTML-encoded, so customer data cannot inject markup.

1// Views/Invoice/Invoice.cshtml2@model InvoiceModel3<h1>Invoice #@Model.Number</h1>4<p>Billed to @Model.Customer</p>5<table>6  <tr><th>Item</th><th>Qty</th><th>Amount</th></tr>7  @foreach (var line in Model.Lines)8  {9    <tr>10      <td>@line.Description</td>11      <td>@line.Quantity.ToString("N0")</td>12      <td>EUR @((line.Quantity * line.UnitPrice).ToString("N2"))</td>13    </tr>14  }15</table>16<p>Total EUR @Model.Lines.Sum(l => l.Quantity * l.UnitPrice).ToString("N2")</p>1718// Program.cs19builder.Services.AddControllersWithViews();20builder.Services.AddSingleton<RazorRenderer>();2122// Renders Views/Invoice/Invoice.cshtml with a model, then converts the markup23app.MapGet("/invoices/{number}/razor-pdf", async (string number, RazorRenderer razor, PdfGateClient client) =>24{25    var model = new InvoiceModel(number, "Acme GmbH",26    [27        new InvoiceLine("API plan, Business", 1, 79.00m),28        new InvoiceLine("Extra documents", 2000, 0.01m),29    ]);3031    string html = await razor.RenderAsync("/Views/Invoice/Invoice.cshtml", model);3233    PdfGateDocumentResponse document = await client.GeneratePdfAsync(new GeneratePdfRequest34    {35        Html = html,36        PageSizeType = GeneratePdfPageSizeType.A4,37    });3839    Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = document.Id });40    return Results.File(pdf, "application/pdf", $"invoice-{number}.pdf");41});4243public record InvoiceModel(string Number, string Customer, List<InvoiceLine> Lines);44public record InvoiceLine(string Description, int Quantity, decimal UnitPrice);

The renderer resolves the view by path, builds the ViewContext a controller would normally provide, and writes the output into a StringWriter. Register it as a singleton next to AddControllersWithViews.

1// Renders a Razor view to a string so it can be sent as the html parameter.2public sealed class RazorRenderer(3    IRazorViewEngine viewEngine,4    ITempDataProvider tempDataProvider,5    IServiceProvider serviceProvider)6{7    public async Task<string> RenderAsync<TModel>(string viewPath, TModel model)8    {9        var httpContext = new DefaultHttpContext { RequestServices = serviceProvider };10        var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());1112        var view = viewEngine.GetView(executingFilePath: null, viewPath, isMainPage: true).View13            ?? throw new FileNotFoundException($"View not found: {viewPath}");1415        var viewData = new ViewDataDictionary<TModel>(new EmptyModelMetadataProvider(), new ModelStateDictionary())16        {17            Model = model18        };1920        await using var writer = new StringWriter();21        var viewContext = new ViewContext(actionContext, view, viewData,22            new TempDataDictionary(httpContext, tempDataProvider), writer, new HtmlHelperOptions());2324        await view.RenderAsync(viewContext);25        return writer.ToString();26    }27}

This endpoint was started and called like the others on the page, and its PDF matched the one from the interpolated string byte for byte. In an MVC project with controllers, inject the same renderer or render inside the controller with the view engine it already has.

Pages that render with JavaScript

Content that appears after the load event, a Chart.js canvas or data from a fetch(), is not in the PDF unless the renderer is told to wait. The pattern that works is a marker element the page inserts when it is done, plus WaitForSelector aimed at that marker. The sample fakes a slow data source with setTimeout.

1const string chartHtml = """2    <h1>Sales report</h1>3    <div id="chart">Loading…</div>4    <script>5      // Stands in for a chart library or a fetch() that finishes later6      setTimeout(() => {7        document.getElementById("chart").textContent = "Q4 revenue: EUR 1.2M";8        document.body.insertAdjacentHTML("beforeend", "<div id=\"chart-ready\"></div>");9      }, 800);10    </script>11    """;1213PdfGateDocumentResponse chart = await client.GeneratePdfAsync(new GeneratePdfRequest14{15    Html = chartHtml,16    WaitForSelector = "#chart-ready",17});1819await using (Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = chart.Id }))20await using (var file = File.Create("wait.pdf"))21{22    await pdf.CopyToAsync(file);23}24Console.WriteLine($"Wait -> {chart.Id}");
  • If the selector never shows up, the request ends after 30 seconds with a 422. You never get a half-rendered file.
  • WaitForNetworkIdle is the fallback for pages you do not own, and Delay is a fixed pause in milliseconds for when nothing else works.
  • To change the page before capture, for instance to stop an animation or open a collapsed panel, put the script in Javascript.

The trade-offs between these options are worked through in waiting for dynamic content.

Error handling

The SDK raises PdfGateException for any unsuccessful response. StatusCode is a nullable HttpStatusCode, ResponseBody is the raw JSON, and the exception message already summarises both. The sample provokes a failure with a loopback URL.

1try2{3    await client.GeneratePdfAsync(new GeneratePdfRequest { Url = "http://localhost:3000/report" });4}5catch (PdfGateException ex)6{7    Console.WriteLine($"API error {(int?)ex.StatusCode}: {ex.Message}");8}

Four statuses cover almost every case you will see from C#:

  • 400: a parameter the API rejected, with the field named in the message. The local URL above is one example, a malformed PageRanges is another.
  • 401: the key is missing, mistyped or has the wrong prefix.
  • 422: the fetch worked but the render did not, in practice almost always a WaitForSelector with no match.
  • 429: too many renders in flight at once for the plan. Back off briefly and retry.

Transport problems bypass this class and surface as HttpRequestException or TaskCanceledException, so a retry policy should catch those separately.

In ASP.NET Core

Register a single PdfGateClient in the service container and let minimal APIs inject it. Two delivery styles are shown. The first streams the file back through Results.File. The second asks for a short-lived download link via PreSignedUrlExpiresIn and returns JSON, so the bytes never pass through your server.

1using PdfGate.net;2using PdfGate.net.Models;34var builder = WebApplication.CreateBuilder(args);5builder.Services.AddSingleton(new PdfGateClient(builder.Configuration["PdfGate:ApiKey"]!));67var app = builder.Build();89// Returns the PDF directly10app.MapGet("/invoices/{number}/pdf", async (string number, PdfGateClient client) =>11{12    string html = $"<h1>Invoice #{number}</h1><p>Generated on {DateTime.UtcNow:d}</p>";1314    PdfGateDocumentResponse document = await client.GeneratePdfAsync(new GeneratePdfRequest15    {16        Html = html,17        PageSizeType = GeneratePdfPageSizeType.A4,18    });1920    Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = document.Id });21    return Results.File(pdf, "application/pdf", $"invoice-{number}.pdf");22});2324// Returns a temporary download link instead of the bytes25app.MapPost("/invoices/{number}/pdf-link", async (string number, PdfGateClient client) =>26{27    PdfGateDocumentResponse document = await client.GeneratePdfAsync(new GeneratePdfRequest28    {29        Html = $"<h1>Invoice #{number}</h1>",30        PreSignedUrlExpiresIn = 3600,31    });3233    return Results.Ok(new { document.Id, document.FileUrl, document.ExpiresAt });34});3536app.Run();
  • PdfGate:ApiKey belongs in user secrets during development and in the host's configuration store in production, never in a committed appsettings.json.
  • Results.File takes ownership of the stream and disposes it once the response has been written, so no using is needed there.
  • A pre-signed link requires file storage to be switched on for the account, otherwise the API answers 422. Lifetimes run from 60 seconds to 24 hours.
  • A render takes a few seconds and the call blocks until it is done. For bulk jobs, fan out with Task.WhenAll but stay under the plan's concurrency limit.

Without the SDK (HttpClient)

Under the SDK there is a single endpoint, POST /generate/pdf, that takes JSON and returns a PDF. HttpClient and PostAsJsonAsync reach it in a dozen lines if you prefer no dependency.

1using System.Net.Http.Headers;2using System.Net.Http.Json;34var apiKey = Environment.GetEnvironmentVariable("PDFGATE_API_KEY")!;56using var http = new HttpClient();7http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);89using HttpResponseMessage response = await http.PostAsJsonAsync(10    "https://api.pdfgate.com/generate/pdf",11    new12    {13        html = "<h1>Hello from HttpClient</h1>",14        pageSizeType = "a4",15        margin = new { top = "20mm", bottom = "20mm" },16    });1718if (!response.IsSuccessStatusCode)19{20    Console.WriteLine(await response.Content.ReadAsStringAsync());21    return;22}2324await using (var file = File.Create("http.pdf"))25{26    await response.Content.CopyToAsync(file);27}28Console.WriteLine("HttpClient -> http.pdf");
  • The success status is 201, not 200. Sending jsonResponse: true swaps the PDF body for the document record.
  • A test_ key must be pointed at api-sandbox.pdfgate.com by hand. The SDK does that routing for you, raw HTTP does not.
  • System.Net.Http.Json is in the shared framework from .NET 5 on, so nothing extra is referenced.

Where to go next

Frequently asked questions

Which .NET versions does the SDK support?

PdfGate.net targets .NET Standard 2.0, so it runs on .NET 6, 8 and 10 as well as .NET Framework 4.6.1 or later. The samples use C# 11 raw string literals, which need .NET 7 or later; on older targets use verbatim strings.

How do I add page numbers to a PDF generated from C#?

Pass Header and Footer HTML with the pageNumber and totalPages CSS classes, and set Margin.Top and Margin.Bottom so there is room to draw them. The renderer fills the classes in on every page.

Can I use Razor views as the template?

Yes. Render the view to a string with IRazorViewEngine and send the string as Html. Scriban and Handlebars.Net work the same way outside a web project. Encode any user input with WebUtility.HtmlEncode first.

What happens when a request fails?

The SDK throws PdfGateException with the HTTP status in StatusCode and the API's JSON in ResponseBody. A 400 means an invalid parameter, 401 a bad key, 422 a page that could not be rendered, and 429 more concurrent conversions than the plan allows.

The parameters used on this page, and the ones it skipped, are all on the HTML to PDF API page.

This website uses cookies to offer a better browsing experience. By clicking on the "Accept" button you accept our Cookie policy.