Tailwind HTML to Image: Compile the CSS, Then Render
Render Tailwind HTML to PNG without the Play CDN: compile the classes at build time or per request, inline the CSS, and capture. Timings measured.
To render Tailwind HTML to an image, compile the Tailwind classes your template uses into plain CSS, put that CSS in a <style> element inside the HTML, and screenshot the page in a browser. Do not load the Play CDN script for this. It compiles the CSS inside the page at render time, which in our test made a cold render about six times slower and added a network dependency on every new browser. This post shows the build-time setup, the request-time setup for templates you do not know in advance, and the numbers behind that advice.
The page belongs to the HTML to image API cluster. If your template is a React component, read React component to image first; the Tailwind steps here work the same on its output.
Tested on 2026-10-18 with Node 22.18.0 on macOS 27.0.1 (Apple M1 Pro):
[email protected],@tailwindcss/[email protected],[email protected](Chrome for Testing 155.0.8059.79). Play CDN tests used@tailwindcss/[email protected]from jsDelivr.
Why the Play CDN is the wrong tool here
The Play CDN is a script that watches the DOM and generates CSS in the browser. Tailwind's own docs present it for development and prototypes. For screenshots, its cost lands in the worst place, the first render in a fresh browser:
- The browser downloads the script (a cold container has an empty HTTP cache).
- The script runs, scans the DOM and injects a stylesheet.
- Only then is the page styled.
We rendered the same template both ways, ten times each, with a new browser profile for every run so the HTTP cache was empty:
| Variant | HTML size | First render, median | Min / max | Second render in the same page, median |
|---|---|---|---|---|
| Compiled CSS inlined | 12,070 bytes | 78 ms | 64 / 91 ms | 79 ms |
Play CDN <script> |
1,560 bytes | 470 ms | 313 / 766 ms | 47 ms |
Times are setContent with waitUntil: 'load' plus the PNG capture. Once the script was cached, the Play CDN version was fast. But a screenshot worker does not get that luxury every time: new containers, new browser profiles and a hosted renderer all start with an empty cache. And every cold render now depends on jsDelivr answering.
In every run, the computed background of <main> was already the Tailwind colour when load fired, so the CDN did not produce unstyled captures in our test. The risk we measured is time and a third-party dependency, not broken images.
The template
A weekly digest card: a title and three metric tiles with a trend badge. All values pass through an escape function, because this kind of template is often filled with user or database text.
// templates/digest.mjs: a 1200 x 630 weekly digest card built with Tailwind classes. const esc = (s) => String(s) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); // Full class names in a lookup table, so the Tailwind scanner can find them. const TREND = { up: 'bg-emerald-100 text-emerald-800', down: 'bg-rose-100 text-rose-800', flat: 'bg-stone-200 text-stone-700', }; export function digestBody({ team, week, metrics }) { const cells = metrics.slice(0, 3).map((m) => ` <div class="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-900/5"> <p class="text-2xl text-stone-500">${esc(m.label)}</p> <p class="mt-3 text-6xl font-bold tracking-tight tabular-nums text-stone-900">${esc(m.value)}</p> <span class="mt-5 inline-block rounded-full px-4 py-1 text-xl font-semibold ${TREND[m.trend] ?? TREND.flat}">${esc(m.change)}</span> </div>`).join(''); return ` <main class="flex h-[630px] w-[1200px] flex-col bg-stone-100 px-20 py-16 font-sans"> <p class="text-2xl font-semibold uppercase tracking-widest text-teal-700">${esc(team)}</p> <h1 class="mt-2 text-7xl font-bold text-stone-900">Week ${esc(week)}</h1> <div class="mt-auto grid grid-cols-3 gap-8">${cells}</div> </main>`; }
// sample.mjs export const sample = { team: 'Platform team', week: 42, metrics: [ { label: 'Deploys', value: '138', change: '+12%', trend: 'up' }, { label: 'p95 build time', value: '4m 10s', change: '-8%', trend: 'down' }, { label: 'Open incidents', value: '2', change: 'no change', trend: 'flat' }, ], };
The TREND table is not style. It is how you keep classes visible to the compiler, as the next section shows.
Option 1: compile at build time
Use this when the set of templates is fixed and lives in your repository.
npm i -D [email protected] @tailwindcss/[email protected] npm i [email protected]
/* input.css */ @import "tailwindcss" source(none); @source "./templates";
npx @tailwindcss/cli -i input.css -o dist/digest.css --minify
The source(none) part is not optional in practice. Tailwind v4 detects sources automatically, starting from the working directory. In our test folder, a scratch script that contained the string bg-sky-500 made that class appear in the output CSS although no template used it. source(none) turns detection off, and @source names the folder you mean. The CLI then built the CSS in about 80 ms. The result was 10,040 bytes minified and 2,781 bytes gzipped.
Now inline the CSS and render:
// render-local.mjs import { readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; import { digestBody } from './templates/digest.mjs'; import { sample } from './sample.mjs'; const css = await readFile(new URL('./dist/digest.css', import.meta.url), 'utf8'); export const page = (body) => `<!doctype html> <html><head><meta charset="utf-8"><style>${css}</style></head> <body>${body}</body></html>`; const html = page(digestBody(sample)); const browser = await puppeteer.launch(); try { const tab = await browser.newPage(); await tab.setViewport({ width: 1200, height: 630, deviceScaleFactor: 2 }); await tab.setContent(html, { waitUntil: 'load' }); await writeFile('digest.png', await tab.screenshot({ type: 'png' })); console.log(`digest.png written; HTML is ${Buffer.byteLength(html)} bytes`); } finally { await browser.close(); }
This printed HTML is 11538 bytes and wrote a 2400 x 1260 PNG (a 1200 x 630 card at 2x). The h-[630px] and w-[1200px] arbitrary values make the <main> element exactly the card size, so the viewport and the design agree.
font-sans resolves to Tailwind's system font stack, which differs between macOS, Linux containers and Windows. For an image that must look the same everywhere, load a web font with @font-face in input.css and font-display: block, or embed it. Consistent fonts in headless rendering covers the details.
Dynamic class names: the silent failure
Here is a template helper that looks harmless:
// dyn/badge.mjs export const badge = (color) => `<span class="bg-${color}-500 px-3">x</span>`;
We compiled it with source(none) and @source "./dyn". The output had no .bg-sky-500 rule. The badge rendered with no background and no error anywhere, because the scanner reads source text and never sees the finished string bg-sky-500.
Two fixes:
- Map to full names, as the
TRENDtable does. The scanner findsbg-emerald-100in the source. - Safelist a pattern in the CSS input when the values are a known set:
@import "tailwindcss" source(none); @source "./dyn"; @source inline("bg-{sky,rose}-500");
With that line, both .bg-sky-500 and .bg-rose-500 were in the output.
Option 2: compile at request time
Some systems store templates in a database or let customers edit them. You cannot run the CLI on those at build time. The tailwindcss package exports compile(), which does the same work in process:
// runtime-compile.mjs: Tailwind CSS for HTML you only see at request time. import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; import { compile } from 'tailwindcss'; const require = createRequire(import.meta.url); const twDir = path.dirname(require.resolve('tailwindcss/package.json')); const loadStylesheet = async (id, base) => { const file = id === 'tailwindcss' ? path.join(twDir, 'index.css') : path.resolve(base, id); return { path: file, base: path.dirname(file), content: await readFile(file, 'utf8') }; }; // Every token in a class attribute is a candidate. Tailwind ignores tokens it does not know. const candidatesIn = (html) => [...new Set([...html.matchAll(/class="([^"]*)"/g)].flatMap((m) => m[1].split(/\s+/)).filter(Boolean))]; export async function cssFor(html) { // A fresh compiler per call: build() accumulates every class it has seen. const compiler = await compile('@import "tailwindcss";', { base: process.cwd(), loadStylesheet }); return compiler.build(candidatesIn(html)); }
Measured on the digest template:
| Step | Time |
|---|---|
compile() (parse Tailwind's CSS) |
16 ms first call |
build() with the digest classes |
7 ms first call, about 0.02 ms when repeated |
Fresh compile() + build(), median of 10 |
15 ms |
Why a fresh compiler per call, when reuse is faster? Because a compiler remembers every candidate. We called build(['bg-sky-500']) on the compiler that had already built the digest. The result still contained .grid-cols-3 and the rest of the digest rules. In a long-running server that reuses one compiler, every template's CSS would grow with every class any customer has ever used. If 15 ms per render is too much, cache the CSS by a hash of the sorted candidate list instead of reusing the compiler.
The regex reads only double-quoted class attributes. If your templates use single quotes or className in JSX output, extend it; extra tokens are harmless because Tailwind ignores names it does not know.
Render it with an API
The inlined HTML from either option is a complete document with no external CSS, which makes it a good fit for an HTML to image API. This sends it as a raw POST with Node's built-in fetch (the raw HTTP guide has the other languages):
// render-api.mjs import { readFile, writeFile } from 'node:fs/promises'; import { digestBody } from './templates/digest.mjs'; import { sample } from './sample.mjs'; const css = await readFile('dist/digest.css', 'utf8'); const html = `<!doctype html><html><head><meta charset="utf-8"><style>${css}</style></head> <body>${digestBody(sample)}</body></html>`; const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ html, viewport: { width: 1200, height: 630, scale: 2 }, output: { format: 'png' }, }), }); if (!res.ok) { const { error } = await res.json(); console.error(`${res.status} ${error.code}: ${error.message}`); process.exit(1); } await writeFile('digest.png', Buffer.from(await res.arrayBuffer())); console.log('CDN copy:', res.headers.get('x-cache-url'));
JSON.stringify handles every quote and newline in the HTML. JSON built by hand with string concatenation breaks on the first quote or newline in a template like this, and the API cannot read the request. The X-Cache-URL header is a public CDN link to the same image, which you can put straight into an og:image tag. This listing follows the documented request shape; it was not run against the live API for this post.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Element has no colour, no error | Class name built from a string | Map to full class names, or @source inline() |
| Unused classes in the CSS | Automatic source detection scanned the whole folder | @import "tailwindcss" source(none); plus explicit @source |
| CSS grows over hours in a server | One compile() result reused for every request |
New compiler per call, or cache by candidate list |
| Slow first render, fast later | Play CDN script downloaded and run in a cold browser | Compile and inline the CSS |
| Card has a white strip at the edge | Body margin, or viewport differs from the design size | h-[630px] w-[1200px] on the root and a matching viewport |
| Font differs between laptop and server | font-sans is a system font stack |
Load or embed a web font with font-display: block |
| API returns 400 for a valid-looking template | JSON assembled by hand, so a quote or newline breaks it | Use JSON.stringify |
| Arbitrary value class ignored | Space inside the brackets, such as w-[1200 px] |
No spaces inside [...] |
| Text blurry on retina screens | 1x capture | deviceScaleFactor: 2 or viewport.scale: 2 |
Summary
- Compile Tailwind, inline the CSS, then render. Keep the Play CDN for prototypes.
- Pin sources with
source(none)and@source, and keep class names whole. - For request-time templates,
compile()+build()costs about 15 ms; do not share one compiler across requests. - To skip running Chrome, send the compiled HTML to the HTML to image API. Get a free key and render the digest card with the request above.
For more on what the API does with your HTML (base URLs, fonts and payload size), see the HTML to image guide. For the other Node options on similar templates, see HTML to image in Node.js.
Related in this topic
- HTML to Image API: Render HTML and CSS to PNG Send HTML and CSS, get a PNG, JPEG, WebP or PDF back from real Chrome. Base URLs for assets, web fonts, transparent backgrounds and signed URLs.
- Best HTML to Image APIs (2026): 9 Options Compared The best HTML to image APIs compared: rendering engine, ready signals, fonts, delivery, public links and prices at three volumes, from each vendor's own docs.
- html2canvas Alternative: Render the DOM in Real Chromium We tested html2canvas 1.4.1 against real Chromium on 14 CSS features. See what it drops, when it is still the right tool, and how to move capture server-side.
- Convert a Webpage to an Image: 5 Methods, Measured Convert a webpage to a PNG, JPEG or WebP five ways, from a DevTools command to an API. Effort, cost and timings for each, plus the settings that matter.
- HTML to Image in Node.js: 4 Ways, Same 6 Templates HTML to image in Node.js with Puppeteer, node-html-to-image, satori and an API, run on the same six templates. Measured times, what broke, and code that works.
- HTML to Image in Python: imgkit, html2image, Playwright Convert HTML to an image in Python with html2image, Playwright or an API, compared on five templates with timings and renders, and why imgkit is a dead end.