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:
- Consume only the IR — never read the original schema. The IR is your stable contract.
- Handle multiple pages — iterate over all pages in the result.
- Respect positioning — elements have explicit x/y coordinates. Use them to determine layout order.
- Use style properties — fontSize, fontWeight, color, and textAlign communicate visual intent.
- Keep it stateless — a renderer should be a pure function from
RenderResultto output.
Next steps
- Custom Functions — extend the expression engine
- Architecture — understand the full rendering pipeline
- Render API — the built-in HTML and PDF renderers