@maildeno/renderer is a plain async function — no special NestJS wiring needed beyond wrapping it in a service and translating RenderError into Nest’s exception types.

Installation

npm install @maildeno/renderer

Requires the Node.js runtime — see a note on runtimes if you’re deploying anywhere other than a standard Node process.

A render service

// render/render.service.ts
import { Injectable, NotFoundException, BadRequestException, InternalServerErrorException } from "@nestjs/common";
import { render, RenderError } from "@maildeno/renderer";

export interface RenderTemplateInput {
  templateId: string;
  mergeTags?: Record<string, Record<string, string>>;
  context?: Record<string, string | number | boolean>;
}

@Injectable()
export class RenderService {
  private readonly baseDir = process.env.TEMPLATE_DIR ?? "templates";

  async renderTemplate({ templateId, mergeTags, context }: RenderTemplateInput): Promise<string> {
    try {
      return await render(`${templateId}.json`, {
        baseDir: this.baseDir,
        mergeTags,
        context,
      });
    } catch (err) {
      if (err instanceof RenderError) {
        switch (err.code) {
          case "TEMPLATE_NOT_FOUND":
            throw new NotFoundException(err.message);
          case "INVALID_TEMPLATE":
            throw new BadRequestException(err.message);
          case "RENDER_ERROR":
          default:
            throw new InternalServerErrorException(err.message);
        }
      }
      throw new InternalServerErrorException("RENDER_FAILED");
    }
  }
}

baseDir is set once, from configuration, rather than trusted per-request — templateId on its own can’t escape that directory even if it contains ../ or an encoded traversal sequence.

The controller

// render/render.controller.ts
import { Body, Controller, Post } from "@nestjs/common";
import { RenderService, RenderTemplateInput } from "./render.service";

@Controller("render")
export class RenderController {
  constructor(private readonly renderService: RenderService) {}

  @Post()
  async render(@Body() body: RenderTemplateInput) {
    const html = await this.renderService.renderTemplate(body);
    return { html };
  }
}

Add a DTO with class-validator decorators in place of the plain interface if you want request-shape validation before templateId ever reaches the service — not shown here since it’s identical to any other Nest endpoint.

Module wiring

// render/render.module.ts
import { Module } from "@nestjs/common";
import { RenderController } from "./render.controller";
import { RenderService } from "./render.service";

@Module({
  controllers: [RenderController],
  providers: [RenderService],
  exports: [RenderService],
})
export class RenderModule {}

Exporting RenderService lets other modules — a mailer module that sends the rendered HTML through Postmark or Resend, for instance — inject it directly instead of going through HTTP internally.

Where to go next