The problem with string interpolation

The obvious approach when generating dynamic images is to build the HTML string in your code, injecting values wherever they’re needed:

// Common first approach
const html = `
  <div style="font-family: sans-serif; padding: 40px; background: #fff;">
    <h1>${post.title}</h1>
    <p>by ${post.author} — ${post.date}</p>
    <div style="color: #666;">${post.category}</div>
  </div>
`;

const res = await fetch('https://renderpix.dev/v1/render', {
  method: 'POST',
  headers: { 'X-API-Key': key, 'Content-Type': 'application/json' },
  body: JSON.stringify({ html, width: 1200, height: 630 }),
});

This works for simple cases. But there are real problems once your templates grow or your stack gets more complex:

Template variables: the cleaner approach

The RenderPix API has a built-in solution: write {{key}} placeholders directly in your HTML, then pass a vars object with the values. The API substitutes them server-side before rendering.

Before — string interpolation
const html = `
  <div class="card">
    <h1>${escapeHtml(post.title)}</h1>
    <p>${escapeHtml(post.author)}</p>
    <span>${escapeHtml(post.category)}</span>
  </div>
`;

// Template is coupled to your JS
// Must sanitize every value
// Can't reuse across languages
After — template variables
// Template lives in its own file
// or database row
const html = `
  <div class="card">
    <h1>{{title}}</h1>
    <p>{{author}}</p>
    <span>{{category}}</span>
  </div>
`;

// Values stay out of the HTML
body: JSON.stringify({ html, vars: {
  title: post.title,
  author: post.author,
  category: post.category,
} })

How it works

The vars field accepts a flat key-value object. Before the HTML is passed to the renderer, every occurrence of {{key}} is replaced with the corresponding value. The substitution happens on the server, in a context that handles encoding correctly — you don’t need to escape values yourself.

const res = await fetch('https://renderpix.dev/v1/render', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.RENDERPIX_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    html: `<!doctype html><html><body style="margin:0;width:1200px;height:630px;
      background:#0f172a;display:flex;align-items:center;justify-content:center">
      <div style="color:#f8fafc;font-family:sans-serif;text-align:center;padding:60px">
        <div style="font-size:13px;color:#64748b;letter-spacing:4px;
          text-transform:uppercase;margin-bottom:16px">{{category}}</div>
        <h1 style="font-size:48px;font-weight:700;line-height:1.1;
          margin-bottom:20px">{{title}}</h1>
        <p style="font-size:20px;color:#94a3b8">by {{author}}</p>
      </div>
    </body></html>`,
    vars: {
      title: "10 Things I Learned Shipping My First SaaS",
      author: "Elif Yildiz",
      category: "Engineering",
    },
    width: 1200,
    height: 630,
    format: 'png',
  }),
});
const { image } = await res.json(); // base64 PNG

Key rule: The template HTML is a static string. It can live in a .html file, a database column, an environment variable — anywhere. The vars object is the only thing that changes between renders.

Storing templates separately from code

Once your template doesn’t have interpolation in it, it becomes a proper static asset. Some patterns that open up:

Load from file

import { readFileSync } from 'fs';

const ogTemplate = readFileSync('./templates/og-image.html', 'utf8');
// og-image.html contains {{title}}, {{author}}, {{category}}

async function generateOG(post) {
  const res = await fetch('https://renderpix.dev/v1/render', {
    method: 'POST',
    headers: { 'X-API-Key': process.env.RENDERPIX_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      html: ogTemplate,
      vars: { title: post.title, author: post.author, category: post.category },
      width: 1200, height: 630, format: 'png',
    }),
  });
  return res.json();
}

Store in a database

-- templates table
SELECT html FROM image_templates WHERE slug = 'og-default';

-- At render time
const { rows } = await db.query(
  'SELECT html FROM image_templates WHERE slug = $1', ['og-default']
);

body: JSON.stringify({
  html: rows[0].html,   // template with {{placeholders}}
  vars: { title, author, category },
  width: 1200, height: 630,
})

Now a designer or content editor can update the template without touching application code. They edit the HTML, save it back to the database, and the next render picks it up automatically.

Batch rendering with vars

Template variables become especially useful when combined with batch rendering. You write the template once, then render dozens of variations in a single request by passing a different vars object per item:

const students = [
  { name: 'Elif Yildiz',     course: 'Web Development', date: 'June 2026' },
  { name: 'Thomas Müller',   course: 'API Design',      date: 'June 2026' },
  { name: 'Sophie Dubois',   course: 'Data Security',   date: 'June 2026' },
];

const certTemplate = readFileSync('./templates/certificate.html', 'utf8');
// certificate.html: {{name}}, {{course}}, {{date}}

const res = await fetch('https://renderpix.dev/v1/batch', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.RENDERPIX_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    items: students.map(s => ({
      html: certTemplate,
      vars: { name: s.name, course: s.course, date: s.date },
      width: 1600, height: 1131, format: 'png',
    })),
  }),
});

const { results } = await res.json();
// results[0].image, results[1].image, results[2].image — one PNG each

Without template variables, you’d be building three separate HTML strings, interpolating values into each, and hoping nothing in a student’s name breaks the JSON payload. With vars, the template is sent once per item (efficiently) and only the data changes.

Using template variables in n8n

This is where template variables really shine for no-code workflows. In n8n, you can’t do JavaScript template literals in an HTTP Request node — but you can pass a vars object built from expression fields:

  1. Store your HTML template in a Set node or retrieve it from a database node. It contains raw {{name}} placeholders.
  2. In the RenderPix HTTP Request node, set the body to JSON. The html field is the static template. The vars field is an object built from n8n expressions: { "name": "{{ $json.name }}" } (n8n expressions, not RenderPix placeholders).
  3. RenderPix receives the static HTML and the populated vars object, does the substitution server-side, and returns the PNG.

Don’t confuse the two syntaxes. n8n uses {{ $json.field }} (with spaces) for its own expression language. RenderPix uses {{key}} (no spaces) in the HTML template. They serve different purposes: n8n expressions populate the vars object; RenderPix substitutes the vars values into the HTML.

What vars doesn’t do

A few things to be aware of:

Putting it together: a reusable OG image generator

Here’s a complete example — a small module that keeps the template in a file and exposes a single generateOG() function:

// lib/og.ts
import { readFileSync } from 'fs';
import { join } from 'path';

const template = readFileSync(join(process.cwd(), 'templates/og.html'), 'utf8');

interface OGParams {
  title: string;
  author: string;
  category?: string;
  date?: string;
}

export async function generateOG(params: OGParams): Promise<Buffer> {
  const res = await fetch('https://renderpix.dev/v1/render', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.RENDERPIX_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html: template,
      vars: {
        title:    params.title,
        author:   params.author,
        category: params.category ?? 'Blog',
        date:     params.date ?? new Date().toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }),
      },
      width: 1200,
      height: 630,
      format: 'png',
    }),
  });

  if (!res.ok) throw new Error(`RenderPix error: ${res.status}`);
  const { image } = await res.json();
  return Buffer.from(image.replace(/^data:image\/png;base64,/, ''), 'base64');
}

The templates/og.html file stays as plain HTML that any designer can open in a browser, edit, and commit. The generateOG() function never touches HTML — it only passes data.

Summary

Try template variables for free

100 renders/month, no credit card required. Write your HTML once, render it with any data.

Related Articles
Batch Certificate Generation with n8n Satori Alternative for Node.js — Full CSS, No JSX, No Puppeteer HTML to image in Node.js — without Puppeteer