<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Accreditly</title>
    <description>The latest articles on DEV Community by Accreditly (@accreditly).</description>
    <link>https://dev.to/accreditly</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1045142%2Fa534ea30-7b1e-4199-b7e1-87688d0761db.png</url>
      <title>DEV Community: Accreditly</title>
      <link>https://dev.to/accreditly</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/accreditly"/>
    <language>en</language>
    <item>
      <title>How HTML to PDF Conversion Actually Works: Rendering Engines, Text Objects and Fonts</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Thu, 03 Sep 2026 09:31:49 +0000</pubDate>
      <link>https://dev.to/accreditly/how-html-to-pdf-conversion-actually-works-rendering-engines-text-objects-and-fonts-103j</link>
      <guid>https://dev.to/accreditly/how-html-to-pdf-conversion-actually-works-rendering-engines-text-objects-and-fonts-103j</guid>
      <description>&lt;p&gt;Two PDFs can look identical on screen and be completely different files. In one you can select a line, search for an invoice number and print it sharp at 300 dpi. The other is a photograph of a page: nothing to select, nothing to find, and every zoom step makes the text softer. Both came from HTML. Both came from something calling itself an HTML to PDF converter. The difference is what happened in the last few milliseconds of the render.&lt;/p&gt;

&lt;p&gt;The full write-up, with the file internals and the font-embedding detail, is on the HTML to Image blog as &lt;a href="https://html2img.com/articles/how-html-to-pdf-conversion-works/" rel="noopener noreferrer"&gt;How HTML to PDF conversion actually works&lt;/a&gt;. This is the short version: the pipeline, what "selectable text" really means, where fonts go, and three commands that tell you in seconds which kind of PDF you have been handed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same pipeline, different last step
&lt;/h2&gt;

&lt;p&gt;A browser-based converter, whether that is headless Chrome on your own box, a Puppeteer script or a hosted API, runs exactly the pipeline it uses to put a page on your monitor. Parse HTML into a DOM. Resolve CSS into computed styles. Lay out every box in CSS pixels and break text into lines. Paint the result into a display list: fill this rect, stroke this border, draw these glyphs here.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd9fzmone23xpwpwfh3gr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd9fzmone23xpwpwfh3gr.png" alt="The render pipeline: parse, style, layout and paint are identical for PNG and PDF; only the final backend differs"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fork is stage five, when that display list is replayed into a backend. A raster backend replays it into pixels and writes a PNG. Chromium's PDF backend, part of the Skia graphics library, replays the same commands into PDF operators and writes a document. The commands are identical. What differs is what the backend is allowed to keep: text stays text, paths stay paths, fonts get subset and embedded, and only &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; content is stored as pixels.&lt;/p&gt;

&lt;p&gt;That is also why a Chrome PDF matches a Chrome PNG so closely, and why the two most common failures, missing fonts and rasterised pages, both happen at stage five.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pixels in, points out
&lt;/h2&gt;

&lt;p&gt;CSS measures in pixels at 96 per inch. PDF measures in points at 72 per inch. So every CSS pixel becomes 0.75 pt, and an A4 page is 794 by 1123 CSS pixels in layout and roughly 595 by 842 pt in the file. You can see the arithmetic in &lt;code&gt;pdfinfo&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdfinfo invoice.pdf
&lt;span class="go"&gt;Creator:         Chromium
Producer:        Skia/PDF m131
Tagged:          yes
Pages:           1
Page size:       595.92 x 841.92 pts (A4)
File size:       69467 bytes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unit change explains a rule that surprises people: DPI does nothing to a PDF. Raising the device pixel ratio makes a screenshot sharper because it adds pixels. A vector page has no pixels to add, so a 1x render and a 4x render of the same HTML produce identical vector content.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a text layer actually is
&lt;/h2&gt;

&lt;p&gt;People say a PDF "has a text layer" as if it were a transparent sheet over an image. That is how OCR repairs a scan, and it is where the phrase comes from, but a properly generated PDF has no such thing. The text is not a layer on the page. The text is the page.&lt;/p&gt;

&lt;p&gt;Inside a page's content stream you find text objects: select a font, set a position, draw a run of glyph IDs. Those IDs are indices into the embedded font, and on their own they mean nothing to a search box. What makes the text selectable is a second structure, the ToUnicode CMap, which maps each glyph ID back to the character it was drawn from. If it is missing, the text renders and scales perfectly but cannot be copied, and &lt;code&gt;pdftotext&lt;/code&gt; returns garbage.&lt;/p&gt;

&lt;p&gt;Run it on the invoice and every word comes back in reading order, including a word drawn with an inline &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt; &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; element, because Skia turns SVG text into the same text objects as HTML text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdftotext &lt;span class="nt"&gt;-layout&lt;/span&gt; invoice.pdf -
&lt;span class="gp"&gt;Northgate Coffee, invoice #&lt;/span&gt;1042
&lt;span class="go"&gt;Issued 3 September 2026. Payment due within 30 days.

 ITEM                              QTY   PRICE
&lt;/span&gt;&lt;span class="gp"&gt; Ethiopian Yirgacheffe, 1 kg       4     $&lt;/span&gt;72.00
&lt;span class="gp"&gt; Filter papers, box of 100         2     $&lt;/span&gt;9.50
&lt;span class="gp"&gt; Total                                   $&lt;/span&gt;81.50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How fonts get inside the file
&lt;/h2&gt;

&lt;p&gt;A PDF that names a font without carrying it is at the mercy of whichever machine opens it, and the line lengths shift with every substitution. So the backend embeds fonts, and to keep files small it subsets them: only the glyphs actually drawn go in. The six-letter prefix on the name is the subset tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdffonts invoice.pdf
&lt;span class="go"&gt;name                              type          emb sub uni
--------------------------------- ------------- --- --- ---
AAAAAA+ManropeExtraLight_800wght  Type 3        yes yes yes
BAAAAA+ManropeExtraLight_500wght  Type 3        yes yes yes
FAAAAA+JetBrainsMono-Medium       CID TrueType  yes yes yes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the three right-hand columns. &lt;code&gt;emb&lt;/code&gt; is whether the font data is in the file, &lt;code&gt;sub&lt;/code&gt; whether it was subset, &lt;code&gt;uni&lt;/code&gt; whether a ToUnicode map exists. You want &lt;code&gt;yes yes yes&lt;/code&gt; on every row.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;type&lt;/code&gt; column has a story in it. Both faces came from Google Fonts. JetBrains Mono was served as a static file and went in as &lt;code&gt;CID TrueType&lt;/code&gt;, the original outlines, the format every PDF tool likes. Manrope was served as a variable font, and Chrome embedded each instance as &lt;code&gt;Type 3&lt;/code&gt;, where every glyph is its own little drawing procedure. Still vector, still selectable, and every mainstream viewer handles it, but PDF/A validators and some accessibility checkers are unhappy about Type 3. If your documents have to pass those, serve static font files instead of variable ones. Nothing else changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vector where it can, raster where it must
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fikayn6cdiqdoku6xzg64.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fikayn6cdiqdoku6xzg64.png" alt="Side by side at 700% zoom: a vector text object stays crisp while a rasterised page dissolves into pixels"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Text, borders, gradients, shadows and inline SVG all go in as geometry. &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; go in as pixels at whatever resolution they arrived, which &lt;code&gt;pdfimages -list&lt;/code&gt; shows you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdfimages &lt;span class="nt"&gt;-list&lt;/span&gt; invoice.pdf
&lt;span class="go"&gt;page num type  width height  size ratio
   1   0 image    64    64    98B  0.8%
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One small logo, 98 bytes. That is what a healthy document's image list looks like. If you are printing, supply bitmaps at two or three times their CSS size, or supply them as SVG and let them go in as paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  The screenshot in a wrapper
&lt;/h2&gt;

&lt;p&gt;Now the file from the first paragraph. It comes from tools with no PDF backend at all: they screenshot the page and write the bitmap into a PDF container, one image per page. The three commands expose it instantly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdffonts wrapped.pdf
&lt;span class="go"&gt;name   type   encoding   emb sub uni
------ ------ ---------- --- --- ---

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdfimages &lt;span class="nt"&gt;-list&lt;/span&gt; wrapped.pdf
&lt;span class="go"&gt;page num type  width height  size ratio
   1   0 image  1654  2339   1.9M   38%

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdftotext wrapped.pdf -
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No fonts, because there is no text. One page-sized image per page. Nothing from &lt;code&gt;pdftotext&lt;/code&gt;. The file is often ten to thirty times larger, and it gets worse exactly where PDFs matter, which is printing: 1654 by 2339 is 200 ppi on A4, and body text goes soft on a laser printer.&lt;/p&gt;

&lt;p&gt;So "can I select the text" is the single best acceptance test for a converter. Not "does it look right", which every tool passes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three-command check
&lt;/h2&gt;

