DEV Community

Cover image for Turn a JSON File of Testimonials into Quote Card Images with One Node.js Script
Accreditly
Accreditly

Posted on Originally published at html2img.com

Turn a JSON File of Testimonials into Quote Card Images with One Node.js Script

You have a folder of customer testimonials, or a spreadsheet of pull quotes, or a transcript with the best lines highlighted. What you do not have is forty quote card images, because someone has to open Figma forty times to make them.

This tutorial turns a JSON file of quotes into a folder of branded PNGs with one Node.js script and an HTML to Image API. If you want the longer version, with a custom-HTML layout and the reasoning behind the design choices, take a look at the full article on generating quote cards from an API. This post is the short, runnable path.

Prerequisites

  • Node.js 18 or later (we use the built-in fetch).
  • A free HTML to Image API key. Sign-up takes a minute and the free tier is enough to run everything here.
  • A testimonials.json file. Any shape works as long as each entry has the quote text and who said it.

Here's the one I'm using:

[
  {
    "quote": "We replaced a designer-in-the-loop process with one API call. The cards went out the same afternoon the quotes came in, and nobody could tell they were not hand made.",
    "name": "Priya Raman",
    "role": "Head of Product, Fieldline"
  },
  {
    "quote": "Onboarding used to take our team a week. It now takes an afternoon.",
    "name": "Tomas Vale",
    "role": "Operations Lead, Harbourline"
  }
]
Enter fullscreen mode Exit fullscreen mode

Step 1: Render one card by hand

Before writing the loop, make one request so you know what comes back. The quote card template takes the quote and the attribution as JSON and returns a hosted PNG:

curl -X POST https://app.html2img.com/api/v1/templates/quote-card \
  -H "X-API-Key: $HTML2IMG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "quote": "We replaced a designer-in-the-loop process with one API call. The cards went out the same afternoon the quotes came in, and nobody could tell they were not hand made.",
    "attribution_name": "Priya Raman",
    "attribution_role": "Head of Product, Fieldline",
    "brand_name": "Fieldline",
    "background_color": "#FAF7F0",
    "accent_color": "#2563EB"
  }'
Enter fullscreen mode Exit fullscreen mode

The response is small:

{
  "success": true,
  "template": "quote-card",
  "credits_remaining": 100,
  "url": "https://i.html2img.com/image-1789228709438-533456.png"
}
Enter fullscreen mode Exit fullscreen mode

And the image at that URL looks like this:

A square off-white quote card with a 30-word testimonial in large dark type, a blue rule, the attribution Priya Raman, Head of Product, Fieldline, and the brand name FIELDLINE at the foot

Two things to notice. The type has been sized to the quote automatically, so a ten-word quote comes back bigger rather than sitting in a sea of empty space. And the single accent_color value is the only branding you have to think about; it shows up as the rule and the brand name and nowhere else.

Step 2: Write the loop

Now the script. It reads the JSON, renders one card per entry, and writes the resulting URL back onto each record:

// make-cards.mjs
import { readFile, writeFile } from 'node:fs/promises';

const API_KEY = process.env.HTML2IMG_API_KEY;
const testimonials = JSON.parse(await readFile('testimonials.json', 'utf8'));

for (const t of testimonials) {
  const response = await fetch('https://app.html2img.com/api/v1/templates/quote-card', {
    method: 'POST',
    headers: {
      'X-API-Key': API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      quote: t.quote,
      attribution_name: t.name,
      attribution_role: t.role,
      brand_name: 'Fieldline',
      background_color: '#FAF7F0',
      accent_color: '#2563EB',
    }),
  });

  const data = await response.json();

  if (!data.success) {
    console.error(`Failed for ${t.name}:`, data.error, data.errors ?? '');
    continue;
  }

  t.card_url = data.url;
  console.log(`${t.name}: ${data.url} (${data.credits_remaining} credits left)`);
}

await writeFile('testimonials.json', JSON.stringify(testimonials, null, 2));
Enter fullscreen mode Exit fullscreen mode

Run it:

HTML2IMG_API_KEY=your_key node make-cards.mjs
Enter fullscreen mode Exit fullscreen mode

The important design choice is writing card_url back into the source file. That makes testimonials.json the single source of truth: the marketing site reads the URL for the testimonial wall, the social scheduler reads it for the post, and re-running the script only renders entries that are missing a URL if you add a one-line if (t.card_url) continue; at the top of the loop.

Step 3: Download the files (optional)

The hosted URL is usually all you need, because it goes straight into a CMS field or a scheduling tool. If you want local files as well, for a pitch deck or a case study PDF, add a download after each render:

import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';

async function download(url, path) {
  const res = await fetch(url);
  await pipeline(res.body, createWriteStream(path));
}

// inside the loop, after t.card_url = data.url;
const slug = t.name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
await download(data.url, `cards/${slug}.png`);
Enter fullscreen mode Exit fullscreen mode

The template renders at 2x, so each file is 2400x2400 and around 1 to 1.5 MB. That is what you want for a retina social feed. For something lighter, render your own markup through the HTML endpoint at the default DPI instead; the full article shows a 1200x630 dark testimonial layout that comes back under 200 KB:

A dark navy 1200x630 quote card with a blue quotation mark, a white serif testimonial, a blue monogram avatar reading PR and the attribution Priya Raman, Head of Product, Fieldline

Step 4: Keep the batch honest

A few lines that stop a batch from going wrong quietly:

  • Watch the credit count. Every response carries credits_remaining, so break out of the loop when it hits zero rather than collecting a string of 402s.
  • Log validation errors, don't throw. A 422 comes with an errors object naming the field. The script above prints it and moves on, so one bad entry does not stop the other thirty-nine.
  • Use a webhook for large batches. For hundreds of cards, pass a webhook_url with each request and let the API post the finished URL back to you instead of holding the connections open one at a time.

That is the whole pipeline: a JSON file in, a JSON file with image URLs out, and a folder of PNGs if you want them. The full write-up goes further into building your own card layout in HTML, sizing the type to the quote length yourself, and producing the square, link-preview and Story formats from the same data.

How are you making quote and testimonial graphics at the moment? Still by hand, or have you automated it another way? Let me know in the comments.

Top comments (0)