DEV Community

IronSoftware
IronSoftware

Posted on

CSHTML to PDF in ASP.NET Core MVC (.NET 10)

Rendering Razor views (CSHTML) to PDF is a common requirement in ASP.NET Core MVC applications. Every business application eventually needs PDF export from view templates — invoices rendered from invoice views, reports from dashboard views, receipts from order confirmation views. The challenge is that Razor views output HTML for browsers, not PDF files, and bridging this gap requires proper integration.

I've implemented PDF export in dozens of MVC applications. Early implementations used Rotativa, a library wrapping wkhtmltopdf. The problem was Rotativa's maintenance stopped and wkhtmltopdf itself is abandoned. The WebKit engine it uses is from 2015, meaning modern CSS features don't work. I've migrated multiple systems away from Rotativa because security audits flagged the unmaintained dependency.

The approach I use now leverages IronPDF's direct Chromium rendering without subprocess wrappers. Razor views render to HTML strings using ASP.NET Core's view engine, then Iron PDF converts those strings to PDFs. This workflow preserves MVC patterns — models, views, layouts, partial views — while generating production-quality PDFs.

The key advantage is maintainability. PDF templates are Razor views that designers and developers already understand. Changes to invoice layouts happen in CSHTML files, not code. The same view serves both web page rendering and PDF generation, ensuring visual consistency. Stakeholders preview PDFs by viewing the web page first, eliminating surprise formatting issues.

Understanding the rendering pipeline helps debug issues. Controllers create view models containing data. The Razor view engine renders CSHTML templates with models into HTML strings. IronPDF's Chromium engine converts HTML to PDF with full CSS and JavaScript support. The PDF returns as a file download or saves to storage. Each step has distinct responsibilities that troubleshoot independently.

using IronPdf;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using System.IO;
// Install via NuGet: Install-Package IronPdf

public class InvoiceController : Controller
{
    private readonly ICompositeViewEngine _viewEngine;
    private readonly ChromePdfRenderer _pdfRenderer;

    public InvoiceController(ICompositeViewEngine viewEngine)
    {
        _viewEngine = viewEngine;
        _pdfRenderer = new ChromePdfRenderer();
    }

    [HttpGet("invoice/{id}/pdf")]
    public async Task<IActionResult> DownloadPdf(int id)
    {
        var invoice = GetInvoiceData(id); // From database
        var html = await RenderViewAsync("InvoiceTemplate", invoice);

        var pdf = _pdfRenderer.RenderHtmlAsPdf(html);
        return File(pdf.BinaryData, "application/pdf", $"invoice-{id}.pdf");
    }

    private async Task<string> RenderViewAsync(string viewName, object model)
    {
        ViewData.Model = model;
        using var writer = new StringWriter();

        var viewResult = _viewEngine.FindView(ControllerContext, viewName, false);
        var viewContext = new ViewContext(
            ControllerContext, viewResult.View, ViewData,
            TempData, writer, new HtmlHelperOptions());

        await viewResult.View.RenderAsync(viewContext);
        return writer.ToString();
    }

    private Invoice GetInvoiceData(int id) => new() { /* ... */ };
}
Enter fullscreen mode Exit fullscreen mode

That's the fundamental pattern — render Razor view to string, convert string to PDF, return as file. The RenderViewAsync method uses ASP.NET Core's view engine to render Razor templates programmatically. For production systems, extract this into a service to avoid duplicating view rendering logic across controllers.

Why Use Razor Views Instead of HTML Templates?

Razor views integrate with MVC patterns that .NET developers already use. Models define data structures, views handle presentation, controllers orchestrate workflow. This separation of concerns makes systems maintainable and testable.

The alternative is maintaining separate HTML template files outside the MVC structure. These templates use placeholder syntax (Handlebars, Mustache, or custom) requiring additional templating libraries and learning curves. Changes to templates don't benefit from Razor's compile-time checking or IntelliSense support.

I've maintained both approaches. Razor-based PDF generation uses the same skills as building web pages — C# in views, HTML helpers, tag helpers, partial views, layouts. Designers familiar with Razor can modify PDF templates. HTML template approaches require learning a separate templating language and managing template files alongside the MVC structure.

Razor views also support complex logic in templates. Conditional rendering, loops over collections, HTML helper methods, and partial view composition all work naturally. This simplifies invoice templates with line item tables, reports with conditional sections, or contracts with dynamic clauses.

