A fairly common requirement in enterprise ASP.NET Core applications is generating a document from backend data and letting the user download it, either as a PDF for viewing or as a DOCX when the recipient needs to edit it further. The data itself can come from a database, an external API, or a computed report, it does not really matter. What matters is picking a document engine that does not fight you on formatting, licensing, or deployment. This walkthrough covers a minimal API implementation using GemBox.Document, one of the libraries that handles this cleanly on Linux hosts without pulling in a Windows dependency.
Why GemBox.Document
There is no shortage of PDF and document generation libraries in the .NET ecosystem, and each one comes with its own trade-offs around licensing cost, learning curve, and platform support. GemBox.Document stood out here for three practical reasons: the licensing model is reasonable for small to mid-sized projects, the API maps closely to how you would think about a document (sections, paragraphs, runs, hyperlinks), and it runs fine in a Linux container without needing a Windows-only rendering engine. It also supports digital signing of PDFs, which is useful if the generated document needs to carry some form of authenticity guarantee, for example an invoice or a signed report.
One thing worth calling out early: GemBox.Document builds a single document model in memory and then exports it to whichever format you ask for, PDF, DOCX, ODT, and a few others. That is convenient because you write the document structure once and get multiple output formats for free, but it also means the same DocumentModel instance is not meant to be reused across formats in a thread-unsafe way. Build a fresh model per request.
Adding the GemBox package
Add the GemBox.Document NuGet package to the project. That alone gets you PDF and DOCX generation out of the box.

If you are deploying to a Linux host, also add the HarfBuzzSharp.NativeAssets.Linux package. Without it, text shaping for the PDF renderer fails at runtime on Linux, and this is easy to miss during local development on Windows or macOS where it works without the extra package. Catching this only in a container in staging is a common mistake, so add it upfront if Linux is anywhere in your deployment plan.
Building the download API
The download endpoint is a small controller with two actions, one per output format. Both actions accept an id, generate a stream from a document service, and return it with the correct content type so the browser knows how to handle the download.
public class DownloadController(DocumentService _documentService) : ControllerBase
{
[Route("pdf/{id}")]
[HttpGet]
public FileStreamResult DownloadPdf(string id)
{
var stream = _documentService.GeneratePdf(id);
return File(stream, "application/pdf");
}
[Route("docx/{id}")]
[HttpGet]
public FileStreamResult DownloadDocx(string id)
{
var stream = _documentService.GenerateDocx(id);
return File(stream,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
}
}
This is deliberately thin. The controller only routes the request and sets the content type, it does not know anything about how the document is built. That separation matters because it lets you unit test the document generation logic without spinning up the web host. One thing missing here that you would want in a real API is a Content-Disposition header with a suggested file name, otherwise the browser downloads the file with a generic name based on the route. It is also worth adding basic authorization on these routes if the underlying data is not meant to be public, since a raw id in the URL is trivially guessable or enumerable.
The document service
The DocumentService class is where the actual document gets built. It constructs a DocumentModel, adds sections and paragraphs, and then saves that model using format-specific SaveOptions, PdfDefault or DocxDefault. The example below builds a two-page document with a bookmark and a hyperlink, just to show the range of what the model supports beyond plain text.
using GemBox.Document;
namespace ApiCreatePdf;
public class DocumentService
{
public Stream GeneratePdf(string id)
{
var documentData = GetDocumentData(id, SaveOptions.PdfDefault);
var pdf = new MemoryStream();
var document = CreateDocument(documentData);
document.Save(pdf, SaveOptions.PdfDefault);
return pdf;
}
public Stream GenerateDocx(string id)
{
var documentData = GetDocumentData(id, SaveOptions.DocxDefault);
var docx = new MemoryStream();
var document = CreateDocument(documentData);
document.Save(docx, SaveOptions.DocxDefault);
return docx;
}
private static DocumentModel CreateDocument(DocumentData documentData)
{
// If using the Professional version, put your serial key below.
ComponentInfo.SetLicense("FREE-LIMITED-KEY");
var document = new DocumentModel();
var section = new Section(document);
document.Sections.Add(section);
// Main text
var paragraph = new Paragraph(document);
section.Blocks.Add(paragraph);
var run = new Run(document, documentData.MainContentText);
paragraph.Inlines.Add(run);
var bookmarkName = "TopOfDocument";
document.Sections.Add(
new Section(document,
new Paragraph(document,
new BookmarkStart(document, bookmarkName),
new Run(document, "This is a 'TopOfDocument' bookmark."),
new BookmarkEnd(document, bookmarkName)),
new Paragraph(document,
new Run(document, "The following is a link to "),
new Hyperlink(document, "https://www.gemboxsoftware.com/document", "GemBox.Document Overview"),
new Run(document, " page.")),
new Paragraph(document,
new SpecialCharacter(document, SpecialCharacterType.PageBreak),
new Run(document, "This is a document's second page."),
new SpecialCharacter(document, SpecialCharacterType.LineBreak),
new Hyperlink(document, bookmarkName, "Return to 'TopOfDocument'.") { IsBookmarkLink = true })));
return document;
}
private DocumentData GetDocumentData(string id, SaveOptions docType)
{
return new DocumentData
{
MainContentText = $"{docType.ContentType} created for id: {id}"
};
}
}
A few things to notice here beyond the obvious mechanics. First, ComponentInfo.SetLicense is called inside CreateDocument, which runs on every request. In the free tier this does not cost much, but if you move to a licensed key, calling SetLicense once per request is wasteful and unnecessary, it should be called once at application startup instead. Second, the bookmark and hyperlink example shows that GemBox is not limited to flat text, you get proper document navigation features like internal links and page breaks, which is genuinely useful for longer generated reports with a table of contents. Third, GetDocumentData here is a stub returning a hardcoded string built from the id, in a real implementation this would hit a repository or a service call, and that data needs to be treated as untrusted if any part of it originates from user input.
Trying it out
Once the API is running, hitting the pdf or docx route with a valid id downloads the corresponding file, and both formats open correctly with the bookmark and hyperlink intact.

This is a demo, so the content is trivial. In a production scenario where the document content comes from user input or from a database field that users can edit, that content needs to be validated and sanitized before it goes into the document. This matters more than it sounds, because document formats like DOCX can carry embedded content, and PDF generation from unsanitized HTML or rich text has been a source of injection style bugs in other libraries. GemBox builds documents from a structured object model rather than parsing raw markup, which reduces this risk considerably compared to libraries that accept HTML input directly, but it does not eliminate the need to check what you are putting into MainContentText or any other field.
GemBox versus QuestPDF and the OpenXML SDK
It is worth knowing the alternatives before committing to GemBox for a new project. QuestPDF is a strong choice if you only need PDF output and want a fluent, code-first layout API with a genuinely free Community license for small companies, though it does not generate DOCX. The OpenXML SDK, Microsoft’s own library, generates DOCX and XLSX natively and has no licensing cost at all, but the API is verbose and low-level, you are working directly with the XML schema, and it does not produce PDF without an additional conversion step. GemBox sits in between: one API surface for both PDF and DOCX, a friendlier object model than OpenXML, and a paid license once you go past the free tier’s page and size limits.
The practical decision usually comes down to how many output formats you need and your tolerance for licensing cost. If PDF is the only requirement, QuestPDF is worth evaluating first since it avoids licensing cost for smaller teams. If you specifically need both PDF and DOCX from the same data model with minimal code duplication, GemBox saves real development time, and that time saving is usually worth more than the license fee once you account for engineering hours.
Production considerations
A few things to plan for before shipping this to production. Document generation is CPU and memory bound work, so for large documents or high request volumes, generating on a background worker or queue rather than synchronously inside a web request avoids tying up request threads and keeps the API responsive under load. Memory streams also need to be disposed properly, FileStreamResult in ASP.NET Core handles disposal of the stream it wraps, but if you add caching or retry logic around document generation, make sure you are not leaking MemoryStream instances. Finally, keep an eye on the free tier limits of GemBox, they cap the number of paragraphs or pages, and hitting that limit in production without noticing during development is a frustrating way to find out you need a license.
Leave a Reply