&lt;p&gt;All three ship with Poppler (&lt;code&gt;apt install poppler-utils&lt;/code&gt;, &lt;code&gt;brew install poppler&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdffonts  output.pdf          &lt;span class="c"&gt;# every row: emb yes, sub yes, uni yes&lt;/span&gt;
pdfimages &lt;span class="nt"&gt;-list&lt;/span&gt; output.pdf    &lt;span class="c"&gt;# one entry per real picture, no page-sized bitmaps&lt;/span&gt;
pdftotext output.pdf - | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="c"&gt;# your content, in reading order&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty &lt;code&gt;pdffonts&lt;/code&gt; means a screenshot in a wrapper. &lt;code&gt;emb: no&lt;/code&gt; means the document looks different on every machine. &lt;code&gt;uni: no&lt;/code&gt; means text that renders but cannot be searched. A page-sized image in &lt;code&gt;pdfimages&lt;/code&gt; means the page was rasterised.&lt;/p&gt;

&lt;p&gt;If you want to try it on a real file, the &lt;a href="https://html2img.com/tools/html-to-pdf" rel="noopener noreferrer"&gt;HTML to PDF converter&lt;/a&gt; returns an A4 PDF from the same Skia backend described above, and from code it is one field on an image request to the &lt;a href="https://html2img.com/html-to-pdf/" rel="noopener noreferrer"&gt;HTML to PDF API&lt;/a&gt;: add &lt;code&gt;"format": "pdf"&lt;/code&gt; and the response &lt;code&gt;url&lt;/code&gt; points at a &lt;code&gt;.pdf&lt;/code&gt;. The longer version on the blog covers screen versus print CSS, webfont timing, pagination and what actually drives file size: &lt;a href="https://html2img.com/articles/how-html-to-pdf-conversion-works/" rel="noopener noreferrer"&gt;How HTML to PDF conversion actually works&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What is the worst PDF you have been handed by a converter, and did &lt;code&gt;pdffonts&lt;/code&gt; come back empty? Share it in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>pdf</category>
      <category>html</category>
      <category>programming</category>
    </item>
    <item>
      <title>Convert HTML to PDF in Node.js With One API Call</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Wed, 26 Aug 2026 08:12:03 +0000</pubDate>
      <link>https://dev.to/accreditly/convert-html-to-pdf-in-nodejs-with-one-api-call-2fe4</link>
      <guid>https://dev.to/accreditly/convert-html-to-pdf-in-nodejs-with-one-api-call-2fe4</guid>
      <description>&lt;p&gt;You have an invoice, a receipt or a report that already exists as HTML and CSS, and you need a Node.js process to turn it into a PDF. Not a person pressing print, a process: a payment webhook, a queue worker, a cron job, an API route. The usual advice is Puppeteer, which means shipping a copy of Chrome with your application, pinning its version, feeding it fonts and watching its memory. That is a lot of infrastructure to own for a document.&lt;/p&gt;

&lt;p&gt;The alternative is one HTTP request. You send the markup with &lt;code&gt;format: "pdf"&lt;/code&gt; and get back a URL to an A4 vector PDF: selectable text, embedded webfonts, automatic pagination. This guide does that in Node.js with the official &lt;code&gt;@html2img/client&lt;/code&gt; package, then covers what a real integration needs on top: a template, downloading and attaching the file, fixed-width layouts, error handling and long documents.&lt;/p&gt;

&lt;p&gt;This is the dev.to edition of a guide that lives on the HTML to Image blog as &lt;a href="https://html2img.com/articles/html-to-pdf-nodejs/" rel="noopener noreferrer"&gt;Convert HTML to PDF in Node.js With One API Call&lt;/a&gt;. The code is the same in both places; the original is where the linked parameter docs are kept current.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one request gives you
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://html2img.com/html-to-pdf" rel="noopener noreferrer"&gt;HTML to PDF API&lt;/a&gt; is the same endpoint as image rendering with one extra parameter. Your HTML is rendered in current Chrome and returned as a real PDF rather than a screenshot wrapped in a PDF shell:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Text is selectable and searchable, so the document works with copy and paste, search and screen readers.&lt;/li&gt;
&lt;li&gt;Webfonts are embedded. Google Fonts, Adobe Fonts and self-hosted &lt;code&gt;@font-face&lt;/code&gt; all load server-side and travel with the file.&lt;/li&gt;
&lt;li&gt;Text, borders, shapes and gradients are vector, so they stay sharp at any zoom. Only &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; elements and canvases are embedded as pixels.&lt;/li&gt;
&lt;li&gt;Long content paginates automatically across as many A4 pages as it needs.&lt;/li&gt;
&lt;li&gt;Background colours, gradients and images are included by default.&lt;/li&gt;
&lt;li&gt;The response &lt;code&gt;url&lt;/code&gt; points at a &lt;code&gt;.pdf&lt;/code&gt; on the CDN, served as &lt;code&gt;application/pdf&lt;/code&gt;. A PDF costs the same single credit as an image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things to know before you write any markup. Pages are A4 portrait, with no US Letter, landscape or custom sizes, and there are no header and footer slots. And the document is rendered with your normal screen CSS: &lt;code&gt;@media print&lt;/code&gt; rules are not applied, so everything the PDF needs belongs in your standard styles. The &lt;a href="https://html2img.com/docs/parameters/format" rel="noopener noreferrer"&gt;format parameter docs&lt;/a&gt; cover the full behaviour, including which sizing parameters are ignored in PDF mode (&lt;code&gt;width&lt;/code&gt;, &lt;code&gt;height&lt;/code&gt;, &lt;code&gt;dpi&lt;/code&gt;, &lt;code&gt;fullpage&lt;/code&gt; and &lt;code&gt;selector&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 18 or newer, for the built-in global &lt;code&gt;fetch&lt;/code&gt;. Bun and Deno work too.&lt;/li&gt;
&lt;li&gt;An HTML to Image API key from the &lt;a href="https://app.html2img.com/register" rel="noopener noreferrer"&gt;dashboard&lt;/a&gt;. Every account starts with free credits and no card is needed.&lt;/li&gt;
&lt;li&gt;Some HTML you want as a PDF. The guide builds an invoice, but a receipt, report or certificate follows the same path.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: install the client and store your key
&lt;/h2&gt;

&lt;p&gt;The official package has no runtime dependencies and is built on the standard &lt;code&gt;fetch&lt;/code&gt; API, so it runs unchanged in Node.js 18 or newer, Bun, Deno and serverless runtimes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @html2img/client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get an API key from the dashboard and put it in the environment, without a public prefix. This is a server-side client: the key spends credits, so it never goes anywhere near browser code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;
&lt;span class="nv"&gt;HTML2IMG_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-api-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://html2img.com/docs/authentication" rel="noopener noreferrer"&gt;authentication docs&lt;/a&gt; cover rotating keys and checking a key with &lt;code&gt;GET /api/me&lt;/code&gt;, which does not consume a credit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: render your first PDF
&lt;/h2&gt;

&lt;p&gt;The whole conversion is one call. Set &lt;code&gt;format&lt;/code&gt; to &lt;code&gt;'pdf'&lt;/code&gt; and the response carries the URL of the finished document.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// render-pdf.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Html2img&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@html2img/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_API_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;
&amp;lt;html&amp;gt;
  &amp;lt;body style="font-family: sans-serif; padding: 48px"&amp;gt;
    &amp;lt;h1&amp;gt;Invoice #1042&amp;lt;/h1&amp;gt;
    &amp;lt;p&amp;gt;Due within 30 days.&amp;lt;/p&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// https://i.html2img.com/....pdf&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;creditsRemaining&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// credits left on the account&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;node --env-file=.env render-pdf.mjs&lt;/code&gt; on Node 20.6 or newer, or load the &lt;code&gt;.env&lt;/code&gt; file with &lt;code&gt;dotenv&lt;/code&gt; on older versions. The response is a typed &lt;code&gt;RenderResponse&lt;/code&gt; with &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;creditsRemaining&lt;/code&gt;, &lt;code&gt;expiresAt&lt;/code&gt; and &lt;code&gt;raw&lt;/code&gt;, which holds the full decoded JSON if you want the envelope as the API sent it.&lt;/p&gt;

&lt;p&gt;That is a working PDF. Everything from here is about making it a document you would put in front of a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: build the document from a template
&lt;/h2&gt;

&lt;p&gt;Keep the HTML in a function that takes your data and returns a complete document. Three rules make the output behave:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write a flowing layout, not a fixed one. The A4 page is roughly 794 CSS pixels wide and content reflows to that width, so use percentages and flex rather than a hard &lt;code&gt;width: 1200px&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Load fonts with a &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag or &lt;code&gt;@import&lt;/code&gt; in the head. They are fetched at render time and embedded in the PDF.&lt;/li&gt;
&lt;li&gt;Escape every value you interpolate. A customer called &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; should not become a script.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// invoice.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;escape&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;&amp;amp;&amp;lt;&amp;gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;quot;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;#39;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})[&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;money&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;invoiceHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`
    &amp;lt;tr&amp;gt;
      &amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;
      &amp;lt;td class="num"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;
      &amp;lt;td class="num"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;money&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;
      &amp;lt;td class="num"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;money&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;
    &amp;lt;/tr&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang="en"&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset="utf-8"&amp;gt;
  &amp;lt;link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&amp;amp;display=swap" rel="stylesheet"&amp;gt;
  &amp;lt;style&amp;gt;
    body { margin: 0; padding: 56px; font-family: 'Inter', sans-serif; font-size: 14px; color: #0f172a; }
    header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; }
    h1 { margin: 0 0 4px; font-size: 28px; }
    .muted { color: #64748b; }
    table { width: 100%; border-collapse: collapse; margin-top: 24px; }
    th { padding: 0 0 8px; border-bottom: 2px solid #e2e8f0; text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; }
    td { padding: 12px 0; border-bottom: 1px solid #e2e8f0; }
    .num { text-align: right; }
    tr { break-inside: avoid; }
    .total { display: flex; justify-content: flex-end; gap: 32px; margin-top: 24px; font-size: 18px; font-weight: 700; }
  &amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;header&amp;gt;
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;Invoice &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/h1&amp;gt;
      &amp;lt;div class="muted"&amp;gt;Issued &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issued&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; &amp;amp;middot; Due &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;due&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div&amp;gt;
      &amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/strong&amp;gt;&amp;lt;br&amp;gt;
      &amp;lt;span class="muted"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/header&amp;gt;

  &amp;lt;div&amp;gt;&amp;lt;span class="muted"&amp;gt;Billed to&amp;lt;/span&amp;gt;&amp;lt;br&amp;gt;&amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/strong&amp;gt;&amp;lt;/div&amp;gt;

  &amp;lt;table&amp;gt;
    &amp;lt;thead&amp;gt;
      &amp;lt;tr&amp;gt;&amp;lt;th&amp;gt;Description&amp;lt;/th&amp;gt;&amp;lt;th class="num"&amp;gt;Qty&amp;lt;/th&amp;gt;&amp;lt;th class="num"&amp;gt;Unit&amp;lt;/th&amp;gt;&amp;lt;th class="num"&amp;gt;Amount&amp;lt;/th&amp;gt;&amp;lt;/tr&amp;gt;
    &amp;lt;/thead&amp;gt;
    &amp;lt;tbody&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/tbody&amp;gt;
  &amp;lt;/table&amp;gt;

  &amp;lt;div class="total"&amp;gt;&amp;lt;span&amp;gt;Total due&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;money&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;tr { break-inside: avoid; }&lt;/code&gt; line is the one people forget. Without it, a line item that lands on a page boundary is cut in half, with its description at the foot of one page and its amount at the top of the next. Automatic pagination fills pages; it has no opinion about what belongs together, and you supply that opinion in CSS. &lt;a href="https://html2img.com/articles/html-to-pdf-page-breaks/" rel="noopener noreferrer"&gt;How to control page breaks in HTML to PDF output&lt;/a&gt; covers the full set of fragmentation properties.&lt;/p&gt;

&lt;p&gt;Because the renderer is real Chrome, the layout CSS you already use works: grid, flexbox, custom properties, &lt;code&gt;gap&lt;/code&gt;, modern selectors. &lt;a href="https://html2img.com/articles/html-to-pdf-css-support/" rel="noopener noreferrer"&gt;Can I use it in a PDF?&lt;/a&gt; compares that support against the other PDF engines if you are moving a template across from one of them.&lt;/p&gt;

&lt;p&gt;Now render it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// send-invoice.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Html2img&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@html2img/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;invoiceHtml&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./invoice.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_API_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INV-1042&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;issued&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;26 August 2026&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;due&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;25 September 2026&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Northgate Coffee&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;billing@northgate.example&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Riverside Bakery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Wholesale beans, 5kg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Delivery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;invoiceHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want to see what the markup will look like before spending a credit, paste it into the free &lt;a href="https://html2img.com/tools/html-to-pdf" rel="noopener noreferrer"&gt;HTML to PDF converter&lt;/a&gt;. It runs the same renderer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: download the file, attach it, or keep the URL
&lt;/h2&gt;

&lt;p&gt;The response carries a hosted URL rather than the bytes, which is usually what you want: store it against the order and link to it from the account page. When you need the file itself, for an email attachment or your own storage, fetch it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;writeFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;invoiceHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`invoices/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.pdf`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same buffer goes straight into a mail attachment. With Nodemailer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;transporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accounts@riverside.example&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Invoice &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your invoice is attached.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.pdf`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail to build in: on the free tier, renders expire and &lt;code&gt;response.expiresAt&lt;/code&gt; tells you when. On paid plans it is &lt;code&gt;null&lt;/code&gt; and the file is hosted permanently. If a free-tier document needs to outlive that window, download a copy as above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: serve it from an API route
&lt;/h2&gt;

&lt;p&gt;In a long-running Node server, build the client once at module scope and reuse it. Return the URL rather than proxying the bytes: the CDN serves the file better than your process will.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Html2imgError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@html2img/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;invoiceHtml&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./invoice.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_API_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/invoices/:id/pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;invoiceHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;Html2imgError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same shape works in a Next.js route handler, a Nuxt server route or a Lambda handler. The &lt;a href="https://html2img.com/integrations/javascript" rel="noopener noreferrer"&gt;JavaScript integration guide&lt;/a&gt; has each of those written out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixed-width layouts: scale the design to the page
&lt;/h2&gt;

&lt;p&gt;Some documents are not flowing text. A certificate, a wide comparison table or a dashboard export is a fixed-width design, and if it is wider than the A4 page a plain PDF render crops it at the right edge. The &lt;code&gt;scale_to_fit&lt;/code&gt; parameter fixes that: the renderer lays the content out at its natural width, then scales the whole layout down to fit the page. The scaling is vector, so text stays selectable and sharp, and any trailing blank page is trimmed.&lt;/p&gt;

&lt;p&gt;As of version 2.0.0, &lt;code&gt;scale_to_fit&lt;/code&gt; is not on the SDK's option types, so send that one request with plain &lt;code&gt;fetch&lt;/code&gt;. This is also the whole API without a dependency: one POST, one header.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://app.html2img.com/api/html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-API-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;certificateHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attendee&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;scale_to_fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Render failed with &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave &lt;code&gt;scale_to_fit&lt;/code&gt; off for the invoice above. A document written to flow reads better reflowed to the page width at full size than scaled down. The &lt;a href="https://html2img.com/docs/parameters/scale-to-fit" rel="noopener noreferrer"&gt;scale_to_fit docs&lt;/a&gt; explain the measuring step and when it applies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle the errors that will actually happen
&lt;/h2&gt;

&lt;p&gt;Every failed request rejects with an &lt;code&gt;Html2imgError&lt;/code&gt;, and the useful cases have their own subclass. The distinction that matters is between failures that are worth retrying and failures that are not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Html2imgError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;InsufficientCreditsError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@html2img/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;invoiceHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveInvoiceUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 400 or 422: the request is wrong, retrying will not help&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rejected fields:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;InsufficientCreditsError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 402: nothing will render until the account is topped up&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Out of credits (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;creditsRemaining&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; left)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 504: the render exceeded the 30 second budget, resend with a webhook&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;Html2imgError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 5xx or a connection failure: retry with backoff&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errorCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client deliberately does not retry on its own. A 5xx or a dropped connection is worth a second attempt; a 4xx is not, and a retry loop around a validation error just spends credits on the same bad request. If you want a retry policy, pass your own &lt;code&gt;fetch&lt;/code&gt; in the constructor options and put the backoff there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Long reports: take the render off the request
&lt;/h2&gt;

&lt;p&gt;A synchronous render has a 30 second budget and the client waits 35 by default. An invoice is nowhere near that. A 40-page report with large tables can be, and several serverless platforms cut a function off well before either limit. For those, pass a &lt;code&gt;webhookUrl&lt;/code&gt;. The API answers immediately with a &lt;code&gt;processing&lt;/code&gt; status and the render id, then POSTs the finished URL to your endpoint when the document is ready.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;monthlyReportHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;webhookUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://your-app.example.com/hooks/html2img?token=shared-secret&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isProcessing&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;renders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;report&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your endpoint receives a JSON body with &lt;code&gt;status&lt;/code&gt;, the &lt;code&gt;url&lt;/code&gt; on success or an &lt;code&gt;error&lt;/code&gt; message on failure, and a &lt;code&gt;log_id&lt;/code&gt; that matches the &lt;code&gt;id&lt;/code&gt; from the initial response, which is how you tie the callback back to the report. Return a 200 within a few seconds and do the rest asynchronously. Delivery is attempted once and the API sends no custom headers, so put a secret in the URL as above. The &lt;a href="https://html2img.com/docs/parameters/webhook-url" rel="noopener noreferrer"&gt;webhook_url docs&lt;/a&gt; have the exact payloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are no longer maintaining
&lt;/h2&gt;

&lt;p&gt;The list of things this integration does not contain is the point of it. There is no Chrome binary in the deploy and no &lt;code&gt;puppeteer-core&lt;/code&gt; and &lt;code&gt;@sparticuz/chromium&lt;/code&gt; pair to keep in step every time Chrome ships a major version. There is no font manifest to curate so that a customer's name does not render as hollow rectangles. There is no memory tuning, no crash recovery and no cold-start budget spent booting a browser. &lt;a href="https://html2img.com/articles/puppeteer-html-to-pdf-serverless/" rel="noopener noreferrer"&gt;Why HTML to PDF with Puppeteer keeps breaking on serverless&lt;/a&gt; goes through each of those failures in detail if you are currently living with them.&lt;/p&gt;

&lt;p&gt;The trade is stated plainly on the product page: the PDF is built for parity with the image renderer, so it looks exactly like the PNG of the same input would. If your project needs US Letter, landscape pages, running headers and footers or print stylesheets, a dedicated PDF service is the better fit, and &lt;a href="https://html2img.com/articles/generate-pdf-from-html/" rel="noopener noreferrer"&gt;The complete guide to generating PDFs from HTML&lt;/a&gt; compares every route, including the ones that count against us. For the invoice, receipt, certificate or report that already exists as HTML in your application, the whole job is the one call at the top of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;p&gt;You installed &lt;code&gt;@html2img/client&lt;/code&gt;, sent an HTML string with &lt;code&gt;format: 'pdf'&lt;/code&gt; and got back a URL to an A4 vector PDF with selectable text and embedded fonts. From there you built the document from a template with &lt;code&gt;break-inside: avoid&lt;/code&gt; on the rows, downloaded the file for an email attachment, put the render behind an Express route, scaled a fixed-width design onto the page with &lt;code&gt;scale_to_fit&lt;/code&gt;, handled the errors that are worth retrying separately from the ones that are not, and moved long reports onto a webhook. Nothing in that list installs a browser.&lt;/p&gt;

&lt;p&gt;The full guide, with the parameter docs it links to, is at &lt;a href="https://html2img.com/articles/html-to-pdf-nodejs/" rel="noopener noreferrer"&gt;Convert HTML to PDF in Node.js With One API Call&lt;/a&gt; on the HTML to Image blog.&lt;/p&gt;

&lt;p&gt;How are you generating PDFs from Node at the moment? If you are still running Puppeteer for it, what does it cost you to keep it working? Share your setup in the comments.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>pdf</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Tested 7 HTML to PDF APIs Against the Same Awkward Invoice</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Sun, 23 Aug 2026 10:55:41 +0000</pubDate>
      <link>https://dev.to/accreditly/i-tested-7-html-to-pdf-apis-against-the-same-awkward-invoice-8nb</link>
      <guid>https://dev.to/accreditly/i-tested-7-html-to-pdf-apis-against-the-same-awkward-invoice-8nb</guid>
      <description>&lt;p&gt;Every "best HTML to PDF API" list you will find is written by a vendor. So is this one: I run &lt;a href="https://html2img.com/html-to-pdf" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt;, and it is one of the seven services below. The difference is that I am telling you in the second sentence, and every service gets judged against the same fixed document instead of a feature grid copied from marketing pages.&lt;/p&gt;

&lt;p&gt;This is a cross-post of &lt;a href="https://html2img.com/articles/best-html-to-pdf-api/" rel="noopener noreferrer"&gt;the full comparison on the HTML to Image blog&lt;/a&gt;, which is the canonical version if you want to link to it.&lt;/p&gt;

&lt;p&gt;"Best" is the wrong question anyway. The right question is: best for &lt;em&gt;which document, at which volume&lt;/em&gt;? A one-page receipt and a 40-page annual report with running headers are different jobs, and the service that wins one loses the other. So I built one deliberately awkward invoice, worked out exactly what it demands from a rendering engine, and put seven services in front of it: HTML to Image, DocRaptor, PDFShift, Api2Pdf, PDFCrowd, PDFMonkey, and self-hosted Gotenberg as the benchmark for doing it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invoice that does the judging
&lt;/h2&gt;

&lt;p&gt;The test document is a two-page supplier invoice: 38 line items, a totals block, small print. Ordinary on the surface, and booby-trapped underneath. Every feature in it was chosen because at least one engine in this market handles it badly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpw4ouvgydj3811mqr3yz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpw4ouvgydj3811mqr3yz.png" alt="Page one of the test invoice rendered by the HTML to Image API: a flexbox header with an SVG logo, a CSS grid billing block, and the start of a 38-row line item table in the Inter webfont" width="800" height="1131"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is what it quietly demands:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A webfont.&lt;/strong&gt; The whole document is set in Inter, loaded from Google Fonts. The engine has to fetch it, render with it, and embed it in the PDF, or the text layer falls back to something ugly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flexbox.&lt;/strong&gt; The header is a flex row. Trivial in a browser, fatal in anything built on 2015-era WebKit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSS Grid.&lt;/strong&gt; The billing block is a three-column grid. This one matters because one major commercial engine still does not support Grid at all. More on that below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom properties.&lt;/strong&gt; Brand colours live in &lt;code&gt;:root&lt;/code&gt; variables. Legacy engines resolve none of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A table that crosses the page boundary.&lt;/strong&gt; 38 rows will not fit on one A4 page, so pagination behaviour is on show, including whether &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt; repeats on page two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;break-inside: avoid&lt;/code&gt;.&lt;/strong&gt; Rows and the totals block must not split across pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page numbers via &lt;code&gt;@page&lt;/code&gt; margin boxes.&lt;/strong&gt; The stylesheet asks for &lt;code&gt;"Page " counter(page) " of " counter(pages)&lt;/code&gt; in the bottom-right margin of every page. Historically this separated the CSS Paged Media engines from the browser engines. In 2026 the answer is more interesting than that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript.&lt;/strong&gt; The subtotal, tax and total are computed by a script at render time. An engine that does not execute JavaScript prints an invoice with dashes where the money should be.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trap CSS, in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@page&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;A4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;18mm&lt;/span&gt; &lt;span class="m"&gt;16mm&lt;/span&gt; &lt;span class="m"&gt;22mm&lt;/span&gt; &lt;span class="m"&gt;16mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="err"&gt;@bottom-right&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Page "&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="s1"&gt;" of "&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;@bottom-left&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Fieldgate Studio Ltd · INV-2026-0847"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="nt"&gt;tbody&lt;/span&gt; &lt;span class="nt"&gt;tr&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.totals&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a service renders this invoice correctly, it will handle the overwhelming majority of transactional documents: receipts, statements, packing slips, certificates, simple reports. If it fails, you now know exactly where.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the judging works
&lt;/h2&gt;

&lt;p&gt;Full disclosure of method, because this is where vendor lists usually go quiet.&lt;/p&gt;

&lt;p&gt;I ran the invoice through my own API and inspected the output with &lt;code&gt;pdfinfo&lt;/code&gt;, &lt;code&gt;pdffonts&lt;/code&gt; and &lt;code&gt;pdftotext&lt;/code&gt;. Those results are reported below as tested facts. For the other six services, I did not create six accounts and pretend to run a neutral lab. Instead, each service is judged on the engine it runs, that engine's documented CSS support, and its published pricing, all checked in August 2026. Engines are honest in a way marketing pages are not: if a service renders with Prince, Prince's documented behaviour is its behaviour. If it renders with Chromium, the invoice behaves the way it behaves in Chrome.&lt;/p&gt;

&lt;p&gt;One tested finding is worth pulling out before the scorecard, because most advice online has not caught up with it. The classic rule was that Chromium-based PDF generation cannot do CSS page numbers, and that you needed Prince, WeasyPrint, or a vendor's header and footer templates to get them. That rule died in November 2024: Chromium 131 shipped support for &lt;code&gt;@page&lt;/code&gt; margin boxes, including &lt;code&gt;counter(page)&lt;/code&gt; and &lt;code&gt;counter(pages)&lt;/code&gt;, and the &lt;a href="https://developer.chrome.com/release-notes/131" rel="noopener noreferrer"&gt;Chrome 131 release notes&lt;/a&gt; document it. My test render came back stamped &lt;code&gt;Skia/PDF m131&lt;/code&gt;, and both pages carried a correct "Page 1 of 2" and "Page 2 of 2" in the bottom margin, from plain CSS, with no vendor-specific footer syntax. Whether &lt;em&gt;your&lt;/em&gt; provider gives you this depends entirely on which Chromium build it has pinned. It is now a question worth asking every vendor on this list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scorecard
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Flexbox + Grid&lt;/th&gt;
&lt;th&gt;CSS page numbers&lt;/th&gt;
&lt;th&gt;Runs JS&lt;/th&gt;
&lt;th&gt;Starting price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTML to Image&lt;/td&gt;
&lt;td&gt;Current Chrome&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, tested&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$9/mo, 1,000 renders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DocRaptor&lt;/td&gt;
&lt;td&gt;Prince&lt;/td&gt;
&lt;td&gt;Flexbox yes, Grid no&lt;/td&gt;
&lt;td&gt;Yes, full Paged Media&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;$15/mo, 125 docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFShift&lt;/td&gt;
&lt;td&gt;Chromium&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Via header/footer templates&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$24/mo, 2,500 credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Api2Pdf&lt;/td&gt;
&lt;td&gt;Chrome, wkhtmltopdf or LibreOffice&lt;/td&gt;
&lt;td&gt;Yes on the Chrome route&lt;/td&gt;
&lt;td&gt;Via header/footer options&lt;/td&gt;
&lt;td&gt;Yes on the Chrome route&lt;/td&gt;
&lt;td&gt;$1/mo + usage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFCrowd&lt;/td&gt;
&lt;td&gt;Chromium-based converters&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Via header/footer parameters&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$23/mo, 1,000 credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFMonkey&lt;/td&gt;
&lt;td&gt;Chromium&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Template-driven workflow&lt;/td&gt;
&lt;td&gt;Yes, on paid plans&lt;/td&gt;
&lt;td&gt;€5/mo, 300 docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gotenberg (self-hosted)&lt;/td&gt;
&lt;td&gt;Chromium + LibreOffice&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Via header/footer files&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Free, plus your server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of this post is each service against the invoice, strengths and failures both.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTML to Image
&lt;/h2&gt;

&lt;p&gt;Mine, so read this section with the scepticism it deserves. Here is what the API did with the test document, verified rather than claimed.&lt;/p&gt;

&lt;p&gt;The invoice came back as a two-page A4 vector PDF, 230KB, with Inter embedded as subsetted fonts and a real text layer throughout: every line item, and the JavaScript-computed totals, are selectable and searchable in the output. The &lt;code&gt;break-inside&lt;/code&gt; rules held, so no row split across the boundary and the totals block arrived intact on page two. The &lt;code&gt;@page&lt;/code&gt; margin boxes rendered, which honestly surprised me too, because my own comparison pages have been telling people the service does not do page numbers. The renderer is current Chrome, Chrome is now past 131, so &lt;code&gt;counter(page)&lt;/code&gt; simply works.&lt;/p&gt;

&lt;p&gt;Two failures, stated plainly. First, the &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt; did not repeat on page two; the 38-row table just continues. That is a known Chromium PDF behaviour and the fix is repeating the header yourself, not a parameter. Second, the scope is deliberately narrow: A4 portrait only, no US Letter, no landscape, no watermarking, no encryption, no merging, and your &lt;code&gt;@media print&lt;/code&gt; styles are ignored because rendering uses screen CSS. The whole PDF surface is one &lt;code&gt;format&lt;/code&gt; parameter on the same endpoint that renders PNGs.&lt;/p&gt;

&lt;p&gt;What you get in exchange for that narrowness: one credit per render regardless of document size, output hosted on a CDN URL that stays live on paid plans, the same API key rendering both images and PDFs, and pricing that starts at $9 for 1,000 renders and reaches 100,000 for $300. If your documents are transactional and A4, this is the cheap, boring option. If they are not, one of the next five services is your answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  DocRaptor
&lt;/h2&gt;

&lt;p&gt;DocRaptor is the only service here that does not render with a browser engine, and that single fact decides everything about it. It runs &lt;a href="https://www.princexml.com/" rel="noopener noreferrer"&gt;Prince&lt;/a&gt;, the commercial CSS Paged Media engine, and Prince remains the best print typographer in the business: real headers and footers, page counters, footnotes, cross-references, PDF bookmarks, mixed page sizes within one document, and tagged, accessible output meeting WCAG 2.0, Section 508 and ISO-14289. DocRaptor holds SOC2 and HIPAA compliance on top. For a regulated 60-page financial statement, nothing else on this list is a serious rival.&lt;/p&gt;

&lt;p&gt;Against this invoice, though, Prince trips on trap number three. Prince's own documentation is upfront that it does not support CSS Grid, so the three-column billing block would need rewriting as flexbox or floats before this invoice renders correctly. JavaScript support exists but is Prince's own engine rather than a browser, so scripts that lean on browser APIs need testing rather than trusting. If your templates were written for Chrome, budget for a porting pass.&lt;/p&gt;

&lt;p&gt;Two more honest observations. DocRaptor's trial policy is the best in the market: test documents are watermarked but unlimited and free forever, so you can develop against it indefinitely without paying. And its pricing is the steepest here by a distance: &lt;a href="https://docraptor.com/plans" rel="noopener noreferrer"&gt;$15 a month covers 125 documents&lt;/a&gt;, $149 covers 5,000, and 40,000 costs $1,000. You are paying for Prince, and for many document types Prince is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  PDFShift
&lt;/h2&gt;

&lt;p&gt;PDFShift is the straightforward one: a French-run, Chromium-based converter that has been doing exactly this job for years without trying to become a platform. The invoice renders correctly because current Chromium renders it correctly, and PDFShift layers on page-level controls: page sizes including Letter and custom dimensions, landscape, encryption, watermarks, custom headers and footers with page numbers, and delivery straight to your own S3 bucket. Asynchronous mode and parallel processing are there for batch work.&lt;/p&gt;

&lt;p&gt;The pricing model needs one careful read: credits are metered by output size, one credit per 5MB, so a 14MB catalogue costs three credits while this 230KB invoice costs one. The free tier is a genuine 50 conversions a month, the published Boost plan is $24 for 2,500 credits with overage at $0.03, and the ladder above that is not published, so high-volume teams are quoting rather than reading a price list. For a team that wants Chromium fidelity plus real PDF options at a sane price, PDFShift is the safe default in the middle of this market.&lt;/p&gt;

&lt;h2&gt;
  
  
  Api2Pdf
&lt;/h2&gt;

&lt;p&gt;Api2Pdf does something rare: it tells you precisely what it runs. Headless Chrome, wkhtmltopdf and LibreOffice are all surfaced as explicit choices, alongside merging, barcodes and thumbnails. That candour cuts both ways. The Chrome route renders the invoice as well as any Chromium service here. The wkhtmltopdf route fails it comprehensively, because &lt;a href="https://github.com/wkhtmltopdf/wkhtmltopdf" rel="noopener noreferrer"&gt;wkhtmltopdf is an archived project&lt;/a&gt; rendering with a WebKit build that predates flexbox-era CSS; it is still offered for legacy compatibility, not for new work. Pick the Chrome engine and never look back.&lt;/p&gt;

&lt;p&gt;The LibreOffice engine is the genuinely differentiating piece: Api2Pdf converts Word, Excel and PowerPoint files to PDF, which nothing else on this list attempts. And the &lt;a href="https://www.api2pdf.com/pricing/" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; is pay-as-you-go taken seriously: $1 a month base, $0.001 per MB of bandwidth, $0.00019551 per second of compute. At this invoice's real weight of 230KB and roughly two seconds of render time, 2,500 invoices a month costs about $2.60. That is not a typo, and it makes Api2Pdf the cheapest metered option in the market by an order of magnitude. The trade is forecastability and the absence of a hosting layer: you get bytes back, and storage is your problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  PDFCrowd
&lt;/h2&gt;

&lt;p&gt;PDFCrowd has been converting HTML since 2009 and its API reads like it: over one hundred configuration parameters covering page size, margins, headers and footers with page numbers, page break control, watermarking and password protection, with official SDKs for Python, PHP, Node.js, Java, .NET, Ruby and Go. The current converter versions are Chromium-based, so the invoice's layout, fonts, custom properties and JavaScript all render correctly, and the header and footer parameters supply the page numbers.&lt;/p&gt;

&lt;p&gt;The things to check before committing are operational rather than rendering. Credits are metered at one per 0.5MB of output, the tightest size metering here, so a 2MB report costs four credits and plan capacity is smaller than the document counts suggest. Plans carry per-minute rate limits and concurrency allowances that step up with price, which matters for bursty invoice runs on billing day. Published pricing starts at $23 for 1,000 credits and $61 covers 10,000. If you want maximum knobs from a vendor that has outlived most of its competitors, this is that vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  PDFMonkey
&lt;/h2&gt;

&lt;p&gt;PDFMonkey is shaped differently from everything above, and comparing it fairly means saying so. You do not POST HTML per render. Templates live in PDFMonkey's dashboard, written in HTML and CSS with Liquid placeholders, and each API call sends the JSON data to merge into one. For an invoice pipeline where the accounts team occasionally edits the template and developers only ever send &lt;code&gt;{"invoice_number": "INV-2026-0847", ...}&lt;/code&gt;, that split is genuinely the right architecture, and it is why PDFMonkey keeps winning small SaaS teams.&lt;/p&gt;

&lt;p&gt;The engine is Chromium, so the invoice's CSS and JavaScript are safe, with one asterisk: the free plan cannot load external images, fonts or scripts at all, so trap number one, the Google-hosted webfont, fails until you pay. &lt;a href="https://www.pdfmonkey.io/pricing" rel="noopener noreferrer"&gt;Pricing&lt;/a&gt; is the friendliest entry point on this list, €5 a month for 300 documents and €15 for 3,000, but read the retention column before storing invoice URLs anywhere: documents are deleted after one day on Starter and seven days on Pro, with unlimited retention only from the €60 Pro+ plan. If your app links to the PDF later, that detail is a production incident waiting in the pricing table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotenberg, if you would rather run it yourself
&lt;/h2&gt;

&lt;p&gt;Every managed service above competes with one alternative: a Docker container on your own infrastructure. &lt;a href="https://github.com/gotenberg/gotenberg" rel="noopener noreferrer"&gt;Gotenberg&lt;/a&gt; is the best version of that idea, an MIT-licensed API wrapping Chromium and LibreOffice, so the invoice renders exactly as it does on any current-Chromium service, header and footer files included, for a licence cost of nothing.&lt;/p&gt;

&lt;p&gt;The cost is not nothing, it has just moved. You now own a browser in production: memory limits, zombie processes, concurrency under a billing-day burst, and the Chromium upgrade treadmill, which, as the margin-box story above shows, is also how you receive new CSS features. A small VM at $20 to $40 a month is the visible line item; the engineer who answers when rendering falls over at 2am is the real one. Gotenberg is the right choice when data genuinely cannot leave your infrastructure, and a self-imposed operations tax the rest of the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the same invoice costs at three volumes
&lt;/h2&gt;

&lt;p&gt;Monthly prices for rendering the 230KB invoice, at published August 2026 rates. Api2Pdf figures are estimates from its published per-MB and per-second rates at the invoice's measured weight and a two-second render; size-metered services assume the document stays under their credit threshold, which at 230KB it comfortably does.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Volume per month&lt;/th&gt;
&lt;th&gt;250 invoices&lt;/th&gt;
&lt;th&gt;2,500 invoices&lt;/th&gt;
&lt;th&gt;25,000 invoices&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTML to Image&lt;/td&gt;
&lt;td&gt;$9&lt;/td&gt;
&lt;td&gt;$25&lt;/td&gt;
&lt;td&gt;$120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DocRaptor&lt;/td&gt;
&lt;td&gt;$29&lt;/td&gt;
&lt;td&gt;$149&lt;/td&gt;
&lt;td&gt;$1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFShift&lt;/td&gt;
&lt;td&gt;$24&lt;/td&gt;
&lt;td&gt;$24&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Api2Pdf&lt;/td&gt;
&lt;td&gt;~$1.20&lt;/td&gt;
&lt;td&gt;~$2.60&lt;/td&gt;
&lt;td&gt;~$17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFCrowd&lt;/td&gt;
&lt;td&gt;$23&lt;/td&gt;
&lt;td&gt;$61&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFMonkey&lt;/td&gt;
&lt;td&gt;€5&lt;/td&gt;
&lt;td&gt;€15&lt;/td&gt;
&lt;td&gt;€180–300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gotenberg&lt;/td&gt;
&lt;td&gt;Your server&lt;/td&gt;
&lt;td&gt;Your server&lt;/td&gt;
&lt;td&gt;Your server, plus real ops work&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things jump out of that table. Api2Pdf's metered model is untouchable on raw price if you can live with usage billing and bring your own storage. DocRaptor costs ten to forty times the Chromium services at every volume, which is the price of Prince and worth paying only when you need what only Prince does. And two of seven vendors stop publishing prices exactly where volume gets interesting, which tells you something about how those negotiations go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one should you pick
&lt;/h2&gt;

&lt;p&gt;Match the service to the document, not to the adjectives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transactional A4 documents at volume, images from the same key&lt;/strong&gt;: HTML to Image. One credit each, hosted URLs, $9 to start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Print-grade typography, accessibility tagging, or compliance requirements&lt;/strong&gt;: DocRaptor, and rewrite your Grid layouts first. Nothing else here produces tagged PDFs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chromium fidelity plus page-level control&lt;/strong&gt;, Letter and landscape, watermarks, encryption: PDFShift for the cleaner API, PDFCrowd for the deepest parameter surface and official SDKs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Office documents in the pipeline, or the lowest possible bill&lt;/strong&gt;: Api2Pdf on its Chrome engine, with your own storage behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Templates edited by non-developers, data merged by API&lt;/strong&gt;: PDFMonkey, on a paid plan, with the retention column read twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data that cannot leave your infrastructure&lt;/strong&gt;: Gotenberg, priced honestly to include the engineer.&lt;/p&gt;

&lt;p&gt;Whatever this or any other vendor's list says: every service here has a free tier, free test documents or a trial. The only test that outranks mine is your own document through two or three of them. The &lt;a href="https://html2img.com/articles/best-html-to-pdf-api/" rel="noopener noreferrer"&gt;full comparison, with the deeper engine notes, lives on the HTML to Image blog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which HTML to PDF service are you running in production, and has it bitten you? Share your experience in the comments below.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>pdf</category>
      <category>api</category>
      <category>programming</category>
    </item>
    <item>
      <title>Can I use it in a PDF? CSS support across HTML to PDF engines</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Thu, 20 Aug 2026 20:02:13 +0000</pubDate>
      <link>https://dev.to/accreditly/can-i-use-it-in-a-pdf-css-support-across-html-to-pdf-engines-4n53</link>
      <guid>https://dev.to/accreditly/can-i-use-it-in-a-pdf-css-support-across-html-to-pdf-engines-4n53</guid>
      <description>&lt;p&gt;Your template renders perfectly in the browser, then falls apart in the PDF: the grid collapses, the footer vanishes and &lt;code&gt;counter(page)&lt;/code&gt; prints nothing. Your CSS is fine. The problem is that "HTML to PDF" is not one renderer. It is four different engines with four different ideas about which parts of CSS exist.&lt;/p&gt;

&lt;p&gt;Browsers have caniuse.com. PDF engines have changelogs, forum threads and folklore. So we built the missing table. The full write-up lives on the HTML to Image blog as &lt;a href="https://html2img.com/articles/html-to-pdf-css-support/" rel="noopener noreferrer"&gt;Can I use it in a PDF? CSS support across HTML to PDF engines&lt;/a&gt;; this is the condensed version with the rows that bite most often.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four engines
&lt;/h2&gt;

&lt;p&gt;Nearly every converter you will meet is one of these under the hood:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chrome headless&lt;/strong&gt;: Puppeteer, Playwright, Gotenberg and most hosted APIs. Browser-grade CSS, and since Chrome 131 (November 2024) proper &lt;code&gt;@page&lt;/code&gt; margin boxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;wkhtmltopdf&lt;/strong&gt;: Qt WebKit frozen around 2012. Archived in January 2023, last release June 2020, and carrying an unpatched 9.8 SSRF vulnerability (CVE-2022-35583).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WeasyPrint&lt;/strong&gt;: a Python layout engine built for pagination. No JavaScript, excellent paged media.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prince&lt;/strong&gt;: the commercial reference implementation for print CSS, with its own engine and its own JavaScript.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The support table
&lt;/h2&gt;

&lt;p&gt;Checked against Chrome 131 or later, wkhtmltopdf 0.12.6, WeasyPrint 69 and Prince 16, in August 2026.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Chrome headless&lt;/th&gt;
&lt;th&gt;wkhtmltopdf&lt;/th&gt;
&lt;th&gt;WeasyPrint&lt;/th&gt;
&lt;th&gt;Prince&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Flexbox&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Broken (2009 draft syntax)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grid&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, gaps remain&lt;/td&gt;
&lt;td&gt;Partial, no fragmentation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom properties &lt;code&gt;var()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;calc()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Unreliable&lt;/td&gt;
&lt;td&gt;Yes, added 2025&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@page&lt;/code&gt; size and margins&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;CLI flags instead&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@page&lt;/code&gt; margin boxes&lt;/td&gt;
&lt;td&gt;Yes, since 131&lt;/td&gt;
&lt;td&gt;No, CLI headers&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;counter(page)&lt;/code&gt; / &lt;code&gt;counter(pages)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Margin boxes only&lt;/td&gt;
&lt;td&gt;No, &lt;code&gt;[page]&lt;/code&gt; tokens&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Named pages (&lt;code&gt;page: cover&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;break-before&lt;/code&gt; / &lt;code&gt;after&lt;/code&gt; / &lt;code&gt;inside&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Legacy &lt;code&gt;page-break-*&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Named strings (&lt;code&gt;string-set&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;target-counter()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;leader()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Footnotes (&lt;code&gt;float: footnote&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs JavaScript&lt;/td&gt;
&lt;td&gt;Yes, full V8&lt;/td&gt;
&lt;td&gt;2012-era engine&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Own engine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The shape of it: Chromium wins everything a browser cares about and loses everything print specific beyond margin boxes. wkhtmltopdf loses everything. The two dedicated print engines are the only ones with the generated content features long documents need.&lt;/p&gt;

&lt;h2&gt;
  
  
  The row that changed recently: headers and footers
&lt;/h2&gt;

&lt;p&gt;Until late 2024 Chromium ignored &lt;code&gt;@page&lt;/code&gt; margin boxes entirely, which is why every Puppeteer tutorial reaches for &lt;code&gt;headerTemplate&lt;/code&gt; and &lt;code&gt;footerTemplate&lt;/code&gt;, a clunky parallel HTML system with its own font quirks. From Chrome 131 it is just CSS, in all 16 margin regions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@page&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;@top-center&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Northgate Coffee · Statement"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9pt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;@bottom-right&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Page "&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="s1"&gt;" of "&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9pt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One catch: in Chromium, &lt;code&gt;counter(page)&lt;/code&gt; only resolves inside margin boxes. Drop it into body content and you get nothing. WeasyPrint and Prince render it anywhere.&lt;/p&gt;

&lt;p&gt;What Chromium still cannot do is pull document content into the header. The classic "current chapter title in the running head" pattern needs named strings, which only the print engines implement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;h2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;string-set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chapter&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@page&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;@top-right&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chapter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The rows Chromium does not have at all
&lt;/h2&gt;

&lt;p&gt;A print table of contents needs the page number of a target element, plus the dotted line leading to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.toc&lt;/span&gt; &lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="nd"&gt;::after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;leader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;target-counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;target-counter()&lt;/code&gt; and &lt;code&gt;leader()&lt;/code&gt; work in WeasyPrint and Prince and nowhere else. The same goes for footnotes via &lt;code&gt;float: footnote&lt;/code&gt;. If you are locked into Chromium and need these, Paged.js polyfills them in JavaScript before printing, at the cost of a heavier render and another dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas hiding inside "Yes"
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;WeasyPrint only gained grid in v62 (April 2024) and &lt;code&gt;calc()&lt;/code&gt; in the 2025 releases. An older install following a newer tutorial fails in confusing ways, so pin and check your version.&lt;/li&gt;
&lt;li&gt;Prince 16's grid cannot fragment across pages yet. Use grid for components, not page-spanning layout.&lt;/li&gt;
&lt;li&gt;wkhtmltopdf's flexbox is the abandoned 2009 &lt;code&gt;-webkit-box&lt;/code&gt; draft. Modern &lt;code&gt;display: flex&lt;/code&gt; markup silently renders as stacked blocks.&lt;/li&gt;
&lt;li&gt;Fragmentation is where most real bugs live. Chrome, WeasyPrint and Prince honour &lt;code&gt;break-inside: avoid&lt;/code&gt; and repeat &lt;code&gt;thead&lt;/code&gt; across pages; wkhtmltopdf only understands the legacy &lt;code&gt;page-break-*&lt;/code&gt; spellings, and its table splitting is fragile even then.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Picking an engine
&lt;/h2&gt;

&lt;p&gt;For invoices, statements and reports: Chromium, now that it has real headers and page numbers. For books, contracts and anything with a table of contents or cross references: WeasyPrint for free, Prince when there is budget. And if you are still on wkhtmltopdf, the table above is the migration argument in one screen: an archived engine, a critical CVE and a decade of missing CSS.&lt;/p&gt;

&lt;p&gt;The full version, covering &lt;code&gt;@page&lt;/code&gt; setup, named pages, fragmentation control and the JavaScript story, is on the blog: &lt;a href="https://html2img.com/articles/html-to-pdf-css-support/" rel="noopener noreferrer"&gt;CSS support across HTML to PDF engines&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which engine are you running, and what is the worst CSS surprise it has given you? Share it in the comments below.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>pdf</category>
      <category>html</category>
    </item>
    <item>
      <title>How to Generate Images in n8n (Social Cards, Certificates and PDF Reports)</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Tue, 18 Aug 2026 19:42:07 +0000</pubDate>
      <link>https://dev.to/accreditly/how-to-generate-images-in-n8n-social-cards-certificates-and-pdf-reports-h24</link>
      <guid>https://dev.to/accreditly/how-to-generate-images-in-n8n-social-cards-certificates-and-pdf-reports-h24</guid>
      <description>&lt;p&gt;Your n8n workflow pulls the data, formats the message and posts it to Slack, then stops dead at the visual. The share card still gets made by hand, the certificate waits for someone to export it, and the Monday report goes out as a wall of numbers.&lt;/p&gt;

&lt;p&gt;You can fix all three with n8n's stock HTTP Request node and an HTML to Image API. No community node, no headless browser to maintain, and it works the same on n8n Cloud and self-hosted. This tutorial builds three workflows: social cards from an RSS feed, certificates from a form submission, and a weekly PDF report on a schedule.&lt;/p&gt;

&lt;p&gt;This is a cross-post: the original lives on the HTML to Image blog as &lt;a href="https://html2img.com/articles/n8n-image-generation/" rel="noopener noreferrer"&gt;How to Generate Images in n8n: Automate Social Cards, Certificates and Reports&lt;/a&gt;, where you'll also find the template gallery and API docs referenced throughout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An n8n instance, either Cloud or self-hosted&lt;/li&gt;
&lt;li&gt;A free &lt;a href="https://html2img.com/" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt; API key (the free tier gives you 25 renders a month, which covers everything here)&lt;/li&gt;
&lt;li&gt;Somewhere to deliver the output: a Slack workspace and a Gmail account in these examples&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How the API fits into n8n
&lt;/h2&gt;

&lt;p&gt;Two endpoints do all the work.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST https://app.html2img.com/api/v1/templates/{slug}&lt;/code&gt; renders one of 25 named templates. You send a small JSON payload (a title, a name, some line items) and get back a finished image at known dimensions. No HTML anywhere in your workflow.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST https://app.html2img.com/api/html&lt;/code&gt; renders raw HTML you supply, in real Chrome. Grid, flexbox, custom fonts and inline JavaScript all behave the way they do in your browser.&lt;/p&gt;

&lt;p&gt;Both respond with the same shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://i.html2img.com/abc123.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"credits_remaining"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;973&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;url&lt;/code&gt; is a permanent CDN link, which is what makes this pleasant in n8n: no binary data moves between nodes. You pass a short string to Slack or into an email and you're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Create the credential
&lt;/h2&gt;

&lt;p&gt;Copy your API key from the dashboard, then in any HTTP Request node:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set &lt;strong&gt;Authentication&lt;/strong&gt; to &lt;strong&gt;Generic Credential Type&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Set &lt;strong&gt;Generic Auth Type&lt;/strong&gt; to &lt;strong&gt;Header Auth&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Create a credential with &lt;strong&gt;Name&lt;/strong&gt; &lt;code&gt;X-API-Key&lt;/code&gt; and &lt;strong&gt;Value&lt;/strong&gt; set to your key&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Save it once, reuse it in every workflow below. The key never appears in node parameters or workflow exports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Social cards from an RSS feed
&lt;/h2&gt;

&lt;p&gt;The workflow is three nodes: &lt;strong&gt;RSS Feed Trigger → HTTP Request → Slack&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Point the RSS Feed Trigger at your blog's feed. Each new post arrives with &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;link&lt;/code&gt; and &lt;code&gt;creator&lt;/code&gt; fields.&lt;/p&gt;

&lt;p&gt;Configure the HTTP Request node:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Method&lt;/strong&gt;: POST&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL&lt;/strong&gt;: &lt;code&gt;https://app.html2img.com/api/v1/templates/open-graph-image&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt;: the Header Auth credential from Step 1&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Body Content Type&lt;/strong&gt;: JSON, with &lt;strong&gt;Specify Body&lt;/strong&gt; set to &lt;strong&gt;Using JSON&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JSON.stringify($json.title)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subtitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fresh on the blog"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice there are no quotes around the expression. &lt;code&gt;JSON.stringify()&lt;/code&gt; adds them itself and escapes whatever is inside. If you write &lt;code&gt;"title": "{{ $json.title }}"&lt;/code&gt; instead, the workflow runs fine for weeks and then breaks the first time a post title contains a double quote. Let &lt;code&gt;stringify&lt;/code&gt; build the value and that failure mode disappears. It's a habit worth applying to every hand-built JSON body in n8n.&lt;/p&gt;

&lt;p&gt;Finally, the Slack node posts the article link plus &lt;code&gt;{{ $json.url }}&lt;/code&gt; from the HTTP Request output. Slack unfurls the CDN URL into the card. Swap Slack for LinkedIn, X or Buffer and nothing upstream changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Certificates from a form submission
&lt;/h2&gt;

&lt;p&gt;Templates need no markup, but certificates usually want your own design. That's the raw HTML endpoint's job, and the clean pattern in n8n is: build the whole API payload in a Code node, then hand it to the HTTP Request untouched.&lt;/p&gt;

&lt;p&gt;The workflow: &lt;strong&gt;n8n Form Trigger → Code → HTTP Request → Gmail&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's the Code node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Full name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;course&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Course&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;long&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;certId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NC-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;
&amp;lt;html&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset="utf-8"&amp;gt;
&amp;lt;style&amp;gt;
  @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@500;700;800&amp;amp;family=JetBrains+Mono:wght@500&amp;amp;display=swap');
  body { width: 1200px; height: 850px; margin: 0; box-sizing: border-box;
         padding: 56px; background: #ffffff; font-family: 'Manrope', sans-serif;
         color: #0e1521; }
  .frame { height: 100%; border: 2px solid #2563eb; border-radius: 12px;
           box-sizing: border-box; padding: 64px; text-align: center;
           display: flex; flex-direction: column; justify-content: space-between; }
  .eyebrow { font-family: 'JetBrains Mono', monospace; font-size: 15px;
             letter-spacing: 0.2em; color: #2563eb; }
  h1 { font-size: 44px; font-weight: 800; margin: 12px 0 0; }
  .name { font-size: 56px; font-weight: 800; color: #2563eb; margin: 8px 0; }
  .body { font-size: 20px; color: #6b7585; line-height: 1.6; }
  .meta { display: flex; justify-content: space-between; align-items: flex-end;
          font-family: 'JetBrains Mono', monospace; font-size: 14px; color: #6b7585; }
  .sig { border-top: 1px solid #0e1521; padding-top: 8px; width: 220px; }
&amp;lt;/style&amp;gt;&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;&amp;lt;div class="frame"&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;div class="eyebrow"&amp;gt;CERTIFICATE OF COMPLETION&amp;lt;/div&amp;gt;
    &amp;lt;h1&amp;gt;Northgate Coffee Academy&amp;lt;/h1&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;p class="body"&amp;gt;This certifies that&amp;lt;/p&amp;gt;
    &amp;lt;div class="name"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/div&amp;gt;
    &amp;lt;p class="body"&amp;gt;has successfully completed&amp;lt;br&amp;gt;&amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;course&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/strong&amp;gt;&amp;lt;br&amp;gt;on &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;div class="meta"&amp;gt;
    &amp;lt;div class="sig"&amp;gt;Course Director&amp;lt;/div&amp;gt;
    &amp;lt;div&amp;gt;Verify: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;certId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;850&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dpi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Template literals drop the attendee's name straight into the markup, and the node returns the complete API payload as its output item. That makes the HTTP Request trivial: POST to &lt;code&gt;https://app.html2img.com/api/html&lt;/code&gt;, &lt;strong&gt;Specify Body&lt;/strong&gt; set to &lt;strong&gt;Using JSON&lt;/strong&gt;, and the entire body is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JSON.stringify($json)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No escaping the quotes, newlines and backticks buried in 40 lines of HTML. The Code node built a clean object; &lt;code&gt;stringify&lt;/code&gt; serialises it correctly by definition.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;dpi: 2&lt;/code&gt; renders at double pixel density so the certificate stays sharp on retina screens and survives printing, at the cost of roughly double the render time.&lt;/p&gt;

&lt;p&gt;The Gmail node then sends an HTML email with the certificate inline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Congratulations! Your certificate is below.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"{{ $json.url }}"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"600"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Certificate of completion"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"max-width:100%"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the certificate is a PNG on a CDN rather than styled HTML in the email body, it renders identically in Outlook, Gmail and Apple Mail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: A weekly report PDF on a schedule
&lt;/h2&gt;

&lt;p&gt;Same pattern as the certificate, two changes: a Schedule Trigger at the front and &lt;code&gt;format: "pdf"&lt;/code&gt; in the payload.&lt;/p&gt;

&lt;p&gt;The workflow: &lt;strong&gt;Schedule Trigger → Google Sheets → Code → HTTP Request → Slack&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Set the trigger to Monday at 08:00 and have the Google Sheets node return your metrics rows. The Code node turns them into a report, with a bar chart made of plain divs. CSS is a perfectly good charting library when real Chrome is your renderer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signups&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`
  &amp;lt;div class="bar-row"&amp;gt;
    &amp;lt;span class="label"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;week&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;
    &amp;lt;div class="track"&amp;gt;&amp;lt;div class="bar" style="width:&lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signups&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;span class="val"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signups&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;
  &amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;
&amp;lt;html&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset="utf-8"&amp;gt;
&amp;lt;style&amp;gt;
  @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@500;700;800&amp;amp;display=swap');
  body { font-family: 'Manrope', sans-serif; color: #0e1521; padding: 48px; }
  h1 { font-size: 28px; margin: 0 0 4px; }
  .sub { color: #6b7585; margin: 0 0 32px; }
  .kpis { display: flex; gap: 16px; margin-bottom: 40px; }
  .kpi { flex: 1; border: 1px solid #e2e6ec; border-radius: 10px; padding: 20px; }
  .kpi b { display: block; font-size: 30px; }
  .kpi span { color: #6b7585; font-size: 14px; }
  .bar-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
  .label { width: 90px; font-size: 13px; color: #6b7585; }
  .track { flex: 1; background: #eff4ff; border-radius: 4px; }
  .bar { height: 18px; background: #2563eb; border-radius: 4px; }
  .val { width: 50px; font-size: 13px; text-align: right; }
&amp;lt;/style&amp;gt;&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;h1&amp;gt;Weekly growth report&amp;lt;/h1&amp;gt;
  &amp;lt;p class="sub"&amp;gt;Generated &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;
  &amp;lt;div class="kpis"&amp;gt;
    &amp;lt;div class="kpi"&amp;gt;&amp;lt;b&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signups&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/b&amp;gt;&amp;lt;span&amp;gt;Signups this week&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;div class="kpi"&amp;gt;&amp;lt;b&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/b&amp;gt;&amp;lt;span&amp;gt;Revenue this week&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;div class="kpi"&amp;gt;&amp;lt;b&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/b&amp;gt;&amp;lt;span&amp;gt;Weeks tracked&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bars&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP Request is identical to Step 3, body &lt;code&gt;{{ JSON.stringify($json) }}&lt;/code&gt;. The difference is &lt;code&gt;format: "pdf"&lt;/code&gt;: the API returns an A4 portrait PDF with selectable text and embedded fonts, and long content paginates automatically. Width and height are ignored in PDF mode, so leave them out.&lt;/p&gt;

&lt;p&gt;The Slack node posts the returned &lt;code&gt;url&lt;/code&gt;. It ends in &lt;code&gt;.pdf&lt;/code&gt; and opens straight in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production tips
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Renders are synchronous within a 30 second budget.&lt;/strong&gt; For heavy renders, pass a &lt;code&gt;webhook_url&lt;/code&gt; in the payload and the API responds immediately, then POSTs the finished file's URL to a second n8n workflow that starts with a Webhook node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't re-render what hasn't changed.&lt;/strong&gt; Every render costs a credit and every URL is permanent, so store the URL back in your sheet or database on first render and reuse it. An IF node checking whether the URL column is populated is usually enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turn on Retry On Fail&lt;/strong&gt; in each HTTP Request node's settings. Validation errors come back as a 422 with a &lt;code&gt;details&lt;/code&gt; object naming the bad field, and n8n shows the response body in the execution log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch &lt;code&gt;credits_remaining&lt;/code&gt;&lt;/strong&gt; in every response. An IF node that pings you when it drops below a threshold means a busy month never silently stops your certificates going out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;You've built three workflows, and they're really one pattern: a trigger, an optional Code node that assembles a payload, and an HTTP Request that returns a CDN URL. Templates cover the no-code cases, raw HTML covers your own designs, and &lt;code&gt;{{ JSON.stringify($json) }}&lt;/code&gt; keeps the whole thing escape-proof. The full version of this guide, with a rendered example of the certificate and the workflow diagrams, is on the HTML to Image blog: &lt;a href="https://html2img.com/articles/n8n-image-generation/" rel="noopener noreferrer"&gt;How to Generate Images in n8n&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Are you generating images or PDFs from your n8n workflows already? Share your setup in the comments below.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Convert Markdown to an Image (Without Screenshotting Your Editor)</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Sat, 15 Aug 2026 07:51:21 +0000</pubDate>
      <link>https://dev.to/accreditly/how-to-convert-markdown-to-an-image-without-screenshotting-your-editor-3l9o</link>
      <guid>https://dev.to/accreditly/how-to-convert-markdown-to-an-image-without-screenshotting-your-editor-3l9o</guid>
      <description>&lt;p&gt;You write release notes, changelogs and comparison tables in Markdown, and then you need to post them somewhere that refuses to render it. Slack collapses your table into a line of pipes. X and LinkedIn strip every heading and bullet to flat text. Email clients show your readers literal asterisks.&lt;/p&gt;

&lt;p&gt;So you open the preview pane and take a screenshot. It works, in the way that holding a door shut works instead of fixing the lock. We've just published a fuller guide on &lt;a href="https://html2img.com/articles/markdown-to-image/" rel="noopener noreferrer"&gt;converting Markdown to an image&lt;/a&gt; over on HTML to Image, and this post covers the working version for JavaScript developers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's wrong with a screenshot
&lt;/h2&gt;

&lt;p&gt;A screenshot carries everything you didn't want along with the content. Your editor theme comes with it, so a dark-mode preview lands in a light-mode inbox looking like a ransom note. The resolution is whatever your display happens to be, so text that was crisp on your laptop turns soft on a retina screen. The crop is hand-drawn, so there's a sliver of scrollbar down one edge.&lt;/p&gt;

&lt;p&gt;And it's manual. If the notes change, you screenshot again. Fine once; wrong as a workflow.&lt;/p&gt;

&lt;p&gt;The better approach: convert the Markdown to HTML, wrap it in a clean stylesheet, and render it in a real browser at a fixed width. The formatting survives because it's no longer formatting. It's pixels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node 18 or newer (for built-in &lt;code&gt;fetch&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;marked&lt;/code&gt; package: &lt;code&gt;npm install marked&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;An API key for a rendering service (&lt;a href="https://html2img.com/docs" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt; has a free tier; any HTML-to-PNG renderer with a full-page option works the same way)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Parse the Markdown
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;marked&lt;/code&gt; handles GitHub-flavoured Markdown out of the box, so tables, strikethrough and fenced code blocks all convert properly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;marked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;markdown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;release-notes.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fragment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gfm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you get back is a bare HTML fragment with no opinion about its own appearance. That's the next step's job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Wrap it in a document stylesheet
&lt;/h2&gt;

&lt;p&gt;The parser gives you semantics; this gives you the look. It's deliberately close to how GitHub renders Markdown, because that's what people expect a rendered document to look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stylesheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
  body { margin: 0; padding: 48px 56px;
    font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
    font-size: 17px; line-height: 1.6; color: #1f2328; }
  h2 { font-size: 30px; margin: 0 0 16px; padding-bottom: 10px;
    border-bottom: 1px solid #d1d9e0; }
  table { border-collapse: collapse; width: 100%; margin: 0 0 18px; }
  th, td { border: 1px solid #d1d9e0; padding: 9px 14px; text-align: left; }
  th { background: #f6f8fa; }
  blockquote { margin: 0 0 18px; padding: 4px 18px;
    border-left: 4px solid #d1d9e0; color: #59636e; }
  code { font-family: ui-monospace, Menlo, Consolas, monospace;
    font-size: 0.9em; background: #f0f1f3; padding: 2px 6px; border-radius: 5px; }
  pre { background: #f6f8fa; border: 1px solid #d1d9e0;
    border-radius: 8px; padding: 16px 18px; }
  pre code { background: none; padding: 0; font-size: 15px; }
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;
&amp;lt;html&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset="utf-8"&amp;gt;&amp;lt;style&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stylesheet&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/style&amp;gt;&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fragment&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap the font stack for your brand face and tint the borders if you want it on-brand. The structure doesn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Render it
&lt;/h2&gt;

&lt;p&gt;One POST. The two parameters that matter are &lt;code&gt;width&lt;/code&gt;, which sets the line length (1,000px reads well for a document), and &lt;code&gt;fullpage: true&lt;/code&gt;, which makes the capture height follow the content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://app.html2img.com/api/html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-API-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fullpage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full-page part is what separates this from screenshotting. A three-line note produces a small image and a forty-line changelog produces a tall one, from the same code, with nothing cropped at the bottom.&lt;/p&gt;

&lt;p&gt;Here's a release note that started life as thirty lines of Markdown and came out of exactly this script:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6b3hvhmkeywh7f1spyv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6b3hvhmkeywh7f1spyv.png" alt="A rendered release note as a PNG: heading, bold bullets, a table with inline code, a blockquote and a code block all intact" width="800" height="729"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bold lead-ins, the inline code, the table columns, the blockquote and the fenced block all survived. Post that image anywhere and it looks the same everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this pays off
&lt;/h2&gt;

&lt;p&gt;Release notes as social posts are the obvious one: the structure survives on platforms that would flatten it to a paragraph. Tables in Slack are the daily one. Email is the sneaky one, because clients disagree about nearly every layout feature, and a PNG looks identical in Gmail, Outlook and Apple Mail since there's nothing left to interpret.&lt;/p&gt;

&lt;p&gt;And because it's one HTTP call, it automates: run it in CI when a release tags, or in a cron that turns the week's merged PRs into a Friday summary card. The Markdown was already being written. Now it travels.&lt;/p&gt;

&lt;p&gt;For the browser-based version of this (paste Markdown, get a PNG, no code), plus Python and PHP implementations and the full stylesheet, the &lt;a href="https://html2img.com/articles/markdown-to-image/" rel="noopener noreferrer"&gt;complete guide is here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Do you post rendered Markdown anywhere regularly, or are you still screenshotting the preview pane? Share your setup in the comments.&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why your Open Graph image doesn't show up when you share a link</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Wed, 12 Aug 2026 17:26:06 +0000</pubDate>
      <link>https://dev.to/accreditly/why-your-open-graph-image-doesnt-show-up-when-you-share-a-link-4h41</link>
      <guid>https://dev.to/accreditly/why-your-open-graph-image-doesnt-show-up-when-you-share-a-link-4h41</guid>
      <description>&lt;p&gt;You paste a link into Slack and the preview comes back as a bare grey rectangle. Or the title shows and the image does not. Or LinkedIn serves artwork from three deploys ago. You open devtools, the tags are right there, and the card is still wrong.&lt;/p&gt;

&lt;p&gt;The reason is nearly always the same one. The thing reading your page is not a browser. A social crawler makes one anonymous request, parses the HTML that came back over the wire, fetches the image, and leaves. No session, no JavaScript engine, no patience. We have published the full version of this with the platform specifics on the HTML to Image blog, in &lt;a href="https://html2img.com/articles/og-image-not-showing/" rel="noopener noreferrer"&gt;Why your Open Graph image doesn't show up when you share a link&lt;/a&gt;, and this post walks through the eight causes and how to tell them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when someone shares your URL
&lt;/h2&gt;

&lt;p&gt;Four steps, and each fails independently:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The platform requests your URL with a crawler user agent, following redirects.&lt;/li&gt;
&lt;li&gt;It parses the served HTML for &lt;code&gt;og:&lt;/code&gt; and &lt;code&gt;twitter:&lt;/code&gt; tags.&lt;/li&gt;
&lt;li&gt;It resolves &lt;code&gt;og:image&lt;/code&gt; to an absolute URL and fetches it, often reading only enough of the file to get its dimensions.&lt;/li&gt;
&lt;li&gt;It builds a card using its own rules about which tag wins, how to crop, and how much of the title to keep.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Working out which step broke is most of the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The tags are in devtools but not in the response
&lt;/h2&gt;

&lt;p&gt;The most common cause by a distance. If your head is set client-side, a Vue app assigning meta on mount, a React app using a client-only helmet component, a CMS script patching the head after load, the crawler sees none of it. Devtools shows the DOM after your JavaScript has run. The crawler reads the document as served.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffa1uur4bo6lucic9wzdx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffa1uur4bo6lucic9wzdx.png" alt="Side by side comparison of a page head in devtools with three Open Graph tags present, and the same page as served to a crawler with no Open Graph tags at all" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One command tells you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; https://example.com/pricing | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'og:\|twitter:'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty output means the tags have to move into server-rendered HTML. In Next.js that is the &lt;code&gt;metadata&lt;/code&gt; export or &lt;code&gt;generateMetadata&lt;/code&gt; in a server component, not anything set in &lt;code&gt;useEffect&lt;/code&gt;. In Nuxt it is &lt;code&gt;useSeoMeta&lt;/code&gt; on a server-rendered route. In a static site generator it is the head partial at build time.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;og:image&lt;/code&gt; is a relative path
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"/images/og/pricing.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Valid HTML, useless to a crawler. The protocol asks for an absolute URL including the scheme, and several platforms drop the image rather than guess your host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/images/og/pricing.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same problem with protocol-relative &lt;code&gt;//&lt;/code&gt; URLs, and with anything still pointing at &lt;code&gt;localhost&lt;/code&gt; because the base URL comes from an environment variable nobody set in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The image is not reachable by an anonymous bot
&lt;/h2&gt;

&lt;p&gt;The image fetch is as anonymous as the page fetch. Hotlink protection that wants a same-origin referer, Cloudflare Bot Fight Mode on the asset path, a &lt;code&gt;robots.txt&lt;/code&gt; rule over the image directory, an expired signed CDN URL, or a private bucket object will all kill it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/images/og/pricing.png | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want a &lt;code&gt;200&lt;/code&gt; and an image content type. A &lt;code&gt;403&lt;/code&gt; for curl is a &lt;code&gt;403&lt;/code&gt; for Facebook.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. No &lt;code&gt;og:image:width&lt;/code&gt; and &lt;code&gt;og:image:height&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Seeing your URL for the first time, a platform has no idea how big the image is, so it downloads and measures it before laying out the card. Several will render the card without the image rather than wait, then get it right on the second or third share. Your launch post has already been seen by then.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/images/og/pricing.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:width"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"630"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"image/png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:alt"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Pricing plans for Northgate"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. The image is the wrong size, ratio or format
&lt;/h2&gt;

&lt;p&gt;1200 by 630 renders cleanly everywhere. Below 600 by 315, Facebook and LinkedIn downgrade to a small square thumbnail beside the text. Over 8MB, the image is dropped. SVG is not read as an &lt;code&gt;og:image&lt;/code&gt; anywhere that matters, and WebP support is patchy enough that PNG or JPEG remains the safe answer.&lt;/p&gt;

&lt;p&gt;Then the crop. Everything centre-crops toward roughly 1.91:1 and square unfurls keep only the middle band, so a logo or headline near an edge is a logo or headline you sometimes lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. &lt;code&gt;twitter:card&lt;/code&gt; is missing so X renders the small card
&lt;/h2&gt;

&lt;p&gt;X falls back to &lt;code&gt;og:&lt;/code&gt; tags for the title, description and image, which is why people skip the &lt;code&gt;twitter:&lt;/code&gt; namespace. The one tag you cannot skip is the card type. Without it you get &lt;code&gt;summary&lt;/code&gt;, a small square thumbnail beside the text, instead of the full-width image card.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. The URL you tested is not the URL people share
&lt;/h2&gt;

&lt;p&gt;Tags are read from the final URL after redirects, and what ends up in the wild is rarely the clean URL you tested. Campaign parameters, a trailing slash difference, a locale prefix, an AMP variant or an &lt;code&gt;http&lt;/code&gt; to &lt;code&gt;https&lt;/code&gt; hop can each resolve to a document with different tags. Test the canonical URL, then test the exact string your campaign is about to use.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Facebook is still showing a card you fixed last week
&lt;/h2&gt;

&lt;p&gt;Meta caches the first scrape hard and can hold it for days. Deploying a fix does not invalidate it, so run the URL through Meta's Sharing Debugger once and use Scrape Again. This one matters mostly because it makes the other seven harder to diagnose. If a live fetch shows correct tags and Facebook shows the old card, the cache is stale rather than your fix being wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the lot in one pass
&lt;/h2&gt;

&lt;p&gt;The official debuggers have thinned out. Meta's needs a Facebook login and only covers Facebook. X removed the rendered preview from its Card Validator, so it now tells you a card is valid without showing you the card. Neither says anything about Slack, Discord or WhatsApp.&lt;/p&gt;

&lt;p&gt;We built the &lt;a href="https://html2img.com/tools/open-graph-checker" rel="noopener noreferrer"&gt;Open Graph Checker&lt;/a&gt; to run the whole sequence in one request: fetch as a crawler without executing JavaScript, follow redirects and read tags from the final URL, resolve relative image URLs, then read the image's real dimensions, type and file size from the file rather than from what the tags claim. It renders the card as Facebook, X, LinkedIn, Slack, Discord and WhatsApp would each build it, scores the page, and prints a corrected tag block. No login, and it reads live rather than from a cache.&lt;/p&gt;

&lt;p&gt;The failures it separates fastest are the ones that look identical from outside: tags missing because they are client-side, an image URL that resolves but returns a &lt;code&gt;403&lt;/code&gt;, an image that is present but 400 pixels wide, and a page that redirects somewhere with different tags.&lt;/p&gt;

&lt;h2&gt;
  
  
  The block you are aiming for
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"article"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:url"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/pricing"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:site_name"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Northgate"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Pricing that scales with your traffic"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Per-render pricing with no seat fees, and a free tier that covers a small site."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/images/og/pricing.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:width"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"630"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"image/png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:alt"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Pricing plans for Northgate"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Pricing that scales with your traffic"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Per-render pricing with no seat fees, and a free tier that covers a small site."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/images/og/pricing.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Titles under about 60 characters and descriptions under about 155, or they truncate in most placements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stopping it happening again
&lt;/h2&gt;

&lt;p&gt;Share metadata rots quietly. It breaks during framework migrations, CMS moves and route refactors, and nothing in your test suite notices because the page still returns a 200. A crude assertion over your important URLs catches most of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;url &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/ &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/pricing &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/docs
&lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;html&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'property="og:image"'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ok   &lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL &lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it in CI after deploy, and put your top twenty URLs through a checker after any migration, while the traffic that would have hit those broken cards is still recoverable.&lt;/p&gt;

&lt;p&gt;The longer version, including how to generate the card image itself when the answer turns out to be that you never had one, is on the &lt;a href="https://html2img.com/articles/og-image-not-showing/" rel="noopener noreferrer"&gt;HTML to Image blog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which of the eight has bitten you? Mine is client-side tags, twice on the same project.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>seo</category>
      <category>html</category>
    </item>
    <item>
      <title>Generate Open Graph images in GitHub Actions (no headless browser required)</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Thu, 06 Aug 2026 13:21:26 +0000</pubDate>
      <link>https://dev.to/accreditly/generate-open-graph-images-in-github-actions-no-headless-browser-required-2153</link>
      <guid>https://dev.to/accreditly/generate-open-graph-images-in-github-actions-no-headless-browser-required-2153</guid>
      <description>&lt;p&gt;You can render Open Graph images, pull request screenshots and release cards from a GitHub workflow without installing a single browser on the runner. No Puppeteer, no &lt;code&gt;apt-get install&lt;/code&gt; block for Chrome's dependencies, no cache step for a 300 MB browser download. One workflow step does the rendering remotely and hands you back a file.&lt;/p&gt;

&lt;p&gt;The step is &lt;code&gt;html2img/action&lt;/code&gt;, the official GitHub Action for the &lt;a href="https://html2img.com" rel="noopener noreferrer"&gt;HTML to Image API&lt;/a&gt;. We have published a full write-up of the workflow patterns in &lt;a href="https://html2img.com/articles/generate-og-images-github-actions/" rel="noopener noreferrer"&gt;Generate Open Graph Images in GitHub Actions&lt;/a&gt;, and this tutorial walks you through getting from nothing to committed OG images and PR screenshots, step by step.&lt;/p&gt;

&lt;p&gt;Why bother moving the render out of the runner at all? Because a GitHub-hosted runner is a fresh machine every run. Anything a headless browser needs gets installed every time or cached with &lt;code&gt;actions/cache&lt;/code&gt;, and both routes add minutes and new ways to fail. An API call takes a couple of seconds whatever the page contains, and fonts, emoji and CSS support stop being your problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A GitHub repository with Actions enabled.&lt;/li&gt;
&lt;li&gt;A free &lt;a href="https://html2img.com" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt; account. The free tier comes with 50 credits and does not ask for a card. One credit is one render.&lt;/li&gt;
&lt;li&gt;Your API key from the dashboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Add your API key as a secret
&lt;/h2&gt;

&lt;p&gt;In your repository, go to Settings, then Secrets and variables, then Actions, and create a repository secret named &lt;code&gt;HTML2IMG_API_KEY&lt;/code&gt; with your key as the value. The action masks the key in logs, but it still belongs in a secret rather than in the workflow file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Render your first image
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;.github/workflows/render.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Render&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;workflow_dispatch&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;html2img/action@v1&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;render&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.HTML2IMG_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;div&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;style="font:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;700&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;72px&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;system-ui;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;padding:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;80px"&amp;gt;Hello&amp;lt;/div&amp;gt;'&lt;/span&gt;
          &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1200&lt;/span&gt;
          &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;630&lt;/span&gt;
          &lt;span class="na"&gt;output-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello.png&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "Rendered ${{ steps.render.outputs.url }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it from the Actions tab. The step writes &lt;code&gt;hello.png&lt;/code&gt; into the workspace and sets three outputs you can use in later steps: &lt;code&gt;url&lt;/code&gt; (the hosted render), &lt;code&gt;path&lt;/code&gt; (where the file landed) and &lt;code&gt;skipped&lt;/code&gt; (more on that in a moment).&lt;/p&gt;

&lt;p&gt;You give the action exactly one source per step: &lt;code&gt;html&lt;/code&gt; for inline markup, &lt;code&gt;html-file&lt;/code&gt; for a file in your repository, &lt;code&gt;url&lt;/code&gt; to screenshot a live page, or &lt;code&gt;template&lt;/code&gt; plus &lt;code&gt;variables&lt;/code&gt; to render a named template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Generate OG images for your blog posts
&lt;/h2&gt;

&lt;p&gt;Here is the real workflow: every time posts change on &lt;code&gt;main&lt;/code&gt;, find the ones with no social card yet, render them, and commit the PNGs. The example paths are Astro's, but only the paths are. Point it at &lt;code&gt;content/posts&lt;/code&gt; and it is a Hugo workflow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OG images&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/content/blog/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;find-posts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;slugs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.find.outputs.slugs }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;find&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;slugs=$(for file in src/content/blog/*.md; do&lt;/span&gt;
            &lt;span class="s"&gt;slug=$(basename "$file" .md)&lt;/span&gt;
            &lt;span class="s"&gt;if [ ! -f "public/og/$slug.png" ]; then printf '%s\n' "$slug"; fi&lt;/span&gt;
          &lt;span class="s"&gt;done | jq -R . | jq -sc .)&lt;/span&gt;
          &lt;span class="s"&gt;echo "slugs=$slugs" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;

  &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;find-posts&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;needs.find-posts.outputs.slugs != '[]'&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;fail-fast&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ fromJSON(needs.find-posts.outputs.slugs) }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build the card markup&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;title=$(sed -n 's/^title: *//p' "src/content/blog/${{ matrix.slug }}.md" | head -1 | tr -d '"')&lt;/span&gt;
          &lt;span class="s"&gt;mkdir -p build&lt;/span&gt;
          &lt;span class="s"&gt;cat &amp;gt; build/card.html &amp;lt;&amp;lt;HTML&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;meta charset="utf-8"&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;div style="display:flex;align-items:center;width:1200px;height:630px;&lt;/span&gt;
                      &lt;span class="s"&gt;box-sizing:border-box;padding:80px;background:#0f172a;&lt;/span&gt;
                      &lt;span class="s"&gt;color:#fff;font:700 68px/1.15 system-ui,sans-serif"&amp;gt;&lt;/span&gt;
            &lt;span class="s"&gt;$title&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;HTML&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;html2img/action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.HTML2IMG_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;html-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build/card.html&lt;/span&gt;
          &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1200&lt;/span&gt;
          &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;630&lt;/span&gt;
          &lt;span class="na"&gt;output-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/og/${{ matrix.slug }}.png&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;og-${{ matrix.slug }}&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/og/&lt;/span&gt;

  &lt;span class="na"&gt;commit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;render&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;og-*&lt;/span&gt;
          &lt;span class="na"&gt;merge-multiple&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/og&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git config user.name 'github-actions[bot]'&lt;/span&gt;
          &lt;span class="s"&gt;git config user.email '41898282+github-actions[bot]@users.noreply.github.com'&lt;/span&gt;
          &lt;span class="s"&gt;git add public/og&lt;/span&gt;
          &lt;span class="s"&gt;if git diff --cached --quiet; then&lt;/span&gt;
            &lt;span class="s"&gt;echo 'No new cards.'&lt;/span&gt;
          &lt;span class="s"&gt;else&lt;/span&gt;
            &lt;span class="s"&gt;git commit -m 'Add Open Graph images'&lt;/span&gt;
            &lt;span class="s"&gt;git push&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Committing the images matters, and it is not just tidiness. On the free plan a hosted render is kept for 7 days. An OG image referenced from a meta tag has to exist for as long as the post does, so the PNG belongs in your repository or your deploy output, not hotlinked from a CDN you do not control.&lt;/p&gt;

&lt;p&gt;The card markup above is deliberately minimal so the workflow stays readable. In a real project you would design something better, or skip HTML altogether and use a named template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Screenshot the pull request preview
&lt;/h2&gt;

&lt;p&gt;If your host deploys a preview per pull request, you can screenshot it and post the image as a comment that updates on every push. Reviewers see the change without leaving the PR.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Preview screenshot&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;screenshot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event.pull_request.head.repo.full_name == github.repository&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Wait for the preview to answer&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;preview&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;url="https://deploy-preview-${{ github.event.pull_request.number }}--example.netlify.app"&lt;/span&gt;
          &lt;span class="s"&gt;for _ in $(seq 1 30); do&lt;/span&gt;
            &lt;span class="s"&gt;if curl -fsS -o /dev/null "$url"; then&lt;/span&gt;
              &lt;span class="s"&gt;echo "url=$url" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
              &lt;span class="s"&gt;exit 0&lt;/span&gt;
            &lt;span class="s"&gt;fi&lt;/span&gt;
            &lt;span class="s"&gt;sleep 10&lt;/span&gt;
          &lt;span class="s"&gt;done&lt;/span&gt;
          &lt;span class="s"&gt;exit 1&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;html2img/action@v1&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shot&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.HTML2IMG_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.preview.outputs.url }}&lt;/span&gt;
          &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1280&lt;/span&gt;
          &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;800&lt;/span&gt;
          &lt;span class="na"&gt;wait-for-selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;main'&lt;/span&gt;
          &lt;span class="na"&gt;css&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.cookie-banner,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.chat-widget&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;display:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;!important&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;peter-evans/create-or-update-comment@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;issue-number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.number }}&lt;/span&gt;
          &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;Preview of ${{ github.event.pull_request.head.sha }}:&lt;/span&gt;

            &lt;span class="s"&gt;![Preview screenshot](${{ steps.shot.outputs.url }})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two inputs are doing the heavy lifting. &lt;code&gt;wait-for-selector&lt;/code&gt; holds the capture until your content actually exists in the DOM, and returns the moment it does, which beats guessing a fixed delay. &lt;code&gt;css&lt;/code&gt; injects styles over the page's own, and since the page's styles usually win on specificity, that &lt;code&gt;!important&lt;/code&gt; is not optional. It is the cleanest way to hide a cookie banner before the shot.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;if:&lt;/code&gt; guard. GitHub does not expose secrets to workflows triggered by pull requests from forks, so this recipe is for branches on your own repository. That is a platform security decision, and a sensible one, but it is better to know before you wonder why fork PRs get no screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that save your credits
&lt;/h2&gt;

&lt;p&gt;First, caching. When &lt;code&gt;output-path&lt;/code&gt; is set, the action hashes the resolved inputs and writes the digest next to the file as &lt;code&gt;&amp;lt;output-path&amp;gt;.html2img-hash&lt;/code&gt;. If the file and a matching digest already exist, the API is never called and the &lt;code&gt;skipped&lt;/code&gt; output is &lt;code&gt;true&lt;/code&gt;. Commit both files, as the OG workflow does, and re-runs cost nothing. CI re-runs identical work all day long, so this default is what keeps 50 free credits meaning 50 images rather than 50 workflow runs.&lt;/p&gt;

&lt;p&gt;Second, DPI. For HTML sources the API's &lt;code&gt;dpi&lt;/code&gt; default is 2, so a 1200 by 630 card comes back as a 2400 by 1260 file. That is the right call for retina screens, but if anything downstream asserts exact pixel dimensions, set &lt;code&gt;dpi: 1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going further
&lt;/h2&gt;

&lt;p&gt;The same step renders named templates (&lt;code&gt;template: open-graph-image&lt;/code&gt; with a &lt;code&gt;variables&lt;/code&gt; JSON object), which is how you attach a social card to every release without maintaining any markup, and it takes &lt;code&gt;format: pdf&lt;/code&gt; if the artefact you need is a document rather than an image. The &lt;a href="https://html2img.com/articles/generate-og-images-github-actions/" rel="noopener noreferrer"&gt;full article&lt;/a&gt; covers the release workflow, the PDF options and the parameter combinations the action refuses to send, and the &lt;a href="https://github.com/html2img/action" rel="noopener noreferrer"&gt;action's README&lt;/a&gt; has the complete inputs table.&lt;/p&gt;

&lt;p&gt;In this tutorial you have set up an API key as a secret, rendered a first image from a workflow, wired up committed OG images for a blog and posted preview screenshots on pull requests, all without a browser anywhere near your CI.&lt;/p&gt;

&lt;p&gt;Are you generating OG images at build time, in CI, or on demand at request time? Share your setup in the comments below.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>github</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Format Dates, Numbers and Relative Time in JavaScript Without a Library</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Tue, 04 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/accreditly/format-dates-numbers-and-relative-time-in-javascript-without-a-library-18cg</link>
      <guid>https://dev.to/accreditly/format-dates-numbers-and-relative-time-in-javascript-without-a-library-18cg</guid>
      <description>&lt;p&gt;A surprising number of production bundles still ship a date library to do one thing: print "3 days ago" under a comment. The browser has done that natively for years, in every locale your users speak, through the &lt;code&gt;Intl&lt;/code&gt; API. It formats dates, currencies, compact numbers, units, relative time and even lists, and it weighs nothing because it is already there. Let's have a look at the parts you will actually use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dates, properly localised
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; covers almost every date display you will ever need with two options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;format&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;dateStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;long&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timeStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;short&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Europe/London&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;// "31 July 2026 at 14:00"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap &lt;code&gt;'en-GB'&lt;/code&gt; for &lt;code&gt;'fr'&lt;/code&gt;, &lt;code&gt;'de'&lt;/code&gt; or &lt;code&gt;'ja'&lt;/code&gt; and the output translates itself, month names and all. The &lt;code&gt;timeZone&lt;/code&gt; option means you can store UTC everywhere and render in the user's zone at the last moment, which is the arrangement you wanted anyway.&lt;/p&gt;

&lt;p&gt;For a one-off you can skip constructing a formatter and call the shortcut on the date itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dateStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// "31 Jul 2026"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Numbers, currencies and units
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Intl.NumberFormat&lt;/code&gt; handles the formatting jobs that usually attract hand-rolled regex:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GBP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1499.99&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "£1,499.99"&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;notation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;compact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1250000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "1.3M"&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kilometer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;unitDisplay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;long&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;26.2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "26.2 kilometres"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That compact notation is the follower-count style you see on every social platform, free of charge. And notice the unit identifier uses the American spelling &lt;code&gt;kilometer&lt;/code&gt; while the en-GB output comes back as "kilometres". The identifiers are fixed; the output localises.&lt;/p&gt;

&lt;h2&gt;
  
  
  Relative time, the bit everyone installs a library for
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Intl.RelativeTimeFormat&lt;/code&gt; produces the "yesterday" and "in 3 hours" strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rtf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RelativeTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;rtf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;day&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// "yesterday"&lt;/span&gt;
&lt;span class="nx"&gt;rtf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hour&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// "in 3 hours"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;numeric: 'auto'&lt;/code&gt; option is what turns "1 day ago" into "yesterday". The API wants a value and a unit, so in practice you pair it with a small helper that picks the largest sensible unit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;units&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;year&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;31536000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;month&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2592000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;week&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;604800&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;day&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hour&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;minute&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;second&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;timeAgo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;units&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;second&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rtf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;timeAgo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// "3 days ago"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifteen lines, and that is the entire reason many projects still carry a date dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lists that read like sentences
&lt;/h2&gt;

&lt;p&gt;The least known member of the family might be the most charming. &lt;code&gt;Intl.ListFormat&lt;/code&gt; joins arrays the way a human would write them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ListFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;conjunction&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PHP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;JavaScript&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CSS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// "PHP, JavaScript and CSS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same code under &lt;code&gt;en-US&lt;/code&gt; and you get "PHP, JavaScript, and CSS". The API knows about the Oxford comma so you do not have to have the argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create formatters once
&lt;/h2&gt;

&lt;p&gt;One genuine footgun: constructing a formatter is the expensive part, while calling &lt;code&gt;.format()&lt;/code&gt; is cheap. Creating a &lt;code&gt;new Intl.DateTimeFormat&lt;/code&gt; inside a loop or a hot render path will show up in a profile. Hoist them to module scope, or memoise per locale if the locale varies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;currencyFormatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where Intl stops
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Intl&lt;/code&gt; formats. It does not parse "31/07/2026", add a month to a date or convert a timestamp between zones for arithmetic. That is the territory of the incoming &lt;code&gt;Temporal&lt;/code&gt; standard, which has started landing in browsers. Until it is everywhere, a fair split is a small library like &lt;code&gt;date-fns&lt;/code&gt; for the maths and &lt;code&gt;Intl&lt;/code&gt; for everything the user sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;p&gt;In this tutorial you've seen &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; for localised dates, &lt;code&gt;Intl.NumberFormat&lt;/code&gt; for currencies, compact notation and units, &lt;code&gt;Intl.RelativeTimeFormat&lt;/code&gt; for "3 days ago", &lt;code&gt;Intl.ListFormat&lt;/code&gt; for sentence-style lists, and why formatters should be created once and reused. For display work, the platform has quietly made the date library optional.&lt;/p&gt;

&lt;p&gt;Which library did &lt;code&gt;Intl&lt;/code&gt; let you delete? Tell us in the comments below.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Your AI agent can't design images. It can write HTML.</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Tue, 04 Aug 2026 08:52:46 +0000</pubDate>
      <link>https://dev.to/accreditly/your-ai-agent-cant-design-images-it-can-write-html-4g7g</link>
      <guid>https://dev.to/accreditly/your-ai-agent-cant-design-images-it-can-write-html-4g7g</guid>
      <description>&lt;p&gt;Ask your AI assistant to "make an OG image for this post" and you get one of two failures. If it reaches for a diffusion model you get soft gradients, warped typography and a title that looks photographed through water. If it does not, it writes you a perfectly good block of HTML and CSS and then apologises, because it has nowhere to render it.&lt;/p&gt;

&lt;p&gt;The second failure is the interesting one. The model already did the design work. Layout, spacing, type scale, brand colour, all expressed precisely in the one design language every LLM has read millions of examples of. It was not missing ability. It was missing a render step.&lt;/p&gt;

&lt;p&gt;This post wires that step in over MCP, so your agent can design, render and refine real image assets inside the conversation. It is a condensed version of our full guide, &lt;a href="https://html2img.com/articles/mcp-image-generation/" rel="noopener noreferrer"&gt;How to generate images from an AI agent with MCP&lt;/a&gt;, which also covers VS Code, Windsurf and the other clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why diffusion is the wrong tool
&lt;/h2&gt;

&lt;p&gt;Diffusion models are remarkable for photographic concepts and a lottery for layout. They cannot reliably render text, cannot hit exact pixel dimensions, cannot reuse your brand tokens and cannot produce the same output twice. A pricing card, an invoice, a certificate or an OG image needs all four.&lt;/p&gt;

&lt;p&gt;HTML and CSS are the opposite: deterministic, pixel-exact, versionable and the most documented visual format in the model's training data. An LLM that mangles the word "Launch" in a generated picture will happily set it in Inter 800 at 96px with correct kerning, because that is just text.&lt;/p&gt;

&lt;p&gt;So let the model design in the language it is fluent in, and bolt a renderer onto the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An MCP-capable client (Claude Code or Cursor below)&lt;/li&gt;
&lt;li&gt;An &lt;a href="https://html2img.com/mcp" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt; API key on a paid plan&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The server is remote, so there is nothing to install or host. It lives at &lt;code&gt;https://app.html2img.com/mcp&lt;/code&gt;, speaks Streamable HTTP and takes the API key as a bearer token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: connect the server
&lt;/h2&gt;

&lt;p&gt;Claude Code is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http html2img https://app.html2img.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cursor takes the same server in &lt;code&gt;~/.cursor/mcp.json&lt;/code&gt;, or &lt;code&gt;.cursor/mcp.json&lt;/code&gt; for a single project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"html2img"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://app.html2img.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer YOUR_API_KEY"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That exposes two tools. &lt;code&gt;generate-image-from-html&lt;/code&gt; renders complete HTML to a PNG or PDF. &lt;code&gt;screenshot-url&lt;/code&gt; captures a live page, with optional injected CSS and an element selector. Both cost one credit per image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: ask for an image
&lt;/h2&gt;

&lt;p&gt;With the server connected, image generation is just conversation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Make me a 1200x630 launch card for "Relay 2.0". Dark background, big title, a one-line subtitle that says "Webhooks that retry themselves", and our accent green #10B981.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent writes something like this and calls the tool with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="sx"&gt;url('https://fonts.googleapis.com/css2?family=Inter:wght@500;800&amp;amp;display=swap')&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1200px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;630px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;flex-direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;justify-content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;box-sizing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;border-box&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0B1220&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;'Inter'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;.pill&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#10B981&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;letter-spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.14em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;text-transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;uppercase&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;96px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;800&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
       &lt;span class="nl"&gt;letter-spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-0.02em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;18px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;24px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;p&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#94A3B8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pill"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Now shipping&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Relay 2.0&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Webhooks that retry themselves.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two seconds later the rendered card is in the chat with a permanent CDN URL. No Chromium install, no Puppeteer script, no switch to a design tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: let it look at its own work
&lt;/h2&gt;

&lt;p&gt;Here is the part that makes this better than fire-and-forget generation. The tool returns two things to the model: the full-resolution URL, and a downscaled preview image. The preview means the model sees what it made.&lt;/p&gt;

&lt;p&gt;So it reviews its render the way you would. It notices the subtitle crowding the title, or a long product name clipping the right edge, then edits the HTML and renders again. You can steer with plain language ("tighten the spacing, the title feels lost") or tell it to critique its own output before showing you. Either way you iterate at conversation speed.&lt;/p&gt;

&lt;p&gt;Each pass costs a credit, so three refinements is three credits. For design work that is the right trade, because the alternative is your afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: put your brand in a rules file
&lt;/h2&gt;

&lt;p&gt;The highest-value trick is the simplest. Every agent runtime has a standing-instructions file (&lt;code&gt;CLAUDE.md&lt;/code&gt;, Cursor rules, Copilot instructions). Put your visual system in it once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;When generating images with the html2img MCP server:
&lt;span class="p"&gt;-&lt;/span&gt; Canvas 1200x630 for OG images, 1080x1080 for Instagram
&lt;span class="p"&gt;-&lt;/span&gt; Fonts: Inter for UI, weights 500 and 800 only
&lt;span class="p"&gt;-&lt;/span&gt; Background #0B1220, text #FFFFFF, muted #94A3B8, accent #10B981
&lt;span class="p"&gt;-&lt;/span&gt; Embed any image as a base64 data URI, never a remote URL
&lt;span class="p"&gt;-&lt;/span&gt; Complete, self-contained HTML with styles in one &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt; block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every image the agent produces from then on is on-brand without restating anything. The data URI rule is technical as well as stylistic: the renderer skips remote &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; sources by design, so assets go inline. Google Fonts are the exception and load server-side, which is why the &lt;code&gt;@import&lt;/code&gt; above just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to use MCP
&lt;/h2&gt;

&lt;p&gt;MCP and the REST API are the same engine, so the question is only who calls it. A person or agent in the loop, designing assets or screenshotting pages mid-task: MCP. Your application rendering an OG image per post at build time or an invoice per order: the REST API, because pipelines want code paths, not conversations.&lt;/p&gt;

&lt;p&gt;They compose well. Design the template in a chat, iterate until the render is right, then lift the final HTML into your codebase and call the API with real data. The MCP session is the design phase, the API call is production.&lt;/p&gt;

&lt;p&gt;We eat this cooking ourselves: every cover image on the HTML to Image blog is produced by an agent that writes the HTML, renders it, inspects the preview and fixes its own mistakes before anything ships. The &lt;a href="https://html2img.com/articles/mcp-image-generation/" rel="noopener noreferrer"&gt;full guide&lt;/a&gt; covers the remaining clients, the complete tool parameters and the PDF output mode.&lt;/p&gt;

&lt;p&gt;Have you wired image rendering into your agent setup, and what did you have it build first? Share it in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Generating 10,000 certificates from one HTML template</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Mon, 03 Aug 2026 20:55:31 +0000</pubDate>
      <link>https://dev.to/accreditly/generating-10000-certificates-from-one-html-template-b99</link>
      <guid>https://dev.to/accreditly/generating-10000-certificates-from-one-html-template-b99</guid>
      <description>&lt;p&gt;The day your first cohort completes a course is the day certificates stop being a design job and become an engineering problem. One certificate is a Canva export. Ten thousand is a rendering pipeline with a database table, a queue and a verification page.&lt;/p&gt;

&lt;p&gt;This post walks through the three ways teams actually build that pipeline, with working Python for each, then covers the two parts most certificate tutorials skip: batching at volume and verification. It is a condensed version of our full guide, &lt;a href="https://html2img.com/articles/how-to-generate-signed-digital-certificates-at-scale/" rel="noopener noreferrer"&gt;How to generate signed digital certificates at scale&lt;/a&gt;, which also covers storage, retention and revocation.&lt;/p&gt;

&lt;p&gt;One scope note up front. Most platform certificates do not need cryptographic signing in the PKI sense. The trust model that 95% of platforms ship is simpler: a unique ID printed on the certificate resolves to a verification page on the issuer's domain. An employer types the ID, the page confirms it. That is the model this post builds. If you need true PKI signing for regulated credentials, the stack is different (Adobe Sign, DocuSign, in-house HSM workflows) and this post is not it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What every certificate needs
&lt;/h2&gt;

&lt;p&gt;Whichever approach you pick, the output is the same:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Layout&lt;/td&gt;
&lt;td&gt;Landscape A4, 2480x1754 at 200 DPI for print&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Personal&lt;/td&gt;
&lt;td&gt;Recipient name with full Unicode support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Course&lt;/td&gt;
&lt;td&gt;Course title and completion date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Issuer&lt;/td&gt;
&lt;td&gt;Issuer name plus a signature image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ID&lt;/td&gt;
&lt;td&gt;Unique certificate ID (UUID or short slug)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verify&lt;/td&gt;
&lt;td&gt;A URL under the ID pointing to your &lt;code&gt;/verify&lt;/code&gt; route&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The signature image communicates authority but provides zero tamper resistance. The certificate ID plus the verification page is the practical trust layer. Keep both in mind as you read the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three approaches at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Render time&lt;/th&gt;
&lt;th&gt;Maintenance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PDF library (ReportLab, PDFKit)&lt;/td&gt;
&lt;td&gt;1 day&lt;/td&gt;
&lt;td&gt;200 to 400 ms&lt;/td&gt;
&lt;td&gt;Fonts, layout drift, library updates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTML plus headless Chrome&lt;/td&gt;
&lt;td&gt;2 hours&lt;/td&gt;
&lt;td&gt;1 to 3 sec&lt;/td&gt;
&lt;td&gt;Chromium, memory, queue workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Template API&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;1 to 2 sec&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Approach 1: a PDF library
&lt;/h2&gt;

&lt;p&gt;Python with ReportLab is the canonical first attempt. The output is a real PDF, text-searchable and around 30 KB per certificate, and you have absolute control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid4&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;reportlab.lib.pagesizes&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;landscape&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;A4&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;reportlab.lib.colors&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HexColor&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;reportlab.pdfgen&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;canvas&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;reportlab.lib.utils&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ImageReader&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completion_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#0F766E&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;landscape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cert_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="nb"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Canvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pagesize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;landscape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFillColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HexColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accent&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fill&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stroke&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFillColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HexColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#FFFFFF&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Helvetica-Bold&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawCentredString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;55&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Certificate of Completion&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFillColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HexColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#1F2937&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Helvetica-Bold&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawCentredString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Helvetica-Bold&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawCentredString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;340&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Helvetica&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawCentredString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;380&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;on &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;completion_date&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ImageReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signature_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;preserveAspectRatio&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Helvetica&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Certificate ID: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawRightString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Verify at northwindstudio.com/verify/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getvalue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That runs. But once you add Unicode font registration (default Helvetica only covers Latin Extended, so "Müller" and "Çelik" need a registered font), signature scaling and line wrapping for long course titles, you are at roughly 250 lines. Layout drift creeps in with every design change, because every element is a coordinate calculation. The output also looks like a 2008 PDF unless you put real design effort in.&lt;/p&gt;

&lt;p&gt;Pick this when you need low-level PDF control, your design barely changes and someone on the team is comfortable living in PDF coordinate space.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 2: an HTML template plus headless Chrome
&lt;/h2&gt;

&lt;p&gt;If your team thinks in HTML and CSS rather than PDF coordinates, this is the natural next step. The certificate becomes the same Jinja, Blade or EJS template you would use for any other branded output, and you iterate on it in a normal browser before pointing Playwright at it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.sync_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sync_playwright&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jinja2&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FileSystemLoader&lt;/span&gt;

&lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loader&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;FileSystemLoader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;templates&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;certificate.html&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completion_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;signature_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#0F766E&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;completion_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;completion_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;signature_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;signature_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;accent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;sync_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2480&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;height&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1754&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_until&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;networkidle&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;png&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full_page&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;png&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template stays under 60 lines of HTML and CSS. Real fonts via Google Fonts, Flexbox for positioning, a background colour for the accent band.&lt;/p&gt;

&lt;p&gt;The honest cost is the same as any headless Chrome workflow: Chromium on every worker, 200 to 400 MB resident per instance, and cold starts of 3 to 5 seconds on serverless. Batching 8,000 certificates a month on a t3.small will OOM. Under 1,000 a month on infrastructure you already run, this is the right pick. Above that, the render infrastructure starts costing more attention than it saves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 3: a template API
&lt;/h2&gt;

&lt;p&gt;The lowest-setup path is to send the data to a hosted template and get an image back. The &lt;a href="https://html2img.com/templates/certificate-of-completion/" rel="noopener noreferrer"&gt;certificate-of-completion template&lt;/a&gt; accepts the fields an LMS has to hand and returns a print-ready 2480x1754 PNG, or a PDF when you set &lt;code&gt;format&lt;/code&gt; to &lt;code&gt;pdf&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completion_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;signature_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#0F766E&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://app.html2img.com/api/v1/templates/certificate-of-completion&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;X-API-Key&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HTML_TO_IMAGE_KEY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;recipient_name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;course_name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;completion_date&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;completion_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;issuer_name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;issuer_signature_url&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;signature_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;certificate_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;accent_color&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;accent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Chromium, no fonts, no workers. The trade is a credit per render, so run the maths against your real volume: a platform issuing 200 certificates a week (10,400 a year) sits comfortably inside a $60 a month plan, and a $25 plan covers 3,000 renders a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batching 10,000
&lt;/h2&gt;

&lt;p&gt;Here is where the title's number earns its place. A cohort completing together looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid4&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;issue_cohort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cohort_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completion_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature_url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;students&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enrollment&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;cohort_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cohort_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;student&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;students&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cert_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;student&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;course_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;completion_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;completion_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Northwind Studio&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;signature_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;signature_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Certificate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;student&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;certificate_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;issued_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That loop is fine for a hundred students. At 1 to 2 seconds per render, 10,000 certificates issued serially take three to five hours, and your worker hangs on every request. The fix is to stop waiting. Pass a webhook URL instead and let the renders come back to you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://app.html2img.com/api/v1/templates/certificate-of-completion&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;X-API-Key&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HTML_TO_IMAGE_KEY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;recipient_name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;course_name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;completion_date&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;completion_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;issuer_name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;issuer_signature_url&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;signature_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;certificate_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;webhook_url&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://northwindstudio.com/webhooks/cert/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dispatch loop now takes minutes, and the webhook handler updates each &lt;code&gt;Certificate&lt;/code&gt; row with the rendered URL as it lands. The same pattern applies to Approach 2 if you push renders onto a queue: fire the jobs, collect results asynchronously, never block issuance on rendering.&lt;/p&gt;

&lt;p&gt;Two batching rules regardless of approach. Generate the certificate ID at dispatch time, not render time, so a retried render is idempotent and never mints a duplicate. And store the image URL in the database rather than the bytes, backing it up to your own S3 bucket if you have multi-year retention requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verification page
&lt;/h2&gt;

&lt;p&gt;This is the part every certificate tutorial skips, and it is the part that makes the certificate worth anything. A printed ID without a resolver is just a number. The trust anchor is the page on your domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;render_template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;abort&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/verify/&amp;lt;cert_id&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cert_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Certificate&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;certificate_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cert_id&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;verify.html&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;student&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;course_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;issued_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issued_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The page shows the recipient, the course, the issue date and the certificate image itself. The certificate links here via the printed URL, the page confirms the certificate. The loop closes.&lt;/p&gt;

&lt;p&gt;Three design notes from running this in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use UUIDs or 12-character hex for the ID, never auto-increment integers. Sequential IDs leak your issuance volume to anyone holding two certificates.&lt;/li&gt;
&lt;li&gt;Serve a plain 404 for revoked certificates, not a "this was revoked" page. An employer hitting a 404 will contact you directly, which is exactly what you want in the rare revocation case.&lt;/li&gt;
&lt;li&gt;Let the verify pages be indexed. They are your trust anchor and you want search engines to know they exist.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Picking one
&lt;/h2&gt;

&lt;p&gt;Under 1,000 certificates a month on infrastructure you already maintain, headless Chrome is fine. If you need embedded PDF metadata and your design is frozen, ReportLab earns its 250 lines. In the 100 to 10,000 a month band, or if you would rather spend engineering time on the learning platform than on render infrastructure, the template API is the sensible default, and there is a free tier to test it on a small cohort first.&lt;/p&gt;

&lt;p&gt;The full guide, including storage, retention, diacritics handling and adding a QR code to the verify flow, is over on &lt;a href="https://html2img.com/articles/how-to-generate-signed-digital-certificates-at-scale/" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;How are you issuing certificates on your platform, and has the verification side ever come up with employers? Share your setup in the comments.&lt;/p&gt;

</description>
      <category>python</category>
      <category>tutorial</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Practical Uses for CSS :has() Now That It Works Everywhere</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Sun, 02 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/accreditly/practical-uses-for-css-has-now-that-it-works-everywhere-4ccf</link>
      <guid>https://dev.to/accreditly/practical-uses-for-css-has-now-that-it-works-everywhere-4ccf</guid>
      <description>&lt;p&gt;For about twenty years the answer to "how do I style a parent based on its children?" was "you can't, use JavaScript". Then &lt;code&gt;:has()&lt;/code&gt; arrived, and it has now been supported in every major browser for long enough that you can use it without a second thought. The interesting question is no longer whether you can use it, but what it is actually good for. Here are the patterns I keep reaching for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Style the wrapper, not just the input
&lt;/h2&gt;

&lt;p&gt;Form fields are usually an input wrapped in a container with a label, a hint and an error slot. Before &lt;code&gt;:has()&lt;/code&gt;, styling that container based on the input's state meant toggling classes with JavaScript. Now it is one selector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.field&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="nd"&gt;:focus-visible&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#2563eb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;outline-offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.field&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="nd"&gt;:user-invalid&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#dc2626&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.field&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="nd"&gt;:user-invalid&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;.hint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#dc2626&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;:user-invalid&lt;/code&gt; rather than &lt;code&gt;:invalid&lt;/code&gt;. Plain &lt;code&gt;:invalid&lt;/code&gt; matches a required field the moment the page loads, before anyone has typed anything, so your form opens in a wall of red. &lt;code&gt;:user-invalid&lt;/code&gt; only matches after the user has interacted with the field, which is what you actually wanted all along.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cards that adapt to their own content
&lt;/h2&gt;

&lt;p&gt;A card grid where some cards have images and some do not usually ends up with a &lt;code&gt;.card--has-image&lt;/code&gt; modifier class that someone has to remember to apply. The card can just check for itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;img&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;img&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;.card-body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CMS or the component can stop caring. If an editor adds an image, the layout adjusts. If they remove it, the padding comes back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Styling the previous sibling
&lt;/h2&gt;

&lt;p&gt;CSS has always been able to style the &lt;em&gt;next&lt;/em&gt; sibling with &lt;code&gt;+&lt;/code&gt;, but never the previous one. &lt;code&gt;:has()&lt;/code&gt; fixes that through a small trick: select the element whose next sibling is hovered.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(+&lt;/span&gt; &lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.02&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hovered item grows, and the item before it grows slightly too. You get the smooth dock-style ripple effect that previously needed a JavaScript mouseover handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Page-level state without JavaScript
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;:has()&lt;/code&gt; can sit on &lt;code&gt;body&lt;/code&gt; or &lt;code&gt;html&lt;/code&gt;, a checkbox anywhere in the document can drive layout anywhere else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;#menu-toggle&lt;/span&gt;&lt;span class="nd"&gt;:checked&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;#menu-toggle&lt;/span&gt;&lt;span class="nd"&gt;:checked&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;.drawer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hamburger menu that locks page scroll and slides a drawer in, with the checkbox hack doing the state management and zero script. For anything more complex than a toggle you will still want JavaScript, but for a mobile nav this is genuinely all it takes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quantity queries
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;:has()&lt;/code&gt; can ask how many children an element contains, which is brilliant for layouts that should change shape as content grows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;6&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as: if this list contains a sixth item, split into two columns. The same idea handles galleries that switch grid density once they pass a threshold, or a tag list that shrinks its font when it gets crowded. No resize observers, no counting nodes in script.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas
&lt;/h2&gt;

&lt;p&gt;Three things to know before you sprinkle this everywhere.&lt;/p&gt;

&lt;p&gt;First, specificity. &lt;code&gt;:has()&lt;/code&gt; takes the specificity of the most specific selector in its argument list, so &lt;code&gt;.card:has(#featured)&lt;/code&gt; carries id-level weight and will win fights you did not expect it to.&lt;/p&gt;

&lt;p&gt;Second, you cannot nest &lt;code&gt;:has()&lt;/code&gt; inside &lt;code&gt;:has()&lt;/code&gt;. The browser will drop the rule.&lt;/p&gt;

&lt;p&gt;Third, unlike &lt;code&gt;:is()&lt;/code&gt; and &lt;code&gt;:where()&lt;/code&gt;, the selector list inside &lt;code&gt;:has()&lt;/code&gt; is not forgiving. One invalid or unsupported selector in the list invalidates the whole rule, so keep experimental selectors out of shared argument lists.&lt;/p&gt;

&lt;p&gt;Performance-wise, browsers have done serious optimisation work here. Sensible, qualified selectors are fine in production. Just avoid pathological things like &lt;code&gt;div:has(*)&lt;/code&gt; on enormous documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;:has()&lt;/code&gt; turns a pile of state-management JavaScript into declarative CSS: form field states, content-aware cards, previous-sibling effects, page-level toggles and quantity queries. Combined with &lt;code&gt;:user-invalid&lt;/code&gt; it also quietly fixes how form validation feels.&lt;/p&gt;

&lt;p&gt;What have you replaced JavaScript with since &lt;code&gt;:has()&lt;/code&gt; landed? Share your favourite pattern in the comments below.&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