The trade-off is coupling PDFs to the MVC framework. If you need PDF generation outside MVC contexts (console applications, background services), rendering Razor views becomes awkward. For these scenarios, simple HTML template files with string replacement or Handlebars might be simpler. But for PDFs generated from MVC applications, Razor views are the natural choice.

How Do I Create a Razor View for PDF Generation?

PDF-specific views should optimize for print output rather than browser display. This means using print-friendly CSS, avoiding interactive elements, and setting appropriate page dimensions.

Create a Razor view (Views/Invoice/InvoiceTemplate.cshtml):

@model InvoiceViewModel

<!DOCTYPE html>
<html>
<head>
    <style>
        @@page {
            size: A4;
            margin: 20mm;
        }

        body {
            font-family: Arial, sans-serif;
            font-size: 12px;
            color: #333;
        }

        .header {
            font-size: 24px;
            font-weight: bold;
            margin-bottom: 20px;
        }

        table {
            width: 100%;
            border-collapse: collapse;
            margin-top: 20px;
        }

        th, td {
            text-align: left;
            padding: 8px;
            border-bottom: 1px solid #ddd;
        }

        th {
            background-color: #f0f0f0;
            font-weight: bold;
        }

        .total {
            text-align: right;
            font-size: 18px;
            font-weight: bold;
            margin-top: 20px;
        }

        @@media print {
            /* Print-specific styles */
            body { background: white; }
            .no-print { display: none; }
        }
    </style>
</head>
<body>
    <div class="header">Invoice #@Model.InvoiceNumber</div>

    <div>
        <strong>Customer:</strong> @Model.CustomerName<br/>
        <strong>Date:</strong> @Model.InvoiceDate.ToShortDateString()
    </div>

    <table>
        <thead>
            <tr>
                <th>Description</th>
                <th>Quantity</th>
                <th>Price</th>
                <th>Total</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var item in Model.LineItems)
            {
                <tr>
                    <td>@item.Description</td>
                    <td>@item.Quantity</td>
                    <td>@item.Price.ToString("C")</td>
                    <td>@((item.Quantity * item.Price).ToString("C"))</td>
                </tr>
            }
        </tbody>
    </table>

    <div class="total">
        Total: @Model.Total.ToString("C")
    </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

The @page CSS rule sets page size and margins for PDF output. The @media print section applies print-specific styles. The Razor syntax (@Model, @foreach) uses standard MVC patterns for data binding.

I include embedded styles rather than external stylesheets because external references can cause loading issues during PDF generation. Embedding ensures styles are always available. For large shared stylesheets, use partial views containing style blocks to avoid duplication.

How Do I Use Layouts and Partial Views in PDF Templates?

Razor's layout and partial view features work in PDF templates exactly as they do in web pages. This promotes reusability — shared headers, footers, or common sections extract to partial views referenced by multiple PDF templates.

Create a PDF layout (Views/Shared/_PdfLayout.cshtml):

<!DOCTYPE html>
<html>
<head>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 40px;
        }
        .header {
            text-align: center;
            border-bottom: 2px solid #333;
            padding-bottom: 10px;
            margin-bottom: 20px;
        }
        .footer {
            text-align: center;
            border-top: 1px solid #333;
            padding-top: 10px;
            margin-top: 20px;
            font-size: 10px;
        }
    </style>
</head>
<body>
    <div class="header">
        <h2>Acme Corporation</h2>
        <p>123 Business St | Phone: (555) 123-4567</p>
    </div>

    @RenderBody()

    <div class="footer">
        <p>Generated on @DateTime.Now.ToShortDateString() | Page 1</p>
    </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Use the layout in PDF views:

@model InvoiceViewModel
@{
    Layout = "_PdfLayout";
}

<h3>Invoice #@Model.InvoiceNumber</h3>
<p>Customer: @Model.CustomerName</p>
<!-- Invoice content -->
Enter fullscreen mode Exit fullscreen mode

The layout provides consistent branding (company header, footer) across all PDFs. Changes to company contact information or branding update once in the layout, affecting all PDFs using it.

For reusable sections, create partial views:

<!-- _InvoiceLineItems.cshtml -->
@model List<LineItemViewModel>

<table>
    <thead>
        <tr>
            <th>Description</th>
            <th>Quantity</th>
            <th>Price</th>
            <th>Total</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model)
        {
            <tr>
                <td>@item.Description</td>
                <td>@item.Quantity</td>
                <td>@item.Price.ToString("C")</td>
                <td>@((item.Quantity * item.Price).ToString("C"))</td>
            </tr>
        }
    </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

Reference the partial in main views:

@await Html.PartialAsync("_InvoiceLineItems", Model.LineItems)
Enter fullscreen mode Exit fullscreen mode

This extracts line item table rendering to a reusable component. Invoices, quotes, and purchase orders all use the same partial view for consistent table formatting.

How Do I Handle Page Breaks and Multi-Page PDFs?

CSS controls page breaks in PDFs. Use page-break-after, page-break-before, or page-break-inside to manage where content splits across pages.

Force page breaks between sections:

.section {
    page-break-after: always;
}
Enter fullscreen mode Exit fullscreen mode

This creates a new page after each section. I use this for reports where each department's data appears on separate pages.

Prevent breaks inside elements:

.invoice-item {
    page-break-inside: avoid;
}
Enter fullscreen mode Exit fullscreen mode

This keeps invoice line items together, preventing awkward splits where half an item appears on one page and half on the next.

For tables spanning multiple pages, repeat headers:

thead {
    display: table-header-group;
}

tfoot {
    display: table-footer-group;
}
Enter fullscreen mode Exit fullscreen mode

Table headers repeat at the top of each page, maintaining context. Table footers repeat at the bottom. This is standard for long invoices or reports where column headers need to appear on every page.

What About View Model Design for PDFs?

View models for PDFs should include all data needed for rendering. Avoid lazy-loading or navigation properties that require database queries during view rendering — assemble complete data in controllers before rendering views.

Create a dedicated PDF view model:

public class InvoicePdfViewModel
{
    public string InvoiceNumber { get; set; }
    public string CustomerName { get; set; }
    public string CustomerAddress { get; set; }
    public DateTime InvoiceDate { get; set; }
    public List<LineItemViewModel> LineItems { get; set; }
    public decimal Subtotal { get; set; }
    public decimal Tax { get; set; }
    public decimal Total { get; set; }
    public string PaymentTerms { get; set; }
}

public class LineItemViewModel
{
    public string Description { get; set; }
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

The view model contains all invoice data in a flat, render-ready structure. Calculated fields (subtotal, tax, total) are pre-computed. There are no navigation properties or lazy collections.

I populate view models in controllers, not views:

var invoice = await _context.Invoices
    .Include(i => i.LineItems)
    .Include(i => i.Customer)
    .FirstOrDefaultAsync(i => i.Id == id);

var viewModel = new InvoicePdfViewModel
{
    InvoiceNumber = invoice.Number,
    CustomerName = invoice.Customer.Name,
    CustomerAddress = invoice.Customer.Address,
    InvoiceDate = invoice.Date,
    LineItems = invoice.LineItems.Select(li => new LineItemViewModel
    {
        Description = li.Description,
        Quantity = li.Quantity,
        Price = li.Price
    }).ToList(),
    Subtotal = invoice.LineItems.Sum(li => li.Quantity * li.Price),
    Tax = invoice.Tax,
    Total = invoice.Total,
    PaymentTerms = "Net 30"
};
Enter fullscreen mode Exit fullscreen mode

This assembles all data upfront. View rendering becomes pure template processing without database access. I've debugged PDF generation issues where lazy loading caused exceptions during rendering — pre-loading all data prevents these issues.

Quick Reference

Task Implementation Notes
Render view to string await RenderViewAsync(viewName, model) Uses ICompositeViewEngine
Convert to PDF renderer.RenderHtmlAsPdf(html) From HTML string
Return as file File(pdf.BinaryData, "application/pdf") Browser download
Use layout Layout = "_PdfLayout" In view
Page breaks CSS page-break-after: always Between sections
Avoid breaks page-break-inside: avoid Within elements
Repeat headers thead { display: table-header-group } Multi-page tables

Key Principles:

  • Render Razor views to HTML strings using ICompositeViewEngine
  • IronPDF converts HTML strings to PDFs with Chromium rendering
  • Use embedded styles to avoid external stylesheet loading issues
  • Layouts and partial views work identically to web pages
  • Pre-populate view models with all data to avoid lazy-loading during rendering
  • Control page breaks with CSS (page-break-after, page-break-inside)
  • Repeat table headers on multi-page tables with display: table-header-group
  • Avoid Rotativa (unmaintained, based on deprecated wkhtmltopdf)

The complete CSHTML to PDF tutorial includes advanced scenarios like async rendering, caching strategies, and custom view locations.


Written by Jacob Mellor, CTO at Iron Software. Jacob created IronPDF and leads a team of 50+ engineers building .NET document processing libraries.

Top comments (0)