Skip to content

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.

  • 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
Terminal window
# Core templating with Scriban engine
dotnet add package Granit.Templating.Scriban
# EF Core store for template management
dotnet add package Granit.Templating.EntityFrameworkCore
# Document generation facade
dotnet 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.Excel
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 { }
// 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 facade
builder.Services.AddGranitDocumentGeneration();

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";
}

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>

These variables are available in every template without configuration:

VariableValueExample
{{ now.date }}Local date27/02/2026
{{ now.datetime }}Local date and time27/02/2026 14:35
{{ now.iso }}ISO 8601 format2026-02-27T14:35:00+00:00
{{ now.year }}Year2026
{{ context.culture }}Current culture (BCP 47)fr-BE
{{ context.culture_name }}Culture display nameFrench (Belgium)
{{ context.tenant_id }}Current tenant ID3fa85f64-... or empty
{{ context.tenant_name }}Tenant nameHospital Saint-Luc or empty

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);
}
}

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);
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;
}
}
public sealed class InvoiceService(IDocumentGenerator generator)
{
public async Task<DocumentResult> GenerateInvoiceAsync(
InvoiceData data, CancellationToken cancellationToken)
{
return await generator.GenerateAsync(
new InvoiceTemplateType(), data, cancellationToken: cancellationToken);
}
}

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);
}
}

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>();

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>();

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.