Create document templates
Granit.Templating and Granit.DocumentGeneration provide a complete pipeline to render data-driven documents. Templates are written in Scriban (text) or stored as XLSX (Excel), enriched with global context variables, and rendered to HTML, PDF, or native Excel files.
Prerequisites
Section titled “Prerequisites”- A .NET 10 project with Granit module system configured
- A PostgreSQL (or other EF Core-supported) database for the template store
- For PDF generation: Docker or a Linux host with Chromium installed
Step 1 — Install packages
Section titled “Step 1 — Install packages”# Core templating with Scriban enginedotnet add package Granit.Templating.Scriban
# EF Core store for template managementdotnet add package Granit.Templating.EntityFrameworkCore
# Document generation facadedotnet add package Granit.DocumentGeneration
# PDF renderer (HTML to PDF via Chromium)dotnet add package Granit.DocumentGeneration.Pdf
# Excel renderer (native XLSX via ClosedXML)dotnet add package Granit.DocumentGeneration.ExcelStep 2 — Declare modules
Section titled “Step 2 — Declare modules”using Granit.Core.Modularity;using Granit.DocumentGeneration;using Granit.DocumentGeneration.Excel;using Granit.Templating.EntityFrameworkCore;using Granit.Templating.Scriban;
[DependsOn( typeof(GranitTemplatingScribanModule), typeof(GranitTemplatingEntityFrameworkCoreModule), typeof(GranitDocumentGenerationModule), typeof(GranitDocumentGenerationExcelModule))]public sealed class MyAppModule : GranitModule { }Step 3 — Register services
Section titled “Step 3 — Register services”// Scriban engine (ITemplateEngine + global contexts now.* and context.*)builder.Services.AddGranitTemplatingWithScriban();
// Excel ClosedXML engine (additive -- both engines coexist)builder.Services.AddGranitDocumentGenerationExcel();
// EF Core store (IDocumentTemplateStoreReader/Writer + HybridCache L1)builder.AddGranitTemplatingEntityFrameworkCore(options => options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
// Document generation facadebuilder.Services.AddGranitDocumentGeneration();Step 4 — Define a template type
Section titled “Step 4 — Define a template type”Each template type is a strongly-typed class that binds a name to a data model.
public sealed record WelcomeEmailData(string FirstName, string ActivationUrl);
public sealed class WelcomeEmailType : TextTemplateType<WelcomeEmailData>{ public override string Name => "Notifications.WelcomeEmail";}public sealed record InvoiceData( string InvoiceNumber, string CustomerName, decimal TotalAmount, IReadOnlyList<InvoiceLineData> Lines, string? QrCodeSvg = null);
public sealed record InvoiceLineData( string Description, int Quantity, decimal UnitPrice);
public sealed class InvoiceTemplateType : DocumentTemplateType<InvoiceData>{ public override string Name => "Billing.Invoice"; // DefaultFormat = DocumentFormat.Pdf}public sealed record ReportData( string ReportTitle, DateOnly GeneratedDate, IReadOnlyList<ReportRowData> Rows);
public sealed class ExcelReportTemplateType : DocumentTemplateType<ReportData>{ public override string Name => "Reports.Monthly"; public override DocumentFormat DefaultFormat => DocumentFormat.Excel;}Step 5 — Write a Scriban template
Section titled “Step 5 — Write a Scriban template”The Scriban engine exposes your TData under the model variable with
snake_case property names.
<!DOCTYPE html><html lang="{{ context.culture }}"><head><meta charset="utf-8" /></head><body> <h1>Invoice {{ model.invoice_number }}</h1> <p>Customer: {{ model.customer_name }}</p> <p>Date: {{ now.date }}</p>
<table> <thead> <tr><th>Description</th><th>Qty</th><th>Unit price</th></tr> </thead> <tbody> {{ for line in model.lines }} <tr> <td>{{ line.description }}</td> <td>{{ line.quantity }}</td> <td>{{ line.unit_price }}</td> </tr> {{ end }} </tbody> </table>
<p><strong>Total: {{ model.total_amount }}</strong></p></body></html>Global context variables
Section titled “Global context variables”These variables are available in every template without configuration:
| Variable | Value | Example |
|---|---|---|
{{ now.date }} | Local date | 27/02/2026 |
{{ now.datetime }} | Local date and time | 27/02/2026 14:35 |
{{ now.iso }} | ISO 8601 format | 2026-02-27T14:35:00+00:00 |
{{ now.year }} | Year | 2026 |
{{ context.culture }} | Current culture (BCP 47) | fr-BE |
{{ context.culture_name }} | Culture display name | French (Belgium) |
{{ context.tenant_id }} | Current tenant ID | 3fa85f64-... or empty |
{{ context.tenant_name }} | Tenant name | Hospital Saint-Luc or empty |
Step 6 — Store and publish templates
Section titled “Step 6 — Store and publish templates”Templates follow a lifecycle: Draft -> Published -> Archived. Only published templates are used by the rendering pipeline.
public sealed class TemplateAdminService( IDocumentTemplateStoreReader storeReader, IDocumentTemplateStoreWriter storeWriter){ public async Task CreateAndPublishAsync( string html, CancellationToken cancellationToken) { var key = new TemplateKey("Billing.Invoice", Culture: "en");
// Save a draft await storeWriter.SaveDraftAsync( key, html, "text/html", "admin@example.com", cancellationToken);
// Publish the draft (invalidates HybridCache) await storeWriter.PublishAsync( key, "admin@example.com", cancellationToken); }
public async Task<IReadOnlyList<TemplateRevision>> GetAuditHistoryAsync( TemplateKey key, CancellationToken cancellationToken) { // ISO 27001 audit trail -- archived revisions are never deleted return await storeReader.GetHistoryAsync(key, cancellationToken); }}Embedded templates (fallback)
Section titled “Embedded templates (fallback)”For templates shipped with your assembly, mark them as embedded resources:
<ItemGroup> <EmbeddedResource Include="Templates/Billing.Invoice.html" LogicalName="$(RootNamespace).Templates.Billing.Invoice.html" /> <EmbeddedResource Include="Templates/Billing.Invoice.fr.html" LogicalName="$(RootNamespace).Templates.Billing.Invoice.fr.html" WithCulture="false" /></ItemGroup>Register them in DI:
builder.Services.AddEmbeddedTemplates(typeof(MyAppModule).Assembly);Step 7 — Render documents
Section titled “Step 7 — Render documents”Render an HTML email
Section titled “Render an HTML email”public sealed class WelcomeEmailService(ITextTemplateRenderer renderer){ public async Task<string> GetHtmlAsync( WelcomeEmailData data, CancellationToken cancellationToken) { RenderedTextResult result = await renderer.RenderAsync( new WelcomeEmailType(), data, cancellationToken);
return result.Html; }}Generate a PDF
Section titled “Generate a PDF”public sealed class InvoiceService(IDocumentGenerator generator){ public async Task<DocumentResult> GenerateInvoiceAsync( InvoiceData data, CancellationToken cancellationToken) { return await generator.GenerateAsync( new InvoiceTemplateType(), data, cancellationToken: cancellationToken); }}Generate a native Excel file
Section titled “Generate a native Excel file”The template is an .xlsx file stored as base64 in the store with the Excel
MIME type. The ClosedXmlTemplateEngine is selected automatically.
public sealed class ExcelReportService(IDocumentGenerator generator){ public async Task<DocumentResult> GenerateReportAsync( ReportData data, CancellationToken cancellationToken) { return await generator.GenerateAsync( new ExcelReportTemplateType(), data, cancellationToken: cancellationToken); }}Enriching template data
Section titled “Enriching template data”ITemplateDataEnricher<TData> lets you add computed data (QR codes, remote
blob URLs, signatures) before rendering. Enrichers return a new TData via
record with — the model is immutable.
public sealed class QrCodeEnricher : ITemplateDataEnricher<InvoiceData>{ public int Order => 10;
public Task<InvoiceData> EnrichAsync( InvoiceData data, CancellationToken cancellationToken = default) { var svg = QrCodeGenerator.Generate(data.InvoiceNumber); return Task.FromResult(data with { QrCodeSvg = svg }); }}Register enrichers in DI:
builder.Services.AddTemplateDataEnricher<InvoiceData, QrCodeEnricher>();Custom global contexts
Section titled “Custom global contexts”Add your own global variables available in every template:
public sealed class CompanyGlobalContext : ITemplateGlobalContext{ public string ContextName => "company";
public object Resolve() => new { name = "Acme Corp", vat_number = "BE0123456789" };}Register and use in templates as {{ company.name }}:
builder.Services.AddTemplateGlobalContext<CompanyGlobalContext>();PDF configuration
Section titled “PDF configuration”Configure PDF rendering options in appsettings.json:
{ "DocumentGeneration": { "Pdf": { "PaperFormat": "A4", "Landscape": false, "MarginTop": "10mm", "MarginBottom": "10mm", "MarginLeft": "10mm", "MarginRight": "10mm", "PrintBackground": true, "MaxConcurrentPages": 4, "ChromiumExecutablePath": null } }}For Docker deployments, install Chromium system-wide and set
ChromiumExecutablePath to /usr/bin/chromium.
Next steps
Section titled “Next steps”- Implement a workflow to add approval cycles to template publication (Draft -> PendingReview -> Published)
- Set up localization for culture-specific template resolution
- Templating reference for the complete API surface