.NET + AI
HTML to PDF in ASP.NET Core with SelectPdf and React
Convert HTML to PDF in ASP.NET Core with SelectPdf's Blink engine: a .NET 8 minimal API that builds a 3-page A4 document with a StringBuilder, CSS print rules that control every page break, and a React front end that previews the HTML and downloads the PDF.
HTML to PDF in ASP.NET Core with SelectPdf and React
This is the whole PDF renderer:
static byte[] RenderPdf(string html)
{
var converter = new HtmlToPdf();
var options = converter.Options;
options.RenderingEngine = RenderingEngine.Blink;
options.CssMediaType = HtmlToPdfCssMediaType.Print;
options.PdfPageSize = PdfPageSize.A4;
options.PdfPageOrientation = PdfPageOrientation.Portrait;
options.WebPageWidth = 794; // 210mm at 96 dpi
options.WebPageHeight = 0;
options.MarginTop = options.MarginBottom = options.MarginLeft = options.MarginRight = 0;
options.PdfDocumentInformation.Title = "AI Foundry Responsible AI Policy (AIF-POL-GOV-001)";
var doc = converter.ConvertHtmlString(html);
try
{
return doc.Save();
}
finally
{
doc.Close();
}
}Twenty lines, and it turns a 3-page corporate policy β dark letterhead band, numbered legal clauses, RACI table, warning callouts, sign-off block β into an A4 PDF that matches the browser preview. Almost none of the work is in that function. It's in the decisions around it: which engine, which CSS media type, why the page width is 794, why the margins are zero, and where page breaks come from.
This guide walks through those decisions using a complete working project: an ASP.NET Core (.NET 8) minimal API that generates the HTML and converts it to PDF with SelectPdf, and a React 19 + Vite front end that previews the document and downloads the file. The source is on GitHub: AfzaalLucky/HtmlToPDF-SelectPDF-Core-React.
What you get
React (Vite, :5173) ASP.NET Core minimal API (:5291)
ββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββββ
β Preview policy ββββββββββΌβ /api ββββΊ β GET /policy-builder.html β
β <iframe srcDoc=html> β proxy β PolicyHtmlBuilder.Build(logo) β
β β β β
β Download PDF ββββββββββΌβββββββββββΊ β GET /policy-builder.pdf β
β blob β <a download> β β same HTML β SelectPdf (Blink) β A4 β
ββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββββ| Piece | What it does |
|---|---|
htmltopdf-api/Program.cs | Three endpoints and the SelectPdf rendering options |
htmltopdf-api/PolicyHtmlBuilder.cs | Builds the 3-page policy HTML with a StringBuilder |
htmltopdf-api/Assets/logo.png | Letterhead logo, inlined as a base64 data URI at startup |
htmltopdf-web/src/App.tsx | Preview / Download PDF UI |
htmltopdf-web/vite.config.ts | Proxies /api/* to the API |
The one design rule that holds the project together: the preview and the PDF come from the same
HTML string. Both endpoints call PolicyHtmlBuilder.Build. If the preview looks right and the PDF
doesn't, the difference is in rendering options or print CSS β never in the content.
Step 1: Add SelectPdf to an ASP.NET Core project
SelectPdf ships the .NET Core converter and the Blink (Chromium) engine as two separate NuGet packages. You need both:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>HtmlToPdfApi</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Select.HtmlToPdf.NetCore" Version="26.3.0" />
<PackageReference Include="Select.HtmlToPdf.NetCore.Blink" Version="26.3.0" />
</ItemGroup>
<ItemGroup>
<None Update="Assets\logo.png" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>The first dotnet build restores the Chromium runtime along with the packages, so expect it to be
slower than usual. Two constraints to know before you commit to this stack:
- Windows. SelectPdf's Blink engine ships a Windows Chromium build. Plan your hosting around that.
- Page limit on the free tier. The project uses the free SelectPdf Community Edition, which generates PDFs of up to 5 pages. The 3-page policy fits. Longer documents need a commercial license β check SelectPdf's pricing page before you design a feature around it.
Step 2: Expose HTML and PDF from a minimal API
using HtmlToPdfApi;
using SelectPdf;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Logo for the StringBuilder-generated HTML, inlined as a data URI.
var logoPath = Path.Combine(AppContext.BaseDirectory, "Assets", "logo.png");
var logoDataUri = "data:image/png;base64," + Convert.ToBase64String(File.ReadAllBytes(logoPath));
const string PdfFileName = "AI-Foundry-Policy-AIF-POL-GOV-001.pdf";
app.MapGet("/", () => Results.Redirect("/policy-builder.pdf"));
app.MapGet("/policy-builder.html", () =>
Results.Content(PolicyHtmlBuilder.Build(logoDataUri), "text/html; charset=utf-8"));
app.MapGet("/policy-builder.pdf", () =>
Results.File(RenderPdf(PolicyHtmlBuilder.Build(logoDataUri)), "application/pdf", PdfFileName));
app.Run();| Endpoint | Returns |
|---|---|
GET / | Redirects to /policy-builder.pdf |
GET /policy-builder.html | The policy HTML generated by PolicyHtmlBuilder |
GET /policy-builder.pdf | The same HTML rendered to an A4 PDF |
Three details that matter:
The logo is a data URI, read once at startup. ConvertHtmlString receives a string, not a file
on disk, so a relative <img src="Assets/logo.png"> has nothing sensible to resolve against. The same
problem shows up on the front end: the React app loads the HTML into an <iframe srcDoc>, where a
relative path would resolve against the Vite dev server, not the API. Inlining the image as base64
makes the document fully self-contained β it renders identically in the converter, the iframe, and a
browser tab β and the file is read once, not per request.
Results.File with a file name sets the download name. Passing PdfFileName as the third
argument makes ASP.NET Core send a Content-Disposition: attachment header, so hitting the URL
directly in a browser downloads AI-Foundry-Policy-AIF-POL-GOV-001.pdf instead of a file called
policy-builder.pdf.
The HTML endpoint is your debugger. When the PDF looks wrong, open /policy-builder.html in
Chrome, switch DevTools to print media emulation, and you're looking at almost exactly what Blink
sees. That's the main reason to expose it at all.
Step 3: Build the HTML in C# with a StringBuilder
You don't need a template engine to generate a structured document. PolicyHtmlBuilder writes the
parts every page repeats β letterhead and footer β once, and appends them per page:
public static class PolicyHtmlBuilder
{
private const int PageCount = 3;
private const string StandardFooterNote = "Internal Β· Controlled Document";
public static string Build(string logoDataUri)
{
var sb = new StringBuilder(capacity: 160_000);
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"utf-8\">");
sb.AppendLine("<title>AI Foundry Responsible AI Policy</title>");
sb.AppendLine("<style>");
sb.AppendLine(Styles);
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine("<main class=\"document\">");
AppendPage(sb, 1, logoDataUri, Page1Body, StandardFooterNote);
AppendPage(sb, 2, logoDataUri, Page2Body, StandardFooterNote);
AppendPage(sb, 3, logoDataUri, Page3Body, StandardFooterNote + " Β· Printed copies are uncontrolled");
sb.AppendLine("</main>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
private static void AppendPage(StringBuilder sb, int number, string logoDataUri, string body, string footerNote)
{
sb.AppendLine($"<article class=\"page\" aria-label=\"Page {number}\">");
AppendLetterhead(sb, logoDataUri);
sb.AppendLine(" <div class=\"page-body\">");
sb.AppendLine(body);
sb.AppendLine(" </div>");
AppendFooter(sb, number, footerNote);
sb.AppendLine("</article>");
}
private static void AppendFooter(StringBuilder sb, int number, string note)
{
sb.AppendLine(" <footer class=\"page-footer\">");
sb.AppendLine(" <span><span class=\"ref\">AIF-POL-GOV-001</span> Β· Version 1.0</span>");
sb.AppendLine($" <span class=\"mid\">{note}</span>");
sb.AppendLine($" <span class=\"pg\">Page {number} of {PageCount}</span>");
sb.AppendLine(" </footer>");
}
}The stylesheet and each page body are C# 11 raw string literals ("""β¦"""), so the CSS and markup
paste in unescaped β no doubled quotes, no \" everywhere:
private const string Page1Body = """
<p class="doc-type">Policy Document Β· Group-Wide</p>
<h1>Responsible AI Development and Use Policy</h1>
...
""";Each <article class="page"> is one physical A4 sheet with its own letterhead and footer. That's a
deliberate choice, and it's the one that makes "Page 2 of 3" possible without any PDF header/footer
API: the page number is just text the builder writes, because the builder already knows which page it's
writing.
The cost is that you decide the page breaks, not the renderer. Content is assigned to pages by hand. More on what that means for overflow below.
One caution if you adapt this for user data: the builder concatenates strings into HTML. That's fine for the fixed policy text here. The moment a value comes from a request or a database, HTML-encode it (
System.Net.WebUtility.HtmlEncode) before appending it.
Step 4: Configure SelectPdf for pixel-accurate A4
Back to the renderer. Every option is there for a reason:
| Option | Value | Why |
|---|---|---|
RenderingEngine | Blink | Chromium layout: CSS custom properties, flexbox and grid all work. The policy's letterhead, two-column sections and sign-off row depend on them. |
CssMediaType | Print | Applies the @media print rules, which is where the fixed-sheet layout lives. |
PdfPageSize / PdfPageOrientation | A4 / Portrait | The physical page. |
WebPageWidth | 794 | 210 mm at 96 dpi. The virtual browser window is exactly as wide as an A4 sheet, so mm units in the CSS map 1:1 onto the paper. |
WebPageHeight | 0 | Auto: let the content determine height. |
Margin* | 0 | The HTML carries its own page padding, and the letterhead bleeds to the paper edge. PDF margins would add a white frame around it. |
PdfDocumentInformation.Title | policy title | Shows in the PDF viewer's title bar and document properties instead of a blank or the file name. |
The WebPageWidth = 794 line is the one people skip, and it's the one that causes most "my PDF is
scaled wrong" bugs. If the virtual viewport is wider than the sheet, the converter shrinks the page to
fit; narrower, and responsive breakpoints can kick in. Match the viewport to the paper and the CSS
millimetres stay millimetres.
doc.Save() returns the PDF as a byte[], which goes straight into Results.File. The finally
block calls doc.Close() so the native document is released even when saving throws.
Step 5: Let CSS own the page breaks
With print media and a 794 px viewport, page layout is pure CSS:
@page { size: A4; margin: 0; }
@media print {
body { background: #fff; padding: 0; }
.document { display: block; }
.page {
width: 210mm; max-width: none; height: 297mm; min-height: 0; overflow: hidden;
box-shadow: none; margin: 0; break-after: page; page-break-after: always;
}
.page:last-child { break-after: auto; page-break-after: auto; }
.table-wrap { overflow: visible; }
}- Each
.pageis exactly 210 Γ 297 mm and forces a break after itself, so one<article>= one PDF page. .page:last-childturns the break off, which prevents a trailing blank page.- Both
break-afterand the legacypage-break-afterare set, so the rule holds regardless of which property the engine honours.
Two more rules keep the output faithful:
body {
-webkit-print-color-adjust: exact; print-color-adjust: exact;
}
h2 { break-after: avoid; }
tr { break-inside: avoid; }
.callout { break-inside: avoid; }print-color-adjust: exact stops the engine from dropping background colours in print mode β without
it, the navy letterhead band, the table header row and the tinted callouts disappear. The break-*
rules stop a heading from being stranded at the bottom of a sheet and a table row from being split.
On screen, the same stylesheet shows the sheets as paper cards on a grey "desk" with a drop shadow, and
a max-width: 640px media query collapses the columns for phones. One stylesheet, two media types, and
the PDF only ever sees the print half.
The trade-off of fixed sheets: .page has height: 297mm and overflow: hidden. If you add a
paragraph to page 2 and it no longer fits, the extra content is clipped silently β there is no error and
no automatic page 4. Every content change needs a look at the PDF. If your documents vary in length
(invoices with N line items, reports), drop the fixed height and let the engine flow content across
pages instead; you then lose the hand-written "Page X of Y" footer and need a different approach for it.
Step 6: Preview and download the PDF in React
The front end is a single component with two views. The interesting parts are how it fetches.
Preview: fetch first, then render.
const POLICY_HTML_URL = '/api/policy-builder.html'
useEffect(() => {
const controller = new AbortController()
fetch(POLICY_HTML_URL, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`Server responded ${response.status} ${response.statusText}`)
setPreview({ status: 'ready', html: await response.text() })
})
.catch((error: unknown) => {
if (controller.signal.aborted) return
setPreview({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return () => controller.abort()
}, [])<iframe className="preview" srcDoc={preview.html} title="AI Foundry Responsible AI Policy" />Pointing <iframe src> straight at the API would be one line shorter, but when the API is down you get
an empty frame and no explanation. Fetching the HTML first means a stopped or stale API produces a
readable error ("Make sure the latest htmltopdf-api service is running on port 5291") instead. The
AbortController cancels the request if the component unmounts, and the aborted check keeps that
cancellation from being reported as a failure.
The srcDoc iframe also isolates the policy's global styles (:root variables, body, h1) from the
app's own CSS β neither leaks into the other.
Download: fetch the bytes, then save a blob.
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
link.remove()
// The browser reads the blob URL asynchronously; revoking it immediately can yield an empty/corrupt file.
setTimeout(() => URL.revokeObjectURL(url), 60_000)
}
async function downloadPdf() {
setView('download')
setDownload({ status: 'busy' })
try {
const response = await fetch(POLICY_PDF_URL)
if (!response.ok) throw new Error(`Server responded ${response.status} ${response.statusText}`)
saveBlob(new Blob([await response.arrayBuffer()], { type: 'application/pdf' }), PDF_FILE_NAME)
setDownload({ status: 'done' })
} catch (error) {
setDownload({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
}Rendering a PDF with a headless Chromium engine takes a few seconds, so the download goes through
fetch rather than a plain link: the UI can show "Generating PDFβ¦", disable the button to block double
clicks, and report a server error in the page instead of downloading an error body named .pdf.
The comment in saveBlob is worth keeping. Calling URL.revokeObjectURL immediately after click()
is a common pattern in snippets online, and it intermittently produces empty or corrupt downloads
because the browser hasn't finished reading the blob yet. Revoking after 60 seconds releases the memory
without racing the download.
State is modelled as discriminated unions, so impossible combinations (an error message while "done") can't be represented:
type PreviewState = { status: 'loading' } | { status: 'ready'; html: string } | { status: 'error'; message: string }
type DownloadState = { status: 'idle' } | { status: 'busy' } | { status: 'done' } | { status: 'error'; message: string }Step 7: Proxy the API in development
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: process.env.HTMLTOPDF_API_URL ?? 'http://localhost:5291',
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})The React app always calls same-origin /api/...; Vite forwards it to the API and strips the prefix.
That removes the need for a CORS policy in the API, and HTMLTOPDF_API_URL lets you point the dev server
at an API on another port or machine without editing code.
Run it locally
You need the .NET 8 SDK, Node.js 20+ and Windows.
# Terminal 1: the API on http://localhost:5291
cd htmltopdf-api
dotnet run --launch-profile http
# Terminal 2: the React app on http://localhost:5173
cd htmltopdf-web
npm install
npm run devClick Preview policy to see the server-generated HTML, and Download PDF to get
AI-Foundry-Policy-AIF-POL-GOV-001.pdf. To check the output without opening another app, drop the file
into the browser-based PDF Info tool β it should report 3 A4
pages β or the PDF Metadata Viewer to confirm the
document title was set.
Takeaways for your own HTML to PDF pipeline
- Serve the HTML you convert. One builder, two endpoints. The HTML endpoint is your preview and your debugger.
- Use the Blink engine and print media. Modern CSS (variables, flex, grid) then behaves in the PDF the way it does in Chrome's print preview.
- Set
WebPageWidthto the paper width β 794 px for A4 at 96 dpi β and zero the PDF margins if the HTML defines its own. - Put page geometry in CSS:
@page { size: A4; margin: 0 }, a fixed-size sheet per page,break-after: page, andprint-color-adjust: exactso backgrounds survive. - Inline images as data URIs when you convert an HTML string, so the document has no external dependencies.
- Fixed sheets clip. Use them for documents with known content; use flowing layout for anything whose length depends on data.
- Encode anything dynamic before it goes into a
StringBuilder-built page. - Check the license against your page count β the SelectPdf Community Edition stops at 5 pages.
The full project, including the reference policy HTML and the complete stylesheet, is at github.com/AfzaalLucky/HtmlToPDF-SelectPDF-Core-React.
Continue reading
Related articles
AI Agent Security: What Developers Need to Know
AI agent security for developers: why the prompt is never the security boundary, and how to handle prompt injection, tool permissions, unsafe model output, data leaks, authorization and runaway cost β with real failures and code from three .NET AI projects.
Read article βPreventing SQL Injection in AI-Generated SQL
How to prevent SQL injection in AI-generated SQL: a least-privilege database user, a real T-SQL parser with an allow-list, cost limits, safe tool queries and a prompt that is never the security boundary. With code for ASP.NET Core and SQL Server.
Read article βASP.NET Core vs Python FastAPI for AI APIs: Which One Should Serve Your Model?
ASP.NET Core vs Python FastAPI for AI APIs β the same streaming LLM endpoint built in both, and a decision framework based on where your model actually runs.
Read article β