@ackplus/nest-dynamic-templates
v2.0.0
Published
A powerful and flexible dynamic template rendering library for NestJS applications with support for Nunjucks, Handlebars, EJS, Pug, MJML, Markdown, and more.
Readme
@ackplus/nest-dynamic-templates
Database-backed, multi-engine template rendering for NestJS — with error messages that actually tell you what went wrong.
Store templates in your database, render them at runtime with the engine of your choice (Nunjucks, Handlebars, EJS, Pug), and post-process the output as HTML, MJML, Markdown or plain text. Built for transactional email, notifications, PDFs and any per-tenant/per-locale content.
📖 Full documentation: https://ack-solutions.github.io/nest-dynamic-templates/
Why this library
- 🧩 Two-stage pipeline — a template engine interpolates your variables, then a language processor turns the result into final markup. Mix and match (e.g. Nunjucks → MJML).
- 🗄️ Database storage — templates and layouts live in your DB (TypeORM), versioned and editable at runtime.
- 🌍 Scope & locale resolution — ship
systemdefaults and let auser/tenant/organizationoverride them, per locale, with automatic fallback. - 🪶 Lightweight & lazy — only the engines you enable are loaded, so you only install the peers you actually use.
- 🛑 Diagnostic errors — when a render fails you get the missing variable, the source line, the context keys you passed, and an actionable hint — not a generic 500.
Install
npm install @ackplus/nest-dynamic-templates
# peer deps you always need:
npm install @nestjs/typeorm typeorm reflect-metadataThen install only the engines you enable:
| You enable | Install |
| --- | --- |
| njk (Nunjucks, default) | npm install nunjucks |
| hbs (Handlebars) | npm install handlebars |
| ejs | npm install ejs |
| pug | npm install pug |
| mjml (email) | npm install mjml |
| md (Markdown) | npm install marked |
| html, txt | nothing — built in |
Quick start
1. Register the module
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
NestDynamicTemplatesModule,
TemplateEngineEnum,
TemplateLanguageEnum,
} from '@ackplus/nest-dynamic-templates';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
// ...your db config
autoLoadEntities: true, // picks up the library's entities automatically
}),
NestDynamicTemplatesModule.forRoot({
isGlobal: true,
engines: {
template: [TemplateEngineEnum.NUNJUCKS],
language: [TemplateLanguageEnum.HTML, TemplateLanguageEnum.MJML],
},
}),
],
})
export class AppModule {}Prefer explicit entities (e.g. for migrations)? Import
NestDynamicTemplatesEntitiesand spread it into your TypeORMentitiesarray.
2. Create a template
import { Injectable } from '@nestjs/common';
import {
TemplateService,
TemplateEngineEnum,
TemplateLanguageEnum,
} from '@ackplus/nest-dynamic-templates';
@Injectable()
export class TemplatesSeeder {
constructor(private readonly templates: TemplateService) {}
seed() {
return this.templates.createTemplate({
name: 'welcome-email',
displayName: 'Welcome email',
scope: 'system',
locale: 'en',
subject: 'Welcome, {{ firstName }}!',
content: '<h1>Hello {{ firstName }}</h1><p>Thanks for joining.</p>',
engine: TemplateEngineEnum.NUNJUCKS,
language: TemplateLanguageEnum.HTML,
type: 'email',
});
}
}3. Render it
const { subject, content } = await this.templates.render({
name: 'welcome-email',
scope: 'system',
locale: 'en',
context: { firstName: 'Ada' },
});
// subject -> "Welcome, Ada!"
// content -> "<h1>Hello Ada</h1><p>Thanks for joining.</p>"Need to render a raw string without touching the DB? Use renderContent():
const html = await this.templates.renderContent({
content: 'Hi {{ name }}',
engine: TemplateEngineEnum.NUNJUCKS,
language: TemplateLanguageEnum.HTML,
context: { name: 'World' },
});Error handling
This is the headline feature. Every render failure throws a typed error with a structured details payload and the original error attached as cause. Missing-variable failures name the variable and list the context you actually passed:
import { TemplateRenderError, TemplateErrorCode, isTemplateError } from '@ackplus/nest-dynamic-templates';
try {
await templates.render({ name: 'welcome-email', context: { email: '[email protected]' } });
} catch (err) {
if (isTemplateError(err)) {
console.error(err.code); // 'TEMPLATE_RENDER_FAILED'
console.error(err.details.missingVariable); // 'firstName'
console.error(err.details.contextKeys); // ['email']
console.error(err.details.location); // { line: 1, column: 16 }
console.error(err.details.snippet); // '<h1>Hello {{ firstName }}</h1>...'
console.error(err.details.hint); // 'Pass "firstName" in the render context. ...'
}
}The thrown message reads:
Failed to render template "welcome-email" [njk → html, scope=system, locale=en]: variable "firstName" is undefined.Each error also extends the matching NestJS HTTP exception, so Nest's exception filter maps the right status automatically and your existing catch (NotFoundException) keeps working:
| Error | HTTP | code |
| --- | --- | --- |
| TemplateRenderError | 422 | TEMPLATE_RENDER_FAILED |
| TemplateNotFoundError | 404 | TEMPLATE_NOT_FOUND |
| TemplateInputError | 400 | TEMPLATE_INVALID_INPUT |
| TemplateForbiddenError | 403 | TEMPLATE_FORBIDDEN |
| TemplateConflictError | 409 | TEMPLATE_CONFLICT |
| TemplateEngineUnavailableError | 500 | TEMPLATE_ENGINE_UNAVAILABLE |
Configuration
NestDynamicTemplatesModule.forRoot({
isGlobal: true,
// Only these engines are loaded. Default: { template: ['njk'], language: ['html','mjml','txt'] }
engines: {
template: [TemplateEngineEnum.NUNJUCKS, TemplateEngineEnum.HANDLEBARS],
language: [TemplateLanguageEnum.HTML, TemplateLanguageEnum.MJML, TemplateLanguageEnum.MARKDOWN],
},
// Custom filters/helpers — available in EVERY template engine.
filters: {
formatDate: (d: Date, fmt: string) => /* ... */ '',
formatCurrency: (n: number, ccy: string) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: ccy }).format(n),
},
// Global values injected into every render (strings, numbers, objects or functions).
globals: {
brandName: 'Acme',
year: () => new Date().getFullYear(),
},
// Raw options forwarded to the underlying engine libraries.
engineOptions: {
template: { njk: { autoescape: true, trimBlocks: true } },
language: { mjml: { validationLevel: 'soft' } },
},
});Async configuration
NestDynamicTemplatesModule.forRootAsync({
isGlobal: true,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
engines: { template: ['njk'], language: ['html', 'mjml'] },
globals: { appUrl: config.get('APP_URL') },
}),
});Scope & locale resolution
render() resolves a template by trying, in order:
- requested scope + requested locale
- requested scope +
en - system scope + requested locale
- system scope +
en
This lets you ship system defaults and override them per tenant/user and per language. Only active templates (isActive: true) are resolved. Example:
// Falls back to the system template if this user has no override.
await templates.render({ name: 'welcome-email', scope: 'user', scopeId: userId, locale: 'fr' });Template fields
A template is a database row. The key fields (full reference with use cases in the docs):
| Field | Required | Default | Purpose |
| --- | --- | --- | --- |
| name | ✅ | — | Stable id you render by, e.g. welcome-email. |
| scope | — | system | Who owns this version — system (shared default) or a custom owner kind like tenant/user. |
| scopeId | — | null | Which owner inside the scope (e.g. the tenant id). Empty for system. |
| locale | — | en | Language variant; missing locales fall back to en. |
| engine | — | njk | Template engine: njk, hbs, ejs, pug. |
| language | — | null | Output processor: html, mjml, md, txt. |
| subject | — | null | Subject line (emails); rendered with the engine. |
| content | ✅ | — | The template body. |
| templateLayoutName | — | null | Layout to wrap this content in. |
| displayName | — | — | Human-friendly label for admin UIs. |
| type | — | null | Free category for grouping, e.g. email/sms. |
| previewContext | — | null | Sample data for previews (not used at render time). |
| isActive | — | true | Set false to disable without deleting. |
scope vs scopeId is the part to understand: scope is the kind of owner (system, or your own tenant/user/…), and scopeId is the exact owner (the tenant id). Together with name + locale they uniquely identify a template, and a render falls back from a specific override to the system default — so you only store overrides where they differ.
Layouts
Layouts are reusable wrappers (e.g. an email shell). The child content is injected where the layout references {{ content }}:
await layouts.createTemplateLayout({
name: 'email-shell',
displayName: 'Email shell',
engine: TemplateEngineEnum.NUNJUCKS,
language: TemplateLanguageEnum.HTML,
content: '<html><body><header>{{ brandName }}</header>{{ content }}</body></html>',
});
// Attach it to a template:
await templates.createTemplate({
name: 'welcome-email',
templateLayoutName: 'email-shell',
/* ...rest... */
});Services
TemplateService—render,renderContent,createTemplate,updateTemplate,overwriteSystemTemplate,deleteTemplate,getTemplates,findTemplate,getTemplateById.TemplateLayoutService— the same surface for layouts.TemplateConfigService— read-only accessor over the resolved config.TemplateEngineRegistryService— access the engine instances directly.
Migrating from v1
v2 is a focused redesign. See MIGRATION.md for the (short) list of breaking changes and how to update — most apps only need to rename enginesOptions to the new flat filters / globals / engineOptions.
License
MIT © AckPlus
