You've got a content pipeline. Posts go out every day. Someone — maybe you — is manually opening Canva, editing a template, exporting a PNG, uploading it.
There's a better way.
In this tutorial I'll show you how to build a fully automated image generation pipeline using HTML + a rendering API. The same approach I use to generate hundreds of social images programmatically.
What we're building
A scheduled script (or n8n/Make.com workflow) that:
- Takes dynamic content (title, subtitle, colors)
- Injects it into an HTML template
- Sends it to a rendering API
- Gets back a pixel-perfect PNG — ready to upload or post
No Puppeteer. No headless Chrome to maintain. No design tools.
Why HTML for image generation?
HTML + CSS is actually the most powerful "design tool" most developers already know. You get:
- Full layout control with flexbox/grid
- Any Google Font
- Tailwind CSS (via CDN)
- Dynamic data injection with template literals
- Pixel-perfect consistency every time
The missing piece is rendering that HTML server-side into an image. That's what a render API does.
The API call
We'll use RenderPix — a server-side Chromium rendering API. Free tier includes 100 renders/month, no credit card needed.
curl -X POST https://renderpix.dev/v1/render \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{
"html": "<div style=\"background:#1a1a2e;color:white;width:1080px;height:1350px;display:flex;align-items:center;justify-content:center;font-size:72px;font-family:sans-serif\">Hello</div>",
"width": 1080,
"height": 1350,
"format": "png"
}' \
--output image.png
Returns a binary PNG. That's it.
Building the HTML template
Here's a production-ready Instagram portrait template (1080×1350):
function buildPostHTML({ title, subtitle, bgColor = '#0f172a', accentColor = '#22d3ee' }) {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1080px;
height: 1350px;
background: ${bgColor};
font-family: 'Inter', sans-serif;
color: white;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 100px;
position: relative;
}
.accent-bar {
width: 80px;
height: 6px;
background: ${accentColor};
border-radius: 3px;
margin-bottom: 48px;
}
h1 {
font-size: 80px;
font-weight: 900;
line-height: 1.05;
text-align: center;
margin-bottom: 36px;
letter-spacing: -0.02em;
}
p {
font-size: 36px;
font-weight: 400;
text-align: center;
color: rgba(255,255,255,0.65);
line-height: 1.5;
max-width: 800px;
}
.brand {
position: absolute;
bottom: 70px;
font-size: 28px;
font-weight: 700;
color: ${accentColor};
letter-spacing: 0.05em;
}
</style>
</head>
<body>
<div class="accent-bar"></div>
<h1>${title}</h1>
<p>${subtitle}</p>
<span class="brand">@yourbrand</span>
</body>
</html>`;
}
The render function
async function renderToImage(html, width = 1080, height = 1350) {
const response = 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, width, height, format: 'png' }),
});
if (!response.ok) {
throw new Error(`Render failed: ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
Putting it together — scheduled pipeline
import { writeFile } from 'fs/promises';
const posts = [
{
title: "Ship fast.\nLearn faster.",
subtitle: "The only way to build great products is to talk to real users.",
bgColor: "#0f172a",
accentColor: "#22d3ee"
},
{
title: "Done is better\nthan perfect.",
subtitle: "Launch early. Iterate often. That's the whole playbook.",
bgColor: "#1a0a2e",
accentColor: "#a78bfa"
}
];
async function generateAll() {
for (const [i, post] of posts.entries()) {
const html = buildPostHTML(post);
const buffer = await renderToImage(html);
await writeFile(`post-${i + 1}.png`, buffer);
console.log(`Generated post-${i + 1}.png`);
}
}
generateAll();
Run this with a cron job, GitHub Actions, or any scheduler.
Common social media sizes
| Platform | Width | Height | Notes |
|---|---|---|---|
| Instagram Portrait | 1080 | 1350 | Best for feed reach |
| Instagram Square | 1080 | 1080 | Classic format |
| Instagram Story | 1080 | 1920 | Full screen |
| Twitter/X Card | 1280 | 720 | Link preview |
| LinkedIn Post | 1200 | 627 | Feed image |
To generate multiple sizes in parallel:
const sizes = [
{ width: 1080, height: 1350, name: 'instagram-portrait' },
{ width: 1080, height: 1080, name: 'instagram-square' },
{ width: 1280, height: 720, name: 'twitter' },
];
const buffers = await Promise.all(
sizes.map(({ width, height }) => renderToImage(html, width, height))
);
Using Tailwind CSS
Works out of the box — just add the CDN script to your HTML head:
<head>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="w-[1080px] h-[1350px] bg-slate-900 flex flex-col items-center justify-center p-24">
<h1 class="text-8xl font-black text-white text-center leading-tight">
Your title here
</h1>
</body>
RenderPix uses server-side Chromium so the CDN script loads and executes before the screenshot is taken.
n8n workflow (no-code)
If you prefer no-code, here's the n8n setup:
Nodes: Schedule Trigger → Code → HTTP Request → Write Binary File
Code node — builds the HTML (same template as above, using $input.first().json for data)
HTTP Request node:
- Method:
POST - URL:
https://renderpix.dev/v1/render - Authentication: Header Auth →
X-API-Key: your_key - Body: JSON with
html,width,height,format - Response format:
File(binary)
Make.com scenario (no-code)
Modules: Schedule → Google Sheets: Get Row → HTTP: Make a Request → Google Drive: Upload
HTTP module config:
- URL:
https://renderpix.dev/v1/render - Method:
POST - Headers:
X-API-Key: your_key - Body type:
Raw / application/json - Response type:
Binary
Why not Puppeteer?
You could self-host Puppeteer or Playwright. But:
- Chromium binary is ~300MB
- Cold starts are slow (1-3s)
- Memory crashes on small servers
- You maintain it when it breaks
For a content pipeline that runs on schedule, a managed render API is simpler and more reliable. The free tier covers most personal projects. Paid plans start at $9/month for 2,000 renders.
Wrapping up
The full pattern:
- Build HTML with dynamic data injected via template literals
- POST to render API → get PNG buffer
- Save to disk, upload to S3, or pass to your posting tool
Works with any scheduler — cron, GitHub Actions, n8n, Make.com, Zapier.
If you build something with this, I'd love to see it. Drop a comment or find me at @renderpixdev.
RenderPix is what I built to solve this exact problem. Free tier available at renderpix.dev.
Top comments (0)