Advanced

Custom Renderer

Build custom renderers that consume the intermediate representation to produce any output format.

Overview

The NextReport Engine uses a two-phase architecture: first it produces an intermediate representation (IR) via renderReport, then a renderer converts that IR into a specific output format. The built-in renderers produce HTML and PDF, but you can create custom renderers for any format.

The RenderResult type

When you call renderReport(schema, data), you get back a RenderResult:

interface RenderResult {
  pages: Page[]
  metadata: {
    pageCount: number
    renderTimeMs: number
  }
}

interface Page {
  width: number // mm
  height: number // mm
  elements: ResolvedElement[]
}

interface ResolvedElement {
  type: 'text' | 'table'
  position: { x: number; y: number; width: number; height: number }
  content: string // Resolved (expressions evaluated)
  style: {
    fontSize?: number
    fontWeight?: string
    fontFamily?: string
    color?: string
    textAlign?: string
  }
}

The key insight: all expressions have already been resolved. The content field contains the final string, not the {{...}} template. The renderer’s job is purely visual — converting positioned, styled elements into the target format.

Example: CSV renderer

Here is a complete example that renders a report to CSV format:

import { renderReport } from '@nextreport/engine'
import type { RenderResult, Page, ResolvedElement } from '@nextreport/engine'

function renderToCsv(result: RenderResult): string {
  const rows: string[] = []

  for (const page of result.pages) {
    // Sort elements by vertical position, then horizontal
    const sorted = [...page.elements].sort((a, b) => {
      if (a.position.y !== b.position.y) return a.position.y - b.position.y
      return a.position.x - b.position.x
    })

    // Group elements into rows by y-position (within 2mm tolerance)
    let currentRow: ResolvedElement[] = []
    let currentY = -1

    for (const el of sorted) {
      if (currentY === -1 || Math.abs(el.position.y - currentY) > 2) {
        if (currentRow.length > 0) {
          rows.push(currentRow.map((e) => escapeCsv(e.content)).join(','))
        }
        currentRow = [el]
        currentY = el.position.y
      } else {
        currentRow.push(el)
      }
    }

    // Push the last row
    if (currentRow.length > 0) {
      rows.push(currentRow.map((e) => escapeCsv(e.content)).join(','))
    }
  }

  return rows.join('\n')
}

function escapeCsv(value: string): string {
  if (value.includes(',') || value.includes('"') || value.includes('\n')) {
    return `"${value.replace(/"/g, '""')}"`
  }
  return value
}

Using the custom renderer

const schema = {
  /* your report schema */
}
const data = {
  /* your data */
}

const result = renderReport(schema, data)
const csv = renderToCsv(result)

console.log(csv)
// Company Name,Invoice #INV-001
// Widget,2,$29.99,$59.98
// Gadget,1,$49.99,$49.99
// ,,,Total: $109.97

Example: Markdown renderer

function renderToMarkdown(result: RenderResult): string {
  const lines: string[] = []

  for (const page of result.pages) {
    const sorted = [...page.elements].sort((a, b) => {
      if (a.position.y !== b.position.y) return a.position.y - b.position.y
      return a.position.x - b.position.x
    })

    for (const el of sorted) {
      const text = el.content

      if (el.style.fontWeight === 'bold' && el.style.fontSize && el.style.fontSize >= 18) {
        lines.push(`# ${text}`)
      } else if (el.style.fontWeight === 'bold') {
        lines.push(`**${text}**`)
      } else {
        lines.push(text)
      }
    }
  }

  return lines.join('\n')
}

Design guidelines

When building a custom renderer:

  1. Consume only the IR — never read the original schema. The IR is your stable contract.
  2. Handle multiple pages — iterate over all pages in the result.
  3. Respect positioning — elements have explicit x/y coordinates. Use them to determine layout order.
  4. Use style properties — fontSize, fontWeight, color, and textAlign communicate visual intent.
  5. Keep it stateless — a renderer should be a pure function from RenderResult to output.

Next steps