<?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 we generate code screenshots for socials</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Wed, 17 Jun 2026 16:00:00 +0000</pubDate>
      <link>https://dev.to/accreditly/how-we-generate-code-screenshots-for-socials-b91</link>
      <guid>https://dev.to/accreditly/how-we-generate-code-screenshots-for-socials-b91</guid>
      <description>&lt;p&gt;We post a lot of code to X and LinkedIn. For a long time we grabbed those screenshots by hand: snippet open in the editor, crop the window, drop the PNG into the post. It worked, but it never stayed consistent. The theme drifted between posts, the widths were all over the place, the output looked soft on retina screens, and roughly once a month someone shipped a screenshot with the file tree still showing.&lt;/p&gt;

&lt;p&gt;So we stopped doing it by hand. These days we generate code screenshots two ways, and which one we reach for depends entirely on whether a human or a machine is making the image. For one-offs we use a browser tool, the &lt;a href="https://html2img.com/tools/code-screenshot/" rel="noopener noreferrer"&gt;Code Screenshot Generator&lt;/a&gt;, which turns a snippet into a PNG without going anywhere near the codebase. For anything that needs to happen at publish time, or the same way across fifty snippets, we call a code screenshot API instead. This post covers both, and why we settled on the split.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why screenshotting the editor does not scale
&lt;/h2&gt;

&lt;p&gt;Grabbing the editor window is fine for one image. The trouble starts when you do it repeatedly.&lt;/p&gt;

&lt;p&gt;Every screenshot ends up slightly different. One uses your current editor theme, the next uses whatever you switched to last week. The line gutter is in one, absent from another. The padding is whatever the window happened to be. Crop them by hand and the widths never match, so a thread of three snippets reads as three different posts stitched together. And because you are capturing a real screen, the output is tied to your display, which means it looks crisp on your machine and soft on everyone else's phone.&lt;/p&gt;

&lt;p&gt;Carbon and Ray.so solve the prettiness problem, and we used them happily for years. They are genuinely good in-browser tools. The catch is that both are in-browser only, with no API. The moment you want a screenshot generated automatically, at publish time or inside a build step, a hand tool is the wrong shape for the job. You cannot put a button-click in a deploy pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quick route: a browser tool for one-offs
&lt;/h2&gt;

&lt;p&gt;When it is a single snippet for a tweet or a slide, and you are away from the code anyway, the fastest path is the &lt;a href="https://html2img.com/tools/code-screenshot/" rel="noopener noreferrer"&gt;online code screenshot tool&lt;/a&gt;. Paste the snippet, pick the language, and you get a clean image back.&lt;/p&gt;

&lt;p&gt;It highlights any language Prism or highlight.js recognises, which is roughly three hundred of them, so JavaScript, Python, Rust, SQL, Elixir and the long tail are all covered. You pick a theme (One Dark, Dracula, GitHub, Night Owl and a handful of others, each matching its VS Code counterpart closely), toggle the macOS-style window chrome on or off, set the padding, and choose a solid colour or a gradient behind the code. Then you download the PNG or copy the hosted URL straight into a post.&lt;/p&gt;

&lt;p&gt;That covers the ad-hoc case. The interesting part is what happens when you want the same output without a person in the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repeatable route: a code screenshot API
&lt;/h2&gt;

&lt;p&gt;When the image needs to be produced by a machine, at publish time, in CI, or off a CMS hook, we use the &lt;a href="https://html2img.com/templates/code-screenshot/" rel="noopener noreferrer"&gt;Code Screenshot API&lt;/a&gt;. It is a ready-made template on HTML to Image (html2img.com): you POST the code and a few styling options as JSON, and you get back a hosted PNG URL. It is the same renderer that sits behind the browser tool, so the screenshots match whichever route produced them. There is no headless Chrome for you to install, patch, or babysit.&lt;/p&gt;

&lt;p&gt;You need two things to follow along: an account with an API key, and a snippet to render. Here is the smallest call that does something useful, with &lt;code&gt;curl&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;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://app.html2img.com/api/v1/templates/code-screenshot &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-API-Key: YOUR_API_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "code": "export async function fetchUser(id: string) {\n  const res = await fetch(`/api/users/${id}`);\n\n  if (!res.ok) {\n    throw new Error(`Failed to load user ${id}`);\n  }\n\n  return res.json() as Promise&amp;lt;User&amp;gt;;\n}",
    "language": "typescript",
    "title": "src/lib/users.ts",
    "theme": "atom-one-dark",
    "background": "linear-gradient(135deg, #6366F1 0%, #8B5CF6 50%, #EC4899 100%)",
    "padding": 72,
    "show_window_chrome": "true",
    "show_line_numbers": "false"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fields map onto what you would set in the browser tool. &lt;code&gt;code&lt;/code&gt; is the snippet, with newlines escaped in the JSON string. &lt;code&gt;language&lt;/code&gt; drives the highlighting. &lt;code&gt;title&lt;/code&gt; is the filename shown in the window bar. &lt;code&gt;theme&lt;/code&gt;, &lt;code&gt;background&lt;/code&gt;, and &lt;code&gt;padding&lt;/code&gt; are the styling, and the two &lt;code&gt;show_&lt;/code&gt; flags toggle the window chrome and line numbers. Only &lt;code&gt;code&lt;/code&gt; is required; leave the rest off and the template falls back to sensible defaults. The full input list, with example values and validation rules, is in the &lt;a href="https://html2img.com/docs/templates/code-screenshot/" rel="noopener noreferrer"&gt;Code Screenshot template reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;By default the template renders a 1600 by 1000 PNG, which you can override with &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt;. A successful call returns JSON:&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;1234&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"template"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"code-screenshot"&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 CDN link to the rendered image. That URL is the whole point: you store it, embed it, or attach it to a post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into the publishing flow
&lt;/h2&gt;

&lt;p&gt;In practice you do not run &lt;code&gt;curl&lt;/code&gt;, you call this from the code that publishes the post. Here is the Node version wrapped in a small helper that hands back the image URL:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;codeScreenshot&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="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&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;res&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/v1/templates/code-screenshot&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;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="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="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;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;atom-one-dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;linear-gradient(135deg, #6366F1 0%, #8B5CF6 50%, #EC4899 100%)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;show_window_chrome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;show_line_numbers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;false&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;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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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;`code-screenshot render failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&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="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;res&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;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it once per snippet when a post is published, save the returned URL alongside the post, and attach that image to the tweet or LinkedIn share. The code is rendered once and served from the CDN forever after, so there is no per-view cost.&lt;/p&gt;

&lt;p&gt;We mostly publish from Laravel, and the shape is identical there. The HTTP client does the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withHeaders&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'X-API-Key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.html2img.key'&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://app.html2img.com/api/v1/templates/code-screenshot'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'code'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$snippet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'language'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'app/Actions/RenderSnippet.php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'theme'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'atom-one-dark'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'show_window_chrome'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&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 other languages follow the same pattern, and the reference has worked examples for Python, Ruby, React and Vue if you want a closer fit to your stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the screenshots to actually read on social
&lt;/h2&gt;

&lt;p&gt;A clean render is only half the job. Social feeds re-compress your image and shrink it, so a few habits make the difference between a screenshot people can read and one they scroll past.&lt;/p&gt;

&lt;p&gt;Trim the snippet first. Anything past roughly twenty lines shrinks below a readable point size once the renderer scales it to fit, so cut to the part that matters and split a longer example into a sequence of smaller images rather than one tall one nobody can read.&lt;/p&gt;

&lt;p&gt;Pick a theme that survives compression. Low-contrast themes like Solarized Light lose detail when X or LinkedIn re-encode the image for the feed. Higher-contrast themes (One Dark, Dracula, GitHub) hold up far better through that round trip, so favour them for anything social.&lt;/p&gt;

&lt;p&gt;Keep it sharp. The browser tool has a 2x DPI toggle for retina output. Through the API the default 1600 by 1000 render is already large enough to stay crisp after a feed re-compresses it, and you can push the dimensions higher if you need more headroom.&lt;/p&gt;

&lt;p&gt;Cache the URL hard. A rendered snippet's CDN URL does not change unless the code changes, so set long cache headers and only re-render when the snippet itself moves. Re-rendering an unchanged screenshot on every publish is wasted work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one we reach for
&lt;/h2&gt;

&lt;p&gt;The split is simple. One-offs, and anything where you are already away from the editor, go through the &lt;a href="https://html2img.com/tools/code-screenshot/" rel="noopener noreferrer"&gt;code screenshot generator&lt;/a&gt; in the browser. Anything repeatable, or that has to happen at publish time, goes through the &lt;a href="https://html2img.com/templates/code-screenshot/" rel="noopener noreferrer"&gt;code screenshot template&lt;/a&gt; and its API. Because both run the same renderer, the two never drift apart, which was the original problem we set out to fix.&lt;/p&gt;

&lt;p&gt;If you want to go deeper, the &lt;a href="https://html2img.com/docs/templates/code-screenshot/" rel="noopener noreferrer"&gt;Code Screenshot API reference&lt;/a&gt; lists every input, including line numbers, diff highlighting by setting the language to &lt;code&gt;diff&lt;/code&gt;, and custom themes via a Prism variables block.&lt;/p&gt;

&lt;p&gt;How do you handle code screenshots for your posts at the moment, still grabbing them by hand, or have you wired it into publishing? Let me know in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Wed, 17 Jun 2026 15:38:11 +0000</pubDate>
      <link>https://dev.to/accreditly/-3mob</link>
      <guid>https://dev.to/accreditly/-3mob</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/accreditly/dynamic-og-images-in-rails-1p9m" class="crayons-story__hidden-navigation-link"&gt;Dynamic OG Images in Rails&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/accreditly" class="crayons-avatar  crayons-avatar--l  "&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1045142%2Fa534ea30-7b1e-4199-b7e1-87688d0761db.png" alt="accreditly profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/accreditly" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Accreditly
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Accreditly
                
              
              &lt;div id="story-author-preview-content-3865132" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/accreditly" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1045142%2Fa534ea30-7b1e-4199-b7e1-87688d0761db.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Accreditly&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/accreditly/dynamic-og-images-in-rails-1p9m" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jun 10&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/accreditly/dynamic-og-images-in-rails-1p9m" id="article-link-3865132"&gt;
          Dynamic OG Images in Rails
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/rails"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;rails&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ruby"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ruby&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/tutorial"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;tutorial&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/accreditly/dynamic-og-images-in-rails-1p9m" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/raised-hands-74b2099fd66a39f2d7eed9305ee0f4553df0eb7b4f11b01b6b1b499973048fe5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;7&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/accreditly/dynamic-og-images-in-rails-1p9m#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            6 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Use Laravel to create your own MCP server</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Tue, 16 Jun 2026 14:20:41 +0000</pubDate>
      <link>https://dev.to/accreditly/use-laravel-to-create-your-own-mcp-server-251g</link>
      <guid>https://dev.to/accreditly/use-laravel-to-create-your-own-mcp-server-251g</guid>
      <description>&lt;p&gt;Claude can already work with your Laravel app. Not by you hand-building a REST API, writing a client, and describing every endpoint to it, but by exposing a few tools over the Model Context Protocol (MCP) and letting the model call them directly. The official &lt;code&gt;laravel/mcp&lt;/code&gt; package turns that into an afternoon's work.&lt;/p&gt;

&lt;p&gt;This is the hands-on version. We'll build a small MCP server for an online shop: tools the model can call, a resource it can read, input validation, auth, and a test to keep it honest. The full write-up of how the protocol fits together lives on our site, &lt;a href="https://accreditly.io/articles/use-laravel-to-create-your-own-mcp-server" rel="noopener noreferrer"&gt;Use Laravel to create your own MCP server&lt;/a&gt;. Here we're going to write the code.&lt;/p&gt;

&lt;p&gt;First, the short version of what we're building. An MCP server exposes three things to a connected AI client: &lt;strong&gt;tools&lt;/strong&gt; (actions the model can call, like searching orders), &lt;strong&gt;resources&lt;/strong&gt; (read-only data it can pull in for context), and &lt;strong&gt;prompts&lt;/strong&gt; (reusable templates). The client and server talk JSON-RPC, and &lt;code&gt;laravel/mcp&lt;/code&gt; handles that wire format so you only write PHP.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A recent Laravel application (12.x works well).&lt;/li&gt;
&lt;li&gt;PHP 8.2 or newer.&lt;/li&gt;
&lt;li&gt;Composer.&lt;/li&gt;
&lt;li&gt;An MCP client to connect with later. Claude Desktop or Claude Code both work, and the bundled MCP Inspector covers testing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Install the package
&lt;/h2&gt;

&lt;p&gt;Pull it in with Composer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require laravel/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then publish the routes file your servers are registered in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ai-routes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you &lt;code&gt;routes/ai.php&lt;/code&gt;. Treat it like &lt;code&gt;routes/web.php&lt;/code&gt;: every server you expose gets a line in there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Create a server
&lt;/h2&gt;

&lt;p&gt;A server is the thing a client connects to. It groups your tools, resources and prompts under one name. Generate one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan make:mcp-server OrdersServer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get &lt;code&gt;app/Mcp/Servers/OrdersServer.php&lt;/code&gt;. The class carries its identity in attributes and lists what it exposes in three arrays:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Mcp\Servers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Mcp\Tools\SearchOrdersTool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Instructions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Version&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Name('Orders Server')]&lt;/span&gt;
&lt;span class="na"&gt;#[Version('1.0.0')]&lt;/span&gt;
&lt;span class="na"&gt;#[Instructions('Search orders, add internal notes, and cancel orders for the shop.')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrdersServer&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Server&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;SearchOrdersTool&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$resources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&gt;//&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$prompts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&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;Don't skip the &lt;code&gt;Instructions&lt;/code&gt; attribute. It is sent to the client as context for what the server is for, so the model knows what it is looking at before it calls anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Register the server
&lt;/h2&gt;

&lt;p&gt;The server does nothing until it is registered in &lt;code&gt;routes/ai.php&lt;/code&gt;. There are two kinds. A web server is reachable over HTTP for remote clients, and a local server runs as an Artisan command for agents on the same machine, like Claude Code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Mcp\Servers\OrdersServer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Facades\Mcp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/mcp/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrdersServer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'throttle:60,1'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrdersServer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Web servers are ordinary routes, so the middleware you already use applies. We'll come back to the auth line in Step 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Build a tool
&lt;/h2&gt;

&lt;p&gt;This is the part that does real work. Generate a tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan make:mcp-tool SearchOrdersTool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A tool has two methods. &lt;code&gt;schema&lt;/code&gt; declares the arguments it accepts, and &lt;code&gt;handle&lt;/code&gt; runs the work and returns a response. Here's a read-only search over orders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Mcp\Tools&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Contracts\JsonSchema\JsonSchema&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Description&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Tool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Tools\Annotations\IsReadOnly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[IsReadOnly]&lt;/span&gt;
&lt;span class="na"&gt;#[Description('Search recent orders, optionally filtered by status, and return their references and totals.')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SearchOrdersTool&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$validated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'nullable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'in:pending,shipped,delivered,cancelled'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'limit'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'between:1,50'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nv"&gt;$orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'limit'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&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="nv"&gt;$orders&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isEmpty&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="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'No orders matched that search.'&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="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;structured&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'count'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$orders&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'orders'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$orders&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&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="s1"&gt;'reference'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'placed_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toIso8601String&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;JsonSchema&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&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="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'delivered'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Only return orders with this status.'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

            &lt;span class="s1"&gt;'limit'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Maximum number of orders to return.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's already in the server's &lt;code&gt;$tools&lt;/code&gt; from Step 2, so the client can call it now.&lt;/p&gt;

&lt;p&gt;Two things are worth calling out. The schema is a typed contract for the model: &lt;code&gt;status&lt;/code&gt; is one of four values, &lt;code&gt;limit&lt;/code&gt; is an integer with a default. And the &lt;code&gt;Description&lt;/code&gt; is how the model decides when to reach for the tool, so write it like you're briefing someone who has never seen your code. &lt;code&gt;Response::structured()&lt;/code&gt; sends back parseable data while keeping a plain text version for clients that want one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Validate the input, and write errors the model will read
&lt;/h2&gt;

&lt;p&gt;The schema sets the shape. Laravel's validator enforces the rules, exactly as in a controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$validated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'reference'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:32'&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="s1"&gt;'reference.required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Provide the order reference, for example "ORD-10423".'&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;Here's the part people miss. The error message goes back to the model, and the model decides what to do next from what it says. "The reference field is required" tells it nothing. "Provide the order reference, for example ORD-10423" tells it how to retry. Write validation messages as instructions to a reader who is going to act on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Tell the client how a tool behaves
&lt;/h2&gt;

&lt;p&gt;Annotations describe a tool's behaviour without changing what it does. A client uses them to decide how to present a tool, for example asking the user to confirm before running anything that makes a change. They are attributes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Mcp\Tools&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Contracts\JsonSchema\JsonSchema&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Description&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Tool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Tools\Annotations\IsDestructive&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Tools\Annotations\IsIdempotent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[IsDestructive]&lt;/span&gt;
&lt;span class="na"&gt;#[IsIdempotent]&lt;/span&gt;
&lt;span class="na"&gt;#[Description('Cancel an order. An order that is already cancelled is left unchanged.')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CancelOrderTool&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$validated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'reference'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:32'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'reference'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'reference'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;firstOrFail&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="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&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="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Order &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is cancelled."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;JsonSchema&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&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="s1"&gt;'reference'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The reference of the order to cancel.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are four: &lt;code&gt;#[IsReadOnly]&lt;/code&gt; (changes nothing), &lt;code&gt;#[IsDestructive]&lt;/code&gt; (can make destructive changes), &lt;code&gt;#[IsIdempotent]&lt;/code&gt; (running it again with the same arguments changes nothing further), and &lt;code&gt;#[IsOpenWorld]&lt;/code&gt; (it touches systems outside your app). Cancelling an order is destructive but idempotent: cancel it twice and it is still just cancelled. Add &lt;code&gt;CancelOrderTool::class&lt;/code&gt; to the server's &lt;code&gt;$tools&lt;/code&gt; so it can be called.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Add a resource and a prompt
&lt;/h2&gt;

&lt;p&gt;Tools are actions. The other two primitives fill in the picture.&lt;/p&gt;

&lt;p&gt;A resource is read-only context the model can pull in. No arguments, just a &lt;code&gt;handle&lt;/code&gt; that returns content, like a returns policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Mcp\Resources&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Description&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Resource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Description('The shop returns and refunds policy.')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefundPolicyResource&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Resource&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resource_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'policies/refunds.md'&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;A prompt is a reusable template the client can offer the user. It declares its arguments and returns the messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Mcp\Prompts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Attributes\Description&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Prompt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Server\Prompts\Argument&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Description('Draft a short status update to send to a customer about their order.')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderUpdatePrompt&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Prompt&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'reference'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'The order the update is about.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&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="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$reference&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'reference'&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="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'You write friendly customer service messages.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;asAssistant&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Draft a short update for the customer about order &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$reference&lt;/span&gt;&lt;span class="si"&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="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;Generate these with &lt;code&gt;make:mcp-resource&lt;/code&gt; and &lt;code&gt;make:mcp-prompt&lt;/code&gt;, then register them in the server's &lt;code&gt;$resources&lt;/code&gt; and &lt;code&gt;$prompts&lt;/code&gt; arrays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Secure the server
&lt;/h2&gt;

&lt;p&gt;A web MCP server is a public endpoint that can read and change your data. Leaving it open is the same mistake as shipping an admin API with no auth. Because it is a normal route, you protect it with middleware.&lt;/p&gt;

&lt;p&gt;The simple option is a token with Laravel Sanctum. The client sends it in the &lt;code&gt;Authorization&lt;/code&gt; header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/mcp/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrdersServer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'throttle:60,1'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For third-party clients, OAuth 2.1 through Laravel Passport is the stronger choice. Register the discovery routes and apply Passport's guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;oauthRoutes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/mcp/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrdersServer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'auth:api'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once a user is authenticated, the request carries them into your tools, so &lt;code&gt;$request-&amp;gt;user()&lt;/code&gt; works as normal. You can even hide a tool per user with a &lt;code&gt;shouldRegister&lt;/code&gt; method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;shouldRegister&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'manage-orders'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&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 tool whose &lt;code&gt;shouldRegister&lt;/code&gt; returns false never shows up in the client's list and cannot be called. One server, different tools for different users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: Inspect and test it
&lt;/h2&gt;

&lt;p&gt;Two things check the server before a real client touches it.&lt;/p&gt;

&lt;p&gt;The MCP Inspector connects to a server and lists its tools, resources and prompts so you can call them by hand. Point it at a registered server by name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan mcp:inspector orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It prints the client settings to copy into your MCP client. If the server is behind auth, you add the header there too.&lt;/p&gt;

&lt;p&gt;For automated coverage, write a normal test and call the primitive on the server that registers it. The response has assertion helpers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Mcp\Servers\OrdersServer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Mcp\Tools\CancelOrderTool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cancels an order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'reference'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ORD-10423'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrdersServer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CancelOrderTool&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'reference'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ORD-10423'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertOk&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ORD-10423 is cancelled'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cancelled'&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;There are matching assertions for errors and notifications, and an &lt;code&gt;actingAs&lt;/code&gt; helper for testing tools that depend on the authenticated user.&lt;/p&gt;

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

&lt;p&gt;You have gone from an empty Laravel app to an MCP server with a read tool, a write tool, a resource, a prompt, authentication, and a test. The model now works against your application through one standard interface, and you never wrote a line of protocol code. The pattern from here is small: add a tool, give it a clear description and honest annotations, validate its input with messages worth reading, and put it behind the right middleware.&lt;/p&gt;

&lt;p&gt;For the longer explanation of how MCP fits together, the full article is on our site: &lt;a href="https://accreditly.io/articles/use-laravel-to-create-your-own-mcp-server" rel="noopener noreferrer"&gt;Use Laravel to create your own MCP server&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you wired an MCP server into something useful yet? Tell me what you connected it to in the comments.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Dynamic OG Images in Rails</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Wed, 10 Jun 2026 11:26:35 +0000</pubDate>
      <link>https://dev.to/accreditly/dynamic-og-images-in-rails-1p9m</link>
      <guid>https://dev.to/accreditly/dynamic-og-images-in-rails-1p9m</guid>
      <description>&lt;p&gt;Every blog post, product page, and profile in a Rails app deserves its own Open Graph image, the picture that shows up when someone shares the link on Twitter, LinkedIn, or Slack. The catch is that Ruby's options for generating those images are worse than the equivalents in the JavaScript and PHP worlds. There's no Satori, no first-class templating-to-image story, and the tools that do exist either make you place pixels by hand or run a headless browser next to Puma.&lt;/p&gt;

&lt;p&gt;This post walks through the three realistic ways to do it in Rails, then builds out the one that keeps your view layer and your servers clean: rendering an ERB template to a PNG over HTTP, with caching and a background job so it never sits on the request path.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is a cross-post of an article first published on &lt;a href="https://html2img.com/articles/dynamic-og-images-in-rails/" rel="noopener noreferrer"&gt;html2img.com&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The three approaches
&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;What runs&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Image libraries (MiniMagick, ruby-vips)&lt;/td&gt;
&lt;td&gt;ImageMagick or libvips on your server&lt;/td&gt;
&lt;td&gt;Fixed layouts with very little text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headless Chrome (Grover)&lt;/td&gt;
&lt;td&gt;Node and a Chromium binary alongside Puma&lt;/td&gt;
&lt;td&gt;Full CSS control, if you can carry the ops cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTML to Image API&lt;/td&gt;
&lt;td&gt;One HTTP call, nothing local&lt;/td&gt;
&lt;td&gt;Apps that want CSS layouts without running a browser&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Approach 1: image libraries, and why they hurt
&lt;/h2&gt;

&lt;p&gt;The pure-Ruby route is MiniMagick or ruby-vips. You open a base image and composite text onto it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"mini_magick"&lt;/span&gt;

&lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;MiniMagick&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;root&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="s2"&gt;"app/assets/images/og-base.png"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;combine_options&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gravity&lt;/span&gt; &lt;span class="s2"&gt;"NorthWest"&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;pointsize&lt;/span&gt; &lt;span class="s2"&gt;"64"&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;fill&lt;/span&gt; &lt;span class="s2"&gt;"#0F172A"&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;font&lt;/span&gt; &lt;span class="s2"&gt;"Inter-Bold"&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;annotate&lt;/span&gt; &lt;span class="s2"&gt;"+80+80"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;root&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="s2"&gt;"public/og/&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.png"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works until the text gets interesting. You're positioning every element by hand, with no line wrapping and no idea how wide a string will render until you measure it. You also have to install and reference font files on every machine. For one short line on a fixed background it's fine. The moment you want a title that wraps, an author line, and a logo, you're reimplementing CSS layout in ImageMagick options. Wrong job for the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 2: headless Chrome with Grover
&lt;/h2&gt;

&lt;p&gt;If you want real CSS, the obvious move is to render HTML in a real browser. Grover wraps Puppeteer and turns HTML into a PNG.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Gemfile&lt;/span&gt;
&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"grover"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&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="ss"&gt;template: &lt;/span&gt;&lt;span class="s2"&gt;"og/post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;layout: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;assigns: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;post: &lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="p"&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="no"&gt;Grover&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&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="ss"&gt;width: &lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;height: &lt;/span&gt;&lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_png&lt;/span&gt;
&lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;binwrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;root&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="s2"&gt;"public/og/&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.png"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;png&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is excellent, because it's a browser. The cost is operational. Grover drives Puppeteer, so every machine that renders an image needs Node and a Chromium binary installed next to your Ruby app. On a container that means a much larger image, a few hundred megabytes of Chrome resident whenever it runs, and cold-start latency when the process spins up. You're operating a browser to make a picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 3: render an ERB template through an API
&lt;/h2&gt;

&lt;p&gt;The third option keeps the browser-quality rendering but moves it off your infrastructure. You write the OG card as a normal ERB template, render it to an HTML string, and POST that string to an image API. One HTTP call, nothing extra to install.&lt;/p&gt;

&lt;p&gt;Start with the key. Add it to your encrypted credentials with &lt;code&gt;rails credentials:edit&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;html2img&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;your-key-here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key goes out as an &lt;code&gt;X-API-Key&lt;/code&gt; header on every request. Now build the template. It's a plain view, styled with inline CSS, sized to exactly the dimensions you'll request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;%# app/views/og/post.html.erb %&amp;gt;&lt;/span&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;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"utf-8"&lt;/span&gt;&lt;span class="nt"&gt;&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@400;600;800&amp;amp;display=swap')&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="o"&gt;*&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;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="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;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;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="n"&gt;space-between&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;#0F172A&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;#F8FAFC&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;.title&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;64px&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;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1000px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nc"&gt;.meta&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;26px&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="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;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"meta"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt; &lt;span class="ni"&gt;&amp;amp;middot;&lt;/span&gt; &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"%-d %B %Y"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/div&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;Set the &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; on the body to match what you send the API, otherwise the content can shift. Because this renders in a real browser, emoji, web fonts, and full CSS behave exactly as they do in your own tab.&lt;/p&gt;

&lt;p&gt;Wrap the call in a plain service object. &lt;code&gt;ApplicationController.render&lt;/code&gt; turns the template into a string outside the request cycle, and Faraday posts it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/services/og_image_generator.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OgImageGenerator&lt;/span&gt;
  &lt;span class="no"&gt;ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://app.html2img.com/api/html"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;

  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GenerationError&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;StandardError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="vi"&gt;@post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&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="ss"&gt;template: &lt;/span&gt;&lt;span class="s2"&gt;"og/post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;layout: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;assigns: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;post: &lt;/span&gt;&lt;span class="vi"&gt;@post&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;connection&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="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;html: &lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;width: &lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;height: &lt;/span&gt;&lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;to_json&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;GenerationError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success?&lt;/span&gt;

    &lt;span class="no"&gt;JSON&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="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;connection&lt;/span&gt;
    &lt;span class="vi"&gt;@connection&lt;/span&gt; &lt;span class="o"&gt;||=&lt;/span&gt; &lt;span class="no"&gt;Faraday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;url: &lt;/span&gt;&lt;span class="no"&gt;ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"application/json"&lt;/span&gt;
      &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"X-API-Key"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:html2img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:api_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is JSON with a &lt;code&gt;url&lt;/code&gt; pointing at the hosted PNG. Store that on the record rather than proxying the image through your own app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating off the request path
&lt;/h2&gt;

&lt;p&gt;You never want to make an external HTTP call while a user waits for a page, so move generation into a background job.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/jobs/generate_og_image_job.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GenerateOgImageJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationJob&lt;/span&gt;
  &lt;span class="n"&gt;queue_as&lt;/span&gt; &lt;span class="ss"&gt;:default&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&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="no"&gt;OgImageGenerator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_columns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;og_image_url: &lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;og_signature: &lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current_og_signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;update_columns&lt;/code&gt; writes straight to the database without firing callbacks, which matters in a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching so you don't regenerate on every save
&lt;/h2&gt;

&lt;p&gt;Regenerating the image every time a record is touched wastes calls. The fix is a signature: a hash of only the fields that appear in the image. If the signature hasn't changed, the existing image is still correct.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AddOgFieldsToPosts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;7.1&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;change&lt;/span&gt;
    &lt;span class="n"&gt;add_column&lt;/span&gt; &lt;span class="ss"&gt;:posts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:og_image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:string&lt;/span&gt;
    &lt;span class="n"&gt;add_column&lt;/span&gt; &lt;span class="ss"&gt;:posts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:og_signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:string&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/models/post.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:author&lt;/span&gt;

  &lt;span class="n"&gt;after_save_commit&lt;/span&gt; &lt;span class="ss"&gt;:enqueue_og_image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;if: :og_image_outdated?&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;current_og_signature&lt;/span&gt;
    &lt;span class="no"&gt;Digest&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SHA1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_i&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="s2"&gt;"|"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;og_image_outdated?&lt;/span&gt;
    &lt;span class="n"&gt;og_image_url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blank?&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;og_signature&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;current_og_signature&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;enqueue_og_image&lt;/span&gt;
    &lt;span class="no"&gt;GenerateOgImageJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Editing a post's body, which isn't part of the card, leaves the signature untouched, so no new image is generated. Change the title and the next save regenerates exactly once. Because the job uses &lt;code&gt;update_columns&lt;/code&gt;, storing the new URL and signature doesn't trigger &lt;code&gt;after_save_commit&lt;/code&gt; again, so there's no loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring up the meta tags
&lt;/h2&gt;

&lt;p&gt;With the URL on the record, the view layer is the easy part. Yield a head block in the layout, then fill it in from the post page with Rails' tag helpers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;%# app/views/posts/show.html.erb %&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;content_for&lt;/span&gt; &lt;span class="ss"&gt;:head&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;meta&lt;/span&gt; &lt;span class="ss"&gt;property: &lt;/span&gt;&lt;span class="s2"&gt;"og:image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="vi"&gt;@post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;og_image_url&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;meta&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s2"&gt;"twitter:card"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="s2"&gt;"summary_large_image"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;meta&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s2"&gt;"twitter:image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="vi"&gt;@post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;og_image_url&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;twitter:card&lt;/code&gt; to &lt;code&gt;summary_large_image&lt;/code&gt; so the image renders full width rather than as a thumbnail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing without hitting the API
&lt;/h2&gt;

&lt;p&gt;Stub the HTTP call so tests never make a real request, then assert the job stores what the API returned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# test/jobs/generate_og_image_job_test.rb&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"test_helper"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GenerateOgImageJobTest&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveJob&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TestCase&lt;/span&gt;
  &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;"stores the returned image url on the post"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:hello_world&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;stub_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"https://app.html2img.com/api/html"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_return&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;status:  &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;body:    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;url: &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="nf"&gt;to_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;headers: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"application/json"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="no"&gt;GenerateOgImageJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;assert_equal&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="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;og_image_url&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That uses WebMock, which most Rails test setups already pull in. The same pattern works in RSpec.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which to reach for
&lt;/h2&gt;

&lt;p&gt;Use MiniMagick only when the layout is fixed and almost text-free. Reach for Grover when you genuinely need a browser on your own infrastructure for other reasons and the OG image is a side benefit. For everything else, rendering an ERB template through an API keeps your servers free of Chrome, gives you real CSS, and costs a single HTTP call per image. Pair it with the signature cache and a background job and OG images become something you set up once and stop thinking about.&lt;/p&gt;

&lt;p&gt;The full version, with a couple of extra notes, is on &lt;a href="https://html2img.com/articles/dynamic-og-images-in-rails/" rel="noopener noreferrer"&gt;html2img.com&lt;/a&gt;. How are you generating OG images in your Rails apps at the moment? Let me know in the comments.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Replacing five Figma files with one HTML renderer for our content brand</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Mon, 08 Jun 2026 14:18:00 +0000</pubDate>
      <link>https://dev.to/accreditly/replacing-five-figma-files-with-one-html-renderer-for-our-content-brand-47ej</link>
      <guid>https://dev.to/accreditly/replacing-five-figma-files-with-one-html-renderer-for-our-content-brand-47ej</guid>
      <description>&lt;p&gt;The trigger was a brand refresh. The marketing director walked into a Tuesday standup with a new colour palette, a new display font, and a kind smile. The work to roll that out across our content brand was three weeks: I counted the Figma files (blog hero, quote card, podcast cover, episode card, newsletter banner), multiplied by the designer's day rate, and added a fortnight of "could you regenerate the hero image for these 300 archive posts" that I would have to do by hand.&lt;/p&gt;

&lt;p&gt;What we shipped instead took a Saturday afternoon and one paid invoice. Every editorial image format moved into HTML templates. The brand variables landed in a single CSS file. A brand refresh became a five-minute edit, a cache bump, and a deploy. The back catalogue regenerated on next request.&lt;/p&gt;

&lt;p&gt;This article is the pattern, with working code in Laravel, the bits I got wrong, and when this is the wrong approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was in the stack
&lt;/h2&gt;

&lt;p&gt;Five image formats. Each one had a Figma file. Each one was opened by a different person across the year and slowly drifted into a slightly different interpretation of the same brand.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blog hero&lt;/strong&gt; at 1600 by 900. Sits at the top of the article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quote card&lt;/strong&gt; at 1200 by 1200. Pull-quotes for social.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Podcast cover&lt;/strong&gt; at 1500 by 1500. Apple Podcasts and Spotify directories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Episode card&lt;/strong&gt; at 1200 by 630. Per-episode share image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email header&lt;/strong&gt; at 1200 by 300. Newsletter banner.
Three different aspect ratios, four different content data shapes, one brand language meant to feel like one thing across all of them. It did not. By the time the refresh landed, the back catalogue looked like a small museum of how the brand had been interpreted over 18 months.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;One Blade view per format. One CSS partial that defines the brand. One renderer service that handles all five.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Services&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Storage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EditorialImageRenderer&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;FORMATS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'blog-hero'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'view'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'editorial.blog-hero'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'quote-card'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'view'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'editorial.quote-card'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'podcast-cover'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'view'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'editorial.podcast-cover'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'episode-card'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'view'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'editorial.episode-card'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'email-header'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'view'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'editorial.email-header'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FORMATS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$format&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$data&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="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&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="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'view'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withHeaders&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'X-API-Key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.html2img.key'&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://app.html2img.com/api/html'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'html'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'width'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'height'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'h'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'wait_for_selector'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'.ready'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$res&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;ksort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_format'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_v'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'1'&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;"editorial/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$format&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$hash&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.png"&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 &lt;code&gt;_v&lt;/code&gt; field in the cache key is the brand-refresh switch. Bumping it from &lt;code&gt;'1'&lt;/code&gt; to &lt;code&gt;'2'&lt;/code&gt; invalidates every cached image. The next request for each one regenerates against the current Blade view, which in turn pulls from the current brand partial.&lt;/p&gt;

&lt;h2&gt;
  
  
  The brand partial
&lt;/h2&gt;

&lt;p&gt;This is the bit that earns the system its keep. Every per-format Blade view starts with &lt;code&gt;@include('editorial._brand')&lt;/code&gt;. The partial defines the colour palette, fonts, and shared element styles. Nothing else in the system hard-codes a brand value.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{-- resources/views/editorial/_brand.blade.php --}}
&amp;lt;style&amp;gt;
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&amp;amp;family=Fraunces:wght@600;800;900&amp;amp;display=swap');
  * { margin: 0; padding: 0; box-sizing: border-box; }
  :root {
    --brand-bg: #FFFCF5;
    --brand-fg: #1A1A1A;
    --brand-accent: #C2410C;
    --brand-muted: #6B7280;
    --font-display: 'Fraunces', Georgia, serif;
    --font-body: 'Inter', sans-serif;
  }
  body {
    background: var(--brand-bg);
    color: var(--brand-fg);
    font-family: var(--font-body);
  }
  .brand-mark {
    font-family: var(--font-display);
    font-weight: 900;
    letter-spacing: -0.02em;
  }
  .ready { visibility: hidden; height: 0; }
&amp;lt;/style&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the brand refreshed, the changes that landed were: &lt;code&gt;--brand-bg&lt;/code&gt; from cream to slate, &lt;code&gt;--font-display&lt;/code&gt; from Fraunces to Newsreader, &lt;code&gt;--brand-accent&lt;/code&gt; from orange to teal. Three lines. One commit. The PR description was longer than the code change.&lt;/p&gt;

&lt;h2&gt;
  
  
  One format in full: the blog hero
&lt;/h2&gt;

&lt;p&gt;Most-shared. Doubles as the Open Graph image. Worth showing in full.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{-- resources/views/editorial/blog-hero.blade.php --}}
&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;
@include('editorial._brand')
&amp;lt;style&amp;gt;
  body { width: 1600px; height: 900px; padding: 100px; display: grid; grid-template-rows: auto 1fr auto; }
  .category {
    font-size: 18px; font-weight: 700; letter-spacing: 4px;
    text-transform: uppercase; color: var(--brand-accent);
  }
  h1 {
    font-family: var(--font-display);
    font-size: 110px; font-weight: 900;
    line-height: 1.02; letter-spacing: -2px;
    align-self: end; max-width: 1300px;
  }
  footer {
    font-size: 24px; color: var(--brand-muted);
    display: flex; justify-content: space-between; align-items: end;
  }
  footer .author { font-weight: 700; color: var(--brand-fg); }
  .divider { width: 80px; height: 4px; background: var(--brand-fg); margin: 16px 0 32px; }
&amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;div class="category"&amp;gt;{{ $category }}&amp;lt;/div&amp;gt;
    &amp;lt;div class="divider"&amp;gt;&amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;h1&amp;gt;{{ $title }}&amp;lt;/h1&amp;gt;
  &amp;lt;footer&amp;gt;
    &amp;lt;span&amp;gt;&amp;lt;span class="author"&amp;gt;{{ $author }}&amp;lt;/span&amp;gt; · {{ $published_at }}&amp;lt;/span&amp;gt;
    &amp;lt;span class="brand-mark"&amp;gt;{{ config('app.brand') }}&amp;lt;/span&amp;gt;
  &amp;lt;/footer&amp;gt;
  &amp;lt;div class="ready"&amp;gt;&amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The display font carries most of the visual weight. A 110px title in Fraunces feels editorial in a way 110px in Inter does not. The grid layout anchors the category at the top, the title vertically centred, and the byline at the bottom: three rows that adapt to title length without manual tuning.&lt;/p&gt;

&lt;p&gt;Triggering the render from the article model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;heroImageUrl&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&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;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EditorialImageRenderer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'blog-hero'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'category'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'author'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'published_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'jS F Y'&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 article template embeds the result as a plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag. Same URL goes into the OG meta tag in the page head.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other four formats
&lt;/h2&gt;

&lt;p&gt;Each one follows the same shape. A Blade view sized to the viewport, including the brand partial, ending with a &lt;code&gt;.ready&lt;/code&gt; element so the renderer knows when to capture.&lt;/p&gt;

&lt;p&gt;The data shapes for the ones I haven't shown in full:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quote card&lt;/strong&gt;: &lt;code&gt;quote&lt;/code&gt;, &lt;code&gt;attribution&lt;/code&gt;, optional &lt;code&gt;avatar_url&lt;/code&gt;. Big serif italic, an oversized quotation-mark glyph as an accent, the attribution underneath.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Podcast cover&lt;/strong&gt;: &lt;code&gt;show_name&lt;/code&gt;, &lt;code&gt;host&lt;/code&gt;, &lt;code&gt;tagline&lt;/code&gt;. Square. Generated rarely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Episode card&lt;/strong&gt;: &lt;code&gt;episode_number&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;guest&lt;/code&gt;, optional &lt;code&gt;topic&lt;/code&gt;. Generated automatically when each episode publishes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email header&lt;/strong&gt;: &lt;code&gt;issue_number&lt;/code&gt;, &lt;code&gt;volume&lt;/code&gt;, &lt;code&gt;date&lt;/code&gt;. Wide banner across the top of the newsletter template.
If you would rather not build the Blade views, html2img has pre-built &lt;a href="https://html2img.com/templates/blog-hero" rel="noopener noreferrer"&gt;blog hero&lt;/a&gt;, &lt;a href="https://html2img.com/templates/quote-card" rel="noopener noreferrer"&gt;quote card&lt;/a&gt;, &lt;a href="https://html2img.com/templates/podcast-cover" rel="noopener noreferrer"&gt;podcast cover&lt;/a&gt;, &lt;a href="https://html2img.com/templates/podcast-episode-card" rel="noopener noreferrer"&gt;podcast episode card&lt;/a&gt; and &lt;a href="https://html2img.com/templates/email-header" rel="noopener noreferrer"&gt;email header&lt;/a&gt; templates that accept the same data shape and return a sized PNG. The trade-off is the layout is opinionated. We use the HTML endpoint for the blog hero and quote card (most brand-defining) and the pre-built templates for the rest.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;The list that bit me first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quote lengths are unpredictable.&lt;/strong&gt; A 9-word pull-quote at 96px looks balanced. A 32-word quote at the same size overflows the card. Either drop the font size dynamically based on character count, or hard-cap the input length in the editor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Brand partial cascade.&lt;/strong&gt; I learned the hard way that putting the brand partial after the per-format &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block lets the format override the brand. That's the opposite of what you want. The partial goes first, the format styles go after, format-specific rules override brand defaults where needed but the brand variables stay as the source of truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webfonts and &lt;code&gt;wait_for_selector&lt;/code&gt;.&lt;/strong&gt; The convention of ending every template with a &lt;code&gt;&amp;lt;div class="ready"&amp;gt;&lt;/code&gt; means the screenshot fires after the meaningful DOM is in place. Without it, the screenshot occasionally captures the page before the webfont has loaded and you ship a fallback render. Solved problem, but only if you remember the convention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External images.&lt;/strong&gt; Avatars hosted on third-party CDNs, hero photographs pulled from Cloudinary, brand marks loaded from another domain. Any of them can delay the render or fail outright. Inline your brand assets as SVG or base64. Pre-upload editorial photos to your own fast CDN, or accept that the render will occasionally miss them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache key construction.&lt;/strong&gt; &lt;code&gt;ksort&lt;/code&gt; the data array before hashing. Without stable ordering, the same logical data hashed twice gives two different keys, which doubles your API calls. Took me a confused afternoon to spot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't store the upstream URL.&lt;/strong&gt; The API returns a CDN URL. Tempting to use it directly. We did, then ran into an outage where every link 404'd. Fetch the bytes once at render time, persist to your own storage, serve from your own CDN.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is the wrong approach
&lt;/h2&gt;

&lt;p&gt;Two cases.&lt;/p&gt;

&lt;p&gt;If your content output is one piece a fortnight and you have a designer who enjoys the work, the engineering cost is not worth it. The Figma stack has a real cost only at higher cadences.&lt;/p&gt;

&lt;p&gt;If your editorial team wants pixel-level art direction on every image (full-bleed photography, custom illustrations per piece), HTML templates can't replace that. They can complement: hero images that need photography handled in Figma, everything else templated. Most content brands sit at the latter mix.&lt;/p&gt;

&lt;p&gt;For everything else, the HTML renderer earned its keep in the brand refresh that triggered it. The thing it actually unlocks is content-team velocity: they can ship a quote card without waiting for the designer, because the template enforces the brand for them. That benefit shows up every week, not just at brand refresh time.&lt;/p&gt;

&lt;p&gt;The first version took an afternoon. The maintenance cost is roughly zero, because there is no Chromium and no Figma file to keep in sync. If you're staring at a Figma folder with five subfolders right now, this is the half-day refactor worth doing.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>php</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why we replaced PDF invoice attachments with inline PNG receipts</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Mon, 01 Jun 2026 14:04:00 +0000</pubDate>
      <link>https://dev.to/accreditly/why-we-replaced-pdf-invoice-attachments-with-inline-png-receipts-1fga</link>
      <guid>https://dev.to/accreditly/why-we-replaced-pdf-invoice-attachments-with-inline-png-receipts-1fga</guid>
      <description>&lt;p&gt;We used to attach a PDF to every order confirmation email. The PDF was the invoice, generated server-side from a Blade template via a PDF library, and it worked. It also tanked our email open rates on mobile, where the attachment showed up as a generic clip icon nobody tapped, and it pushed up our spam score because PDF attachments at scale carry weight.&lt;/p&gt;

&lt;p&gt;A year ago we replaced the PDF with an inline PNG image embedded in the email body. Same content. No attachment. The image renders the moment the email opens. Customers see the invoice without tapping anything. Open rates went up. The spam score dropped enough to feel it.&lt;/p&gt;

&lt;p&gt;This article is about the pattern that replaced the PDF, and how the same plumbing handles receipts, vouchers, and product cards. Working code in Laravel, the bits we got wrong, and when this is the wrong approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the PDF attachment is not great
&lt;/h2&gt;

&lt;p&gt;A PDF as an order confirmation feels professional. There are three real problems.&lt;/p&gt;

&lt;p&gt;PDFs are heavy. A styled invoice from a library like &lt;code&gt;dompdf&lt;/code&gt; weighs roughly 150 KB. The equivalent PNG is 40 KB. When you send a million transactional emails a year, the difference adds up. More importantly, your email service provider grades you on average message size, and that grade feeds back into deliverability.&lt;/p&gt;

&lt;p&gt;Mobile email clients hide attachments. Gmail on Android shows a generic "1 attachment" pill that needs a tap. iOS Mail shows the attachment as an icon at the bottom of the message. Neither client renders the PDF inline by default. The customer has to want to see the invoice. Most do not bother.&lt;/p&gt;

&lt;p&gt;Spam scoring penalises attachments at scale. Marketing emails with PDF attachments are flagged disproportionately, because most legitimate transactional senders moved off PDF attachments years ago. Hitting Inbox or Promotions is partly determined by the absence of attachments. Replacing the PDF with an inline image moves you back into Inbox.&lt;/p&gt;

&lt;p&gt;The fix that I tried first: generate the PDF, render its first page to a PNG with &lt;code&gt;imagick&lt;/code&gt;, embed the PNG. This works. It also means you're now running both a PDF generator and a rasteriser in your image pipeline, with one feeding the other. The plumbing was twice as much code for half the design control.&lt;/p&gt;

&lt;p&gt;The fix that stuck: skip the PDF, render the invoice template directly to a PNG.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;One Blade template per document type. One renderer service that handles all of them. One cache layer on S3 that serves repeat requests without a re-render.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Services&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Storage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TransactionalImageRenderer&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;VIEWPORTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'invoice'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1240&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1754&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'view'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'images.invoice'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'receipt'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'view'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'images.receipt'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'voucher'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'view'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'images.voucher'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'product-card'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'view'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'images.product'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;VIEWPORTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$data&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="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&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="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'view'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withHeaders&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'X-API-Key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.html2img.key'&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://app.html2img.com/api/html'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'html'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'width'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'height'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'h'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'wait_for_selector'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'.ready'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$res&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;ksort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_v'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'1'&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;"tx-images/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$hash&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.png"&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;A few things worth highlighting. The cache hits S3 first. If the image exists for this exact payload, it returns the URL immediately. No API call, no credit spent. The vast majority of calls become hits once the system has warmed up, because the same invoice gets re-rendered every time the customer or our support team opens the original email.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;wait_for_selector: '.ready'&lt;/code&gt; is a convention I picked up after a few rendered images shipped with the font in the fallback state. Add a &lt;code&gt;&amp;lt;div class="ready"&amp;gt;&lt;/code&gt; to the bottom of every template, after the last meaningful element. The screenshot fires once that div is in the DOM, which guarantees everything above it has rendered. Cleaner than picking a content-specific selector per template.&lt;/p&gt;

&lt;p&gt;The Mailable side is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderInvoiceMail&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Mailable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;Order&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$invoiceUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TransactionalImageRenderer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'invoice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'reference'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'issued_on'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'jS F Y'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'lines'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'total'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'emails.order-invoice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'invoice_url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$invoiceUrl&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Your invoice from Coastline Coffee Co"&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;In the email view, the invoice is a plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag pointing at the URL on our CDN. No attachment, no &lt;code&gt;withAttachment&lt;/code&gt;, no PDF library.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the numbers
&lt;/h2&gt;

&lt;p&gt;Mobile open-to-action rate (customers who tapped to view the invoice on mobile) went from 14% to 89%. The latter is essentially everyone who opened the email, because they didn't need to do anything to see the invoice.&lt;/p&gt;

&lt;p&gt;Email size dropped from an average 198 KB to 67 KB. Send time per batch dropped proportionally.&lt;/p&gt;

&lt;p&gt;Spam score (we use Postmark, which scores 0 to 10) dropped from a stable 1.2 to 0.4. Deliverability into the Inbox tab on Gmail, measured via the seed-list panel, went from 81% to 94%.&lt;/p&gt;

&lt;p&gt;These numbers are specific to our setup and our send volume (high transactional, very low marketing). Your mileage will differ. The direction has been consistent across the three companies I have seen do this migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four document types
&lt;/h2&gt;

&lt;p&gt;The same renderer covers four template shapes. Each has a viewport, a Blade view, and a cache key. The data shape is different per type but the plumbing is identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invoice&lt;/strong&gt; at 1240 by 1754. A4 portrait. Full line items, totals, party details. Heaviest template. If you'd rather not maintain the HTML, there's a pre-built &lt;a href="https://html2img.com/templates/invoice-image" rel="noopener noreferrer"&gt;invoice template&lt;/a&gt; at the same dimensions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Receipt&lt;/strong&gt; at 600 by 900. A compact summary, just enough for an order confirmation. Pre-built version: &lt;a href="https://html2img.com/templates/receipt-image" rel="noopener noreferrer"&gt;receipt template&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Voucher&lt;/strong&gt; at 1200 by 600. The code, expiry, terms, brand colour. Goes into marketing emails. Pre-built: &lt;a href="https://html2img.com/templates/coupon-voucher" rel="noopener noreferrer"&gt;coupon voucher&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Product card&lt;/strong&gt; at 1200 by 630. Product photo, name, price, optional sale price. Used in abandoned-cart emails. Pre-built: &lt;a href="https://html2img.com/templates/product-card" rel="noopener noreferrer"&gt;product card&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you go the pre-built route, the renderer becomes even shorter because you skip the Blade view and the wait selector. You POST JSON to the template endpoint, the response is your PNG URL. The trade-off is the layout is opinionated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;The list of things I wish I'd known.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mobile email clients clip wide images.&lt;/strong&gt; Most clients render the image at the email's content width (usually 600px on mobile). A 1200-wide voucher scales down cleanly. A 1240-wide invoice scales down too small to read. Use the preview-plus-link pattern for anything wider than 1000px: a smaller preview image inline, a "view full invoice" link to the full-size image on your CDN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tabular numerals.&lt;/strong&gt; Without &lt;code&gt;font-variant-numeric: tabular-nums&lt;/code&gt; on your money columns, numbers shift left and right row to row. Looks wrong in a way you can't quite name until you fix it. One CSS line, immediate improvement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Currency formatting.&lt;/strong&gt; &lt;code&gt;number_format($amount, 2)&lt;/code&gt; is fine for GBP and USD. If you support more than one currency, use &lt;code&gt;NumberFormatter&lt;/code&gt; from the Intl extension. The bug is when you display "£1,234.56" for everyone including the customer paying in EUR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compliance.&lt;/strong&gt; If your invoices need a stable PDF for accounting or audit reasons, the PNG-in-email pattern complements the PDF, it doesn't fully replace it. Generate both: the PNG goes in the email body, the PDF lives in the customer's account or in your finance system. Most tax authorities still expect the PDF; tax authorities are not who reads the email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The S3 cache is the system of record.&lt;/strong&gt; The upstream API gives you a URL on its CDN. Tempting to just use that URL in the email. We did, then ran into a 90-minute outage where every old invoice link suddenly 404'd. The fix is to fetch the bytes once at render time and persist to your own storage. Treat the upstream URL as a one-shot handle, not a permanent address.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is the wrong approach
&lt;/h2&gt;

&lt;p&gt;Two cases where the PDF attachment is still right.&lt;/p&gt;

&lt;p&gt;Markets where customers print every invoice. PDFs at print-quality DPI are a better fit than PNGs, which are fixed-resolution. For B2B sales in regulated industries, the PDF attachment is still the norm and customers expect it.&lt;/p&gt;

&lt;p&gt;Strict data residency. If your invoice contains PII that can't leave your infrastructure, an API renderer is a third-party processor. The render request and the resulting bytes flow through a third party. The API doesn't persist your HTML, but if your compliance team has a "no third-party processors for PII" rule, you generate the PDF locally and live with the attachment.&lt;/p&gt;

&lt;p&gt;Outside of those two, the pattern has been the lowest-friction win for transactional email work I've made in years. The first version took an afternoon. The maintenance cost is roughly zero, because there is no Chromium and no PDF library to keep happy. Replacing a working PDF pipeline with this is a half-day refactor for the average application.&lt;/p&gt;

&lt;p&gt;Worth the half-day.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>php</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>One API, every social image - dynamic OG, Twitter, LinkedIn, Pinterest, YouTube</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Tue, 26 May 2026 13:23:00 +0000</pubDate>
      <link>https://dev.to/accreditly/one-api-every-social-image-dynamic-og-twitter-linkedin-pinterest-youtube-2361</link>
      <guid>https://dev.to/accreditly/one-api-every-social-image-dynamic-og-twitter-linkedin-pinterest-youtube-2361</guid>
      <description>&lt;p&gt;We were shipping one OG image per post. It was 1200 by 630. It was rendered server-side from a Blade template via Puppeteer. We were quite pleased with it.&lt;/p&gt;

&lt;p&gt;Then someone in growth pointed out that the same image looked terrible on LinkedIn (cropped weirdly), unremarkable on Twitter (where 1200 by 675 actually shows in full), and tiny on Pinterest (where the feed is a vertical aspect ratio). The fix on paper was simple: make per-platform variants. The fix in practice was a fortnight of getting Puppeteer to behave at eight different viewport sizes.&lt;/p&gt;

&lt;p&gt;This article is about the version that came after that. One HTML template, eight platform variants, the API runs the browser. No Chromium to maintain. Worth posting because the fan-out pattern is reusable and quite a lot smaller than what I was carrying before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one shared image is not enough
&lt;/h2&gt;

&lt;p&gt;The reflex is to ship a single 1200 by 630 and let each platform crop. That gets you 70% of the way there and looks fine. The reason teams that care about share traffic go further is that each platform has its own pathology:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OG / Facebook&lt;/strong&gt;: 1200 by 630. Title in the centre, brand in a corner. Standard landscape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twitter / X&lt;/strong&gt;: 1200 by 675 for &lt;code&gt;summary_large_image&lt;/code&gt;. Slightly taller than OG. A 1200 by 630 image works but leaves a thin grey bar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn&lt;/strong&gt;: 1200 by 627. Functionally identical to OG, but the comment overlay covers the bottom 80px on mobile. Anything at the bottom is invisible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pinterest&lt;/strong&gt;: 1000 by 1500. Vertical. Completely different design problem. You have room for the title, key points, and a call to action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instagram square&lt;/strong&gt;: 1080 by 1080. Title can be larger because the safe area is huge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instagram story&lt;/strong&gt;: 1080 by 1920. Vertical. The top 200px is hidden by the username overlay, the bottom 250px by the reply bar. Real estate is the middle slab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YouTube thumbnail&lt;/strong&gt;: 1280 by 720. Bold, high contrast, big title. Different conventions to a blog OG.
A blog post that gets shared into all of those wants eight images, sized correctly, generated from the same content. Building eight Puppeteer templates is a lot of work. Building one HTML template and rendering it at eight viewports is not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fan-out pattern
&lt;/h2&gt;

&lt;p&gt;The principle: one HTML template, made flexible enough to render at three orientation modes (landscape, square, portrait), called by an API that handles the viewport per platform.&lt;/p&gt;

&lt;p&gt;Here is the shape of a job that does this for a blog post. Laravel version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;PLATFORMS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'og'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'twitter'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;675&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'linkedin'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;627&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'facebook'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'pinterest'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'square'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'story'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'youtube'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;720&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PLATFORMS&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'social.post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'orientation'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orientationFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withHeaders&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'X-API-Key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.html2img.key'&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://app.html2img.com/api/html'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'html'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'width'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'height'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'h'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'wait_for_selector'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'.title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;socialImages&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;updateOrCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'platform'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node version, same shape:&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;PLATFORMS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;og&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w&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;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w&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;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;675&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;linkedin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w&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;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;627&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;facebook&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w&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;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;pinterest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w&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;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;square&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;story&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;youtube&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;720&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&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;results&lt;/span&gt; &lt;span class="o"&gt;=&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PLATFORMS&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;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;renderTemplate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;orientationFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&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;res&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="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_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="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&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="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;wait_for_selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.title&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;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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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 for &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;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&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="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;res&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;results&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="o"&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;return&lt;/span&gt; &lt;span class="nx"&gt;results&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;Eight platforms, sequentially, in roughly 15 seconds total. If you want it faster, fire the requests in parallel with a concurrency limit of 3 to 5 (any higher and you start rate-limiting).&lt;/p&gt;

&lt;h2&gt;
  
  
  The template that handles three orientations
&lt;/h2&gt;

&lt;p&gt;This is the bit that took the longest to get right. The same HTML has to read cleanly at 1200 by 630, 1080 by 1080, and 1080 by 1920. The trick is &lt;code&gt;100vw&lt;/code&gt;/&lt;code&gt;100vh&lt;/code&gt; on the body, plus orientation-aware font sizing.&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;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"utf-8"&lt;/span&gt;&lt;span class="nt"&gt;&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@400;700;900&amp;amp;display=swap')&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="o"&gt;*&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;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="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="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;100vw&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;100vh&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="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;135deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#0B1220&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#1E293B&lt;/span&gt; &lt;span class="m"&gt;100%&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-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&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="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;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;grid-template-rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;.kicker&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;20px&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;#F59E0B&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;700&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="nl"&gt;letter-spacing&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;.title&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;60px&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;900&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;line-height&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="nl"&gt;align-self&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;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;920px&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="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-orientation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"square"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;.title&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;64px&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="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-orientation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"portrait"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;.title&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;72px&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="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-orientation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"portrait"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;.kicker&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;24px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;.footer&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;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;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;justify-content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;space-between&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;.footer&lt;/span&gt; &lt;span class="nt"&gt;strong&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-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;700&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&lt;/span&gt; &lt;span class="na"&gt;data-orientation=&lt;/span&gt;&lt;span class="s"&gt;"{{orientation}}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"kicker"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{category}}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{title}}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"footer"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&amp;lt;strong&amp;gt;&lt;/span&gt;{{author}}&lt;span class="nt"&gt;&amp;lt;/strong&amp;gt;&lt;/span&gt; · {{published_at}}&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&lt;/span&gt;yourdomain.example&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&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;The &lt;code&gt;data-orientation&lt;/code&gt; attribute is the only thing that changes between calls. Three CSS selectors handle the three modes. The grid layout keeps the kicker at the top, the title vertically centred, and the footer at the bottom, no matter the viewport.&lt;/p&gt;

&lt;p&gt;There is also a per-platform template approach where you don't write the HTML at all. The &lt;a href="https://html2img.com/templates" rel="noopener noreferrer"&gt;html2img per-platform templates&lt;/a&gt; cover every social size individually (&lt;a href="https://html2img.com/templates/open-graph-image" rel="noopener noreferrer"&gt;OG&lt;/a&gt;, &lt;a href="https://html2img.com/templates/twitter-post" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;, &lt;a href="https://html2img.com/templates/linkedin-post" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, &lt;a href="https://html2img.com/templates/pinterest-pin" rel="noopener noreferrer"&gt;Pinterest&lt;/a&gt;, &lt;a href="https://html2img.com/templates/instagram-square-post" rel="noopener noreferrer"&gt;Instagram square&lt;/a&gt;, &lt;a href="https://html2img.com/templates/instagram-story" rel="noopener noreferrer"&gt;Instagram story&lt;/a&gt;, &lt;a href="https://html2img.com/templates/youtube-thumbnail" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt;, &lt;a href="https://html2img.com/templates/facebook-post" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt;). Same JSON payload shape, different endpoint per platform. Useful if your branding is settled and you don't want to maintain layout code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;The list that made me wish I'd known earlier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;wait_for_selector&lt;/code&gt; matters more than you'd think.&lt;/strong&gt; Without it, the screenshot occasionally fires before the webfont has loaded and you get a fallback render. The selector should match an element that only renders once the font is in. Setting it to &lt;code&gt;.title&lt;/code&gt; works because the title element gets its visible width from the loaded font.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inline your brand assets.&lt;/strong&gt; External image references slow the render and occasionally fail. Logos as inline SVG, avatars as base64 data URIs, hero images either inlined or pre-uploaded to your own fast CDN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache by content hash.&lt;/strong&gt; The naive cache key is the post ID, but if the title changes the existing image is stale. Hash the inputs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'author'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'platform'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'v'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&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;Bumping the &lt;code&gt;v&lt;/code&gt; field is your manual cache bust when the template design changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persist the bytes, not the upstream URL.&lt;/strong&gt; The API returns a hosted CDN URL. Convenient. But for compliance and the case where the upstream has an outage, fetch the bytes once and serve from your own storage. Treat the upstream URL as a one-shot handle, not the system of record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LinkedIn overlays the bottom 80px on mobile.&lt;/strong&gt; Don't put critical content there. The &lt;code&gt;.footer&lt;/code&gt; in the template above is fine because it's brand metadata, not the message itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't try to use the same image for the Instagram story.&lt;/strong&gt; A landscape card stretched portrait reads badly. The orientation switch in the template gives you a portrait layout with title at the top and brand at the bottom, which is the only way a 1080 by 1920 looks intentional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the loop in your meta tags
&lt;/h2&gt;

&lt;p&gt;The render side is half the work. The other half is your HTML head, which has to point each platform at the right variant.&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;head&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://yourcdn.example/social/{{slug}}/og.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;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:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://yourcdn.example/social/{{slug}}/twitter.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- LinkedIn reads og:image --&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Pinterest reads og:image but you can also expose the pin variant
       as a direct URL for the Pin button --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"image/png"&lt;/span&gt;
        &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://yourcdn.example/social/{{slug}}/pinterest.png"&lt;/span&gt;
        &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"Pin this"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instagram, YouTube and the in-feed Twitter/X variants are not driven by meta tags. You upload them directly to those platforms (or hand them to the marketing team to upload).&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest verdict
&lt;/h2&gt;

&lt;p&gt;If your team publishes daily and shares to more than one platform, the fan-out approach earns its keep within a couple of weeks. Building it took roughly half a day from "I should do this" to "all eight variants ship per post". Maintaining it has been free. Owning Chromium for the same job was a fortnightly small-fire situation.&lt;/p&gt;

&lt;p&gt;If you're publishing once a week, this is over-engineering. Stay with one 1200 by 630 and a Figma file.&lt;/p&gt;

&lt;p&gt;The same pattern works for product cards on an e-commerce site, share images for user-generated content (think Strava-style activity cards), and the per-episode covers for a podcast network. The viewport list changes, the rest of the code stays the same.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tutorial</category>
      <category>automation</category>
    </item>
    <item>
      <title>Chart.js Server-Side Rendering as Images (Python Guide)</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Thu, 07 May 2026 10:47:00 +0000</pubDate>
      <link>https://dev.to/accreditly/chartjs-server-side-rendering-as-images-python-guide-5edn</link>
      <guid>https://dev.to/accreditly/chartjs-server-side-rendering-as-images-python-guide-5edn</guid>
      <description>&lt;p&gt;I needed weekly performance reports emailed to clients. Each report had four charts: revenue trend, conversion rate, top products, traffic sources. The app was Python. The charts on the live dashboard were Chart.js because that's what the frontend team had standardised on, and they looked good.&lt;/p&gt;

&lt;p&gt;First attempt was matplotlib. It produced charts. They looked like matplotlib charts, which is to say fine for a scientific paper and terrible for a client email that's supposed to match the product's visual language. The brand colours were approximate. The fonts were wrong. The legend looked like it was from 2008. Clients politely asked why the emailed reports didn't match the dashboard.&lt;/p&gt;

&lt;p&gt;Second attempt was&amp;nbsp;&lt;code&gt;chartjs-node-canvas&lt;/code&gt;. This works by running Chart.js against a Node canvas polyfill. Close to the real thing, but not identical. Gradients rendered differently. Some plugins didn't work at all. And now I was running a Node service from Python for one specific job, which felt wrong.&lt;/p&gt;

&lt;p&gt;What actually solved it was Chart.js server-side rendering, where "server-side" means a real browser on someone else's server. I generate the HTML for the chart, including the Chart.js script tag and the config, post it to a rendering API, and get back a PNG that matches what the dashboard shows exactly, because it's the same Chart.js code running in the same browser engine. This article is how I set that up from Python, and the small details that make the difference between a chart that looks right and one that doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious Python chart options fall short
&lt;/h2&gt;

&lt;p&gt;Matplotlib and Plotly both generate images natively from Python. They're fine tools. The problem is that if your product uses Chart.js (or D3, or Recharts, or ApexCharts) on the frontend, and you want emailed or PDF-embedded charts to match, you're re-implementing the same visual twice in two different libraries. Colours drift. Fonts drift. Tooltip styling is irrelevant for a static image but the overall look and feel stops matching.&lt;/p&gt;

&lt;p&gt;Plotly can export as a PNG via its&amp;nbsp;&lt;code&gt;kaleido&lt;/code&gt;&amp;nbsp;backend, which is better, but you're still maintaining two chart implementations. Any change to the dashboard's chart styling means a matching change to the Python-side Plotly code, and keeping those in sync is a perennial source of bugs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;chartjs-node-canvas&lt;/code&gt;&amp;nbsp;gets you one library, but at the cost of running Node from Python and accepting that canvas-polyfill rendering isn't pixel-perfect against real browser canvas. Gradients, shadows, and certain plugin behaviours differ. For anything that'll sit next to the "real" chart in a client's inbox, close-but-not-quite is worse than obviously-different.&lt;/p&gt;

&lt;p&gt;The approach that actually gives you matching output is to render the chart in a real Chromium instance. Chart.js draws to a canvas, you screenshot the canvas, done. Running that browser yourself brings all the Puppeteer-on-a-server problems: binary size, version drift, memory. Pointing at an API that does it for you removes the problem entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Here's a working Python function that takes a chart config and returns PNG bytes. The chart config is a plain dict that matches Chart.js's config shape, which means you can either write it by hand in Python or fetch it from wherever your frontend gets its config from and reuse it directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import os
import json
import requests

HTML2IMG_URL = "https://api.html2img.com/v1/render"
API_KEY = os.environ["HTML2IMG_API_KEY"]

def render_chart(config: dict, width: int = 800, height: int = 450) -&amp;gt; bytes:
    """Render a Chart.js config as PNG bytes."""
    html = f"""
    &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;
        html, body {{ margin: 0; padding: 0; background: white; }}
        body {{
          width: {width}px;
          height: {height}px;
          font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
          padding: 24px;
        }}
        #chart {{ width: 100%; height: 100%; }}
      &amp;lt;/style&amp;gt;
      &amp;lt;script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"&amp;gt;&amp;lt;/script&amp;gt;
    &amp;lt;/head&amp;gt;
    &amp;lt;body&amp;gt;
      &amp;lt;canvas id="chart"&amp;gt;&amp;lt;/canvas&amp;gt;
      &amp;lt;script&amp;gt;
        const ctx = document.getElementById('chart').getContext('2d');
        const config = {json.dumps(config)};
        config.options = config.options || {{}};
        config.options.animation = false;
        config.options.responsive = false;
        config.options.maintainAspectRatio = false;
        const chart = new Chart(ctx, config);
        window.__chartReady = true;
      &amp;lt;/script&amp;gt;
    &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
    """

    response = requests.post(
        HTML2IMG_URL,
        headers={
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
        },
        json={
            "html": html,
            "viewport_width": width + 48,
            "viewport_height": height + 48,
            "device_scale_factor": 2,
            "wait_for_selector": "#chart",
            "ms_delay": 400,
        },
        timeout=30,
    )
    response.raise_for_status()
    return response.content
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things in that HTML are worth calling out because they're the difference between a chart that renders and one that doesn't.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;animation = false&lt;/code&gt;&amp;nbsp;disables Chart.js's entrance animation. The screenshot fires as soon as the chart is ready, and you don't want to capture it mid-fade-in.&amp;nbsp;&lt;code&gt;responsive = false&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;maintainAspectRatio = false&lt;/code&gt;&amp;nbsp;force the chart to use the exact canvas size you set, rather than trying to resize relative to its container.&amp;nbsp;&lt;code&gt;ms_delay: 400&lt;/code&gt;&amp;nbsp;gives Chart.js a moment to finish drawing after the canvas appears in the DOM. You could be more precise by having the page set a sentinel element when the chart is drawn and waiting for that selector, but a small delay is usually sufficient.&lt;/p&gt;

&lt;p&gt;Now using it for a revenue trend chart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;revenue_config = {
    "type": "line",
    "data": {
        "labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"],
        "datasets": [{
            "label": "Revenue",
            "data": [42000, 48500, 51200, 55800, 61300, 68900, 72400, 79200],
            "borderColor": "#6366f1",
            "backgroundColor": "rgba(99, 102, 241, 0.12)",
            "fill": True,
            "tension": 0.35,
            "pointBackgroundColor": "#6366f1",
            "pointRadius": 4,
            "borderWidth": 3,
        }],
    },
    "options": {
        "plugins": {
            "legend": {"display": False},
            "title": {
                "display": True,
                "text": "Monthly Revenue",
                "font": {"size": 18, "weight": "700"},
                "color": "#0f172a",
                "align": "start",
                "padding": {"bottom": 20},
            },
        },
        "scales": {
            "y": {
                "beginAtZero": False,
                "grid": {"color": "#f1f5f9"},
                "ticks": {
                    "color": "#64748b",
                    "callback": "function(v) { return '£' + (v/1000) + 'k'; }",
                },
            },
            "x": {
                "grid": {"display": False},
                "ticks": {"color": "#64748b"},
            },
        },
    },
}

png_bytes = render_chart(revenue_config, width=800, height=450)

with open("revenue.png", "wb") as f:
    f.write(png_bytes)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One wrinkle: the&amp;nbsp;&lt;code&gt;callback&lt;/code&gt;&amp;nbsp;function for the y-axis ticks is expressed as a string. JSON can't hold JavaScript functions, so if you need them, you serialise the config, then have a post-processing step that replaces string function placeholders with real function literals. Here's a tiny helper that does that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import re

def build_html_config(config: dict) -&amp;gt; str:
    """Serialise a config dict, unwrapping string-encoded JS functions."""
    json_str = json.dumps(config)
    # Match "function(...) { ... }" inside JSON strings and unquote them
    return re.sub(
        r'"(function\s*\([^)]*\)\s*\{[^}]*\})"',
        lambda m: m.group(1).replace('\\"', '"'),
        json_str,
    )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in the HTML template, replace&amp;nbsp;&lt;code&gt;JSON.stringify(config)&lt;/code&gt;&amp;nbsp;with the output of&amp;nbsp;&lt;code&gt;build_html_config(config)&lt;/code&gt;. For simple charts that don't need formatter callbacks, you can skip this and pass config straight through.&lt;/p&gt;

&lt;p&gt;Below is what we're rendering:&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.amazonaws.com%2Fuploads%2Farticles%2Fcaynijujt5jvq7z768cp.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.amazonaws.com%2Fuploads%2Farticles%2Fcaynijujt5jvq7z768cp.png" width="800" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas worth knowing
&lt;/h2&gt;

&lt;p&gt;Chart.js versions matter. The config shape changed significantly between v2 and v3, and again in a smaller way for v4. Pin the CDN URL to a specific version so your server-side renders don't suddenly break when the chart library publishes a release. The example above pins to 4.4.0.&lt;/p&gt;

&lt;p&gt;Canvas text rendering is affected by fonts being available at the time of draw. If your chart uses a custom font via&amp;nbsp;&lt;code&gt;font.family&lt;/code&gt;, that font needs to either be a system font, be loaded before Chart.js runs, or you accept a fallback. The cleanest pattern is to include&amp;nbsp;&lt;code&gt;@font-face&lt;/code&gt;&amp;nbsp;in the&amp;nbsp;&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;&amp;nbsp;block and reference the same font both in CSS and in the Chart.js config. Then bump&amp;nbsp;&lt;code&gt;ms_delay&lt;/code&gt;&amp;nbsp;to 500-700ms to be safe.&lt;/p&gt;

&lt;p&gt;Plugins that need DOM interaction, like the&amp;nbsp;&lt;code&gt;chartjs-plugin-datalabels&lt;/code&gt;&amp;nbsp;click handlers or zoom plugins, are irrelevant for a static image and should be omitted from the server-side config even if your frontend uses them. Anything that only affects hover or click state is wasted computation.&lt;/p&gt;

&lt;p&gt;For reports with multiple charts, render them in parallel. The API calls are independent, so&amp;nbsp;&lt;code&gt;concurrent.futures.ThreadPoolExecutor&lt;/code&gt;&amp;nbsp;with a handful of workers cuts report generation time substantially:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from concurrent.futures import ThreadPoolExecutor

configs = [revenue_config, conversion_config, products_config, traffic_config]

with ThreadPoolExecutor(max_workers=4) as pool:
    images = list(pool.map(render_chart, configs))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For truly bulk runs (hundreds of charts per report, or reports generated on a schedule for thousands of accounts), switch to the webhook pattern. You post each render with a&amp;nbsp;&lt;code&gt;webhook_url&lt;/code&gt;, the API calls your endpoint with the finished PNG, and you don't hold threads open waiting. For a weekly-report system, that's usually the right shape.&lt;/p&gt;

&lt;p&gt;One last thing: don't inline base64 PNGs into emails if you can avoid it. Host the PNG somewhere (S3, a signed CDN URL, your own storage) and reference it in the email HTML. Email clients handle external images more reliably than inline attachments, and the email itself stays small.&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;&lt;a href="https://html2img.com/docs/usage/python" rel="noopener noreferrer"&gt;Python usage guide&lt;/a&gt;&amp;nbsp;covers the requests setup and async alternatives. For more chart examples beyond Chart.js, including rendering D3 and custom canvas visualisations, the&amp;nbsp;&lt;a href="https://html2img.com/docs/examples/chart-screenshot" rel="noopener noreferrer"&gt;chart screenshot example&lt;/a&gt;&amp;nbsp;has a few variants. And if you're building this into a larger reporting pipeline, the&amp;nbsp;&lt;a href="https://html2img.com/docs/parameters/webhook-url" rel="noopener noreferrer"&gt;webhook documentation&lt;/a&gt;&amp;nbsp;walks through the async flow.&lt;/p&gt;

&lt;p&gt;The trick is just to let the real library do the drawing. Your dashboard's Chart.js code is already the canonical source of truth for what a chart looks like in your product. Reusing it server-side means you stop maintaining a second chart implementation, and your emails finally match your app.&lt;/p&gt;

</description>
      <category>chartjs</category>
      <category>python</category>
      <category>ssr</category>
      <category>images</category>
    </item>
    <item>
      <title>HTML Invoice to Image in Laravel</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Tue, 05 May 2026 09:11:00 +0000</pubDate>
      <link>https://dev.to/accreditly/html-invoice-to-image-in-laravel-40li</link>
      <guid>https://dev.to/accreditly/html-invoice-to-image-in-laravel-40li</guid>
      <description>&lt;p&gt;I've written invoice PDFs with DomPDF. I've written them with wkhtmltopdf via Snappy. Both work. Neither produces output I'd want a customer to see if I cared about how the thing looked. DomPDF's CSS support is stuck somewhere around 2012, and Snappy hands you a wkhtmltopdf binary that renders fonts like it's still running on Windows XP.&lt;/p&gt;

&lt;p&gt;The specific thing that pushed me off both of them was a client who wanted their invoices to match their brand guide. Inter font, a specific hex for the accent colour, rounded corners on the total row, a subtle shadow under the line items, and a small logo top-left. DomPDF rendered the Inter font as Times New Roman. Snappy got the font right but broke the border-radius. Neither supported CSS grid, so the two-column header with billing and shipping addresses side-by-side needed a float hack that I didn't want to write in 2026.&lt;/p&gt;

&lt;p&gt;What I actually wanted was to render a Laravel Blade template the way Chrome renders it, and get a PNG I could email, embed in a dashboard, or drop into a PDF later. This article is how I got there without putting a headless browser on my own server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the usual Laravel invoice libraries fall short
&lt;/h2&gt;

&lt;p&gt;The state of PHP PDF libraries is, to put it politely, historical. DomPDF is actively maintained but its rendering engine is a subset of CSS 2.1 with partial CSS 3 support, which means no flexbox, no grid, unreliable webfonts, and shadow/filter properties that either ignore you or crash the renderer. It works for "here is a table of numbers" output. It doesn't work when your design team has opinions.&lt;/p&gt;

&lt;p&gt;Snappy (via wkhtmltopdf) rendered more faithfully for a while, but wkhtmltopdf itself was archived in 2023. It still runs. It's not getting updates. And even at its peak, its font rendering was noticeably different from Chrome, which matters because your designer previewed the template in Chrome.&lt;/p&gt;

&lt;p&gt;The modern-feeling option is to run Puppeteer or Playwright from PHP via an exec call, or a service like Browsershot. This works, but now you're maintaining a Node.js install alongside your Laravel app, a Chromium binary, and you're exec'ing into it from PHP which is a category of bug I don't love debugging. Browsershot in particular is great software, but it's infrastructure you have to install, update, and keep working on every environment including your CI and your production containers.&lt;/p&gt;

&lt;p&gt;The approach that got me out of this was to stop trying to render the invoice on my own infrastructure. Let the Blade template render to HTML the way it normally would. Post that HTML to an API. Get a PNG back. The invoice design lives entirely in Blade and CSS, same as any other Laravel view, and nothing about my server has to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I'll walk through a working implementation for a billing system that generates a PNG invoice when an order is marked as paid. The invoice has a header with company details, a line item table, a totals block, and a footer with payment terms. Everything you'd expect.&lt;/p&gt;

&lt;p&gt;Start with the Blade template at&amp;nbsp;&lt;code&gt;resources/views/invoices/image.blade.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&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=Inter:wght@400;500;600;800&amp;amp;display=swap');
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            width: 800px;
            font-family: 'Inter', system-ui, sans-serif;
            padding: 64px;
            color: #0f172a;
            background: white;
        }
        header {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 32px;
            padding-bottom: 32px;
            border-bottom: 2px solid #0f172a;
        }
        .brand h1 { font-size: 28px; font-weight: 800; margin-bottom: 4px; }
        .brand .addr { font-size: 13px; color: #64748b; line-height: 1.6; }
        .meta { text-align: right; }
        .meta .label { font-size: 12px; text-transform: uppercase; letter-spacing: 2px; color: #94a3b8; }
        .meta .invoice-no { font-size: 32px; font-weight: 800; margin: 6px 0 16px; }
        .meta .dates { font-size: 13px; color: #475569; line-height: 1.8; }
        .bill-to { margin: 40px 0 24px; }
        .bill-to .label { font-size: 12px; text-transform: uppercase; letter-spacing: 2px; color: #94a3b8; margin-bottom: 8px; }
        .bill-to .name { font-size: 16px; font-weight: 600; }
        .bill-to .addr { font-size: 13px; color: #475569; line-height: 1.6; margin-top: 4px; }
        table { width: 100%; border-collapse: collapse; margin-top: 16px; }
        th { text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: 1.5px; color: #94a3b8; padding: 12px 0; border-bottom: 1px solid #e2e8f0; }
        th.right { text-align: right; }
        td { padding: 16px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px; }
        td.right { text-align: right; }
        .totals { margin-top: 24px; display: flex; justify-content: flex-end; }
        .totals-inner { width: 300px; }
        .totals-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 14px; color: #475569; }
        .totals-row.total {
            margin-top: 12px; padding: 16px 20px;
            background: #0f172a; color: white;
            border-radius: 10px;
            font-size: 18px; font-weight: 800;
        }
        footer { margin-top: 48px; padding-top: 24px; border-top: 1px solid #e2e8f0; font-size: 12px; color: #64748b; line-height: 1.6; }
    &amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;header&amp;gt;
        &amp;lt;div class="brand"&amp;gt;
            &amp;lt;h1&amp;gt;{{ $business['name'] }}&amp;lt;/h1&amp;gt;
            &amp;lt;div class="addr"&amp;gt;
                {{ $business['address'] }}&amp;lt;br&amp;gt;
                {{ $business['email'] }} - {{ $business['phone'] }}
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class="meta"&amp;gt;
            &amp;lt;div class="label"&amp;gt;Invoice&amp;lt;/div&amp;gt;
            &amp;lt;div class="invoice-no"&amp;gt;#{{ $invoice['number'] }}&amp;lt;/div&amp;gt;
            &amp;lt;div class="dates"&amp;gt;
                Issued {{ $invoice['issued_at'] }}&amp;lt;br&amp;gt;
                Due {{ $invoice['due_at'] }}
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/header&amp;gt;

    &amp;lt;div class="bill-to"&amp;gt;
        &amp;lt;div class="label"&amp;gt;Bill to&amp;lt;/div&amp;gt;
        &amp;lt;div class="name"&amp;gt;{{ $customer['name'] }}&amp;lt;/div&amp;gt;
        &amp;lt;div class="addr"&amp;gt;{{ $customer['address'] }}&amp;lt;/div&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="right"&amp;gt;Qty&amp;lt;/th&amp;gt;
                &amp;lt;th class="right"&amp;gt;Unit&amp;lt;/th&amp;gt;
                &amp;lt;th class="right"&amp;gt;Amount&amp;lt;/th&amp;gt;
            &amp;lt;/tr&amp;gt;
        &amp;lt;/thead&amp;gt;
        &amp;lt;tbody&amp;gt;
            @foreach ($items as $item)
                &amp;lt;tr&amp;gt;
                    &amp;lt;td&amp;gt;{{ $item['description'] }}&amp;lt;/td&amp;gt;
                    &amp;lt;td class="right"&amp;gt;{{ $item['qty'] }}&amp;lt;/td&amp;gt;
                    &amp;lt;td class="right"&amp;gt;£{{ number_format($item['unit'], 2) }}&amp;lt;/td&amp;gt;
                    &amp;lt;td class="right"&amp;gt;£{{ number_format($item['qty'] * $item['unit'], 2) }}&amp;lt;/td&amp;gt;
                &amp;lt;/tr&amp;gt;
            @endforeach
        &amp;lt;/tbody&amp;gt;
    &amp;lt;/table&amp;gt;

    &amp;lt;div class="totals"&amp;gt;
        &amp;lt;div class="totals-inner"&amp;gt;
            &amp;lt;div class="totals-row"&amp;gt;&amp;lt;span&amp;gt;Subtotal&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;£{{ number_format($totals['subtotal'], 2) }}&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
            &amp;lt;div class="totals-row"&amp;gt;&amp;lt;span&amp;gt;VAT ({{ $totals['vat_rate'] }}%)&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;£{{ number_format($totals['vat'], 2) }}&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
            &amp;lt;div class="totals-row total"&amp;gt;&amp;lt;span&amp;gt;Total&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;£{{ number_format($totals['total'], 2) }}&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;footer&amp;gt;
        Payment due within 14 days. Bank transfer details on request.&amp;lt;br&amp;gt;
        {{ $business['name'] }} - Company no. {{ $business['company_no'] }}
    &amp;lt;/footer&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a service class that takes a domain Invoice, renders the Blade template, posts it to the API, and returns the PNG bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;?php

namespace App\Services;

use App\Models\Invoice;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\View;
use RuntimeException;

class InvoiceImageRenderer
{
    public function __construct(
        private readonly string $apiKey,
    ) {}

    public function render(Invoice $invoice): string
    {
        $html = View::make('invoices.image', [
            'business' =&amp;gt; config('invoicing.business'),
            'customer' =&amp;gt; [
                'name' =&amp;gt; $invoice-&amp;gt;customer-&amp;gt;name,
                'address' =&amp;gt; $invoice-&amp;gt;customer-&amp;gt;formatted_address,
            ],
            'invoice' =&amp;gt; [
                'number' =&amp;gt; $invoice-&amp;gt;number,
                'issued_at' =&amp;gt; $invoice-&amp;gt;issued_at-&amp;gt;format('j F Y'),
                'due_at' =&amp;gt; $invoice-&amp;gt;due_at-&amp;gt;format('j F Y'),
            ],
            'items' =&amp;gt; $invoice-&amp;gt;items-&amp;gt;map(fn ($i) =&amp;gt; [
                'description' =&amp;gt; $i-&amp;gt;description,
                'qty' =&amp;gt; $i-&amp;gt;quantity,
                'unit' =&amp;gt; $i-&amp;gt;unit_price,
            ])-&amp;gt;all(),
            'totals' =&amp;gt; [
                'subtotal' =&amp;gt; $invoice-&amp;gt;subtotal,
                'vat_rate' =&amp;gt; $invoice-&amp;gt;vat_rate,
                'vat' =&amp;gt; $invoice-&amp;gt;vat_amount,
                'total' =&amp;gt; $invoice-&amp;gt;total,
            ],
        ])-&amp;gt;render();

        $response = Http::withToken($this-&amp;gt;apiKey)
            -&amp;gt;timeout(30)
            -&amp;gt;post('https://api.html2img.com/v1/render', [
                'html' =&amp;gt; $html,
                'viewport_width' =&amp;gt; 800,
                'viewport_height' =&amp;gt; 1100,
                'device_scale_factor' =&amp;gt; 2,
                'wait_for_selector' =&amp;gt; '.totals-row.total',
                'full_page' =&amp;gt; true,
            ]);

        if ($response-&amp;gt;failed()) {
            throw new RuntimeException(
                "Invoice render failed: {$response-&amp;gt;status()} {$response-&amp;gt;body()}"
            );
        }

        return $response-&amp;gt;body();
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bind it in a service provider or resolve it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$renderer = new InvoiceImageRenderer(config('services.html2img.key'));
$png = $renderer-&amp;gt;render($invoice);

Storage::disk('s3')-&amp;gt;put("invoices/{$invoice-&amp;gt;number}.png", $png);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details in that render call worth pointing out.&amp;nbsp;&lt;code&gt;full_page: true&lt;/code&gt;&amp;nbsp;is what makes this work for invoices specifically, because an invoice's height depends on how many line items it has, and you don't want to clip or stretch. The viewport width stays fixed at 800 (a comfortable reading width for a document), and the height becomes whatever the content needs.&amp;nbsp;&lt;code&gt;wait_for_selector: '.totals-row.total'&lt;/code&gt;&amp;nbsp;ensures the render happens after the full content tree has mounted, which matters if the Google Font hasn't finished loading yet.&amp;nbsp;&lt;code&gt;device_scale_factor: 2&lt;/code&gt;&amp;nbsp;gives you a 1600-pixel-wide PNG that looks crisp when viewed on retina screens or printed.&lt;/p&gt;

&lt;p&gt;Blade does the HTML escaping for you when you use&amp;nbsp;&lt;code&gt;{{ $var }}&lt;/code&gt;, so user-supplied data like customer names and addresses can't break the layout or inject markup. If you're pulling item descriptions from a rich-text source, use&amp;nbsp;&lt;code&gt;{!! $var !!}&lt;/code&gt;&amp;nbsp;deliberately and only after you've sanitised it server-side.&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.amazonaws.com%2Fuploads%2Farticles%2Fq95fbgmv8m2ameppy3bt.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.amazonaws.com%2Fuploads%2Farticles%2Fq95fbgmv8m2ameppy3bt.png" width="800" height="1100"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas worth knowing
&lt;/h2&gt;

&lt;p&gt;Fonts are the biggest source of surprise on first run. The&amp;nbsp;&lt;code&gt;@import&lt;/code&gt;&amp;nbsp;inside the&amp;nbsp;&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;&amp;nbsp;block works, but adds a network round-trip inside the render. For a production pipeline that's generating thousands of invoices, either host the font file yourself and reference it via&amp;nbsp;&lt;code&gt;@font-face&lt;/code&gt;&amp;nbsp;with a publicly reachable URL, or base64-encode the woff2 and inline it. The&amp;nbsp;&lt;code&gt;wait_for_selector&lt;/code&gt;&amp;nbsp;option helps, but if your selector is visible before the font has swapped in, you still get a fallback for a brief moment. Adding a small&amp;nbsp;&lt;code&gt;ms_delay&lt;/code&gt;&amp;nbsp;of 300-500ms on top of the selector wait is a reasonable belt-and-braces.&lt;/p&gt;

&lt;p&gt;Numbers need locale-aware formatting.&amp;nbsp;&lt;code&gt;number_format()&lt;/code&gt;&amp;nbsp;defaults to US conventions. For UK invoices I use&amp;nbsp;&lt;code&gt;number_format($amount, 2, '.', ',')&lt;/code&gt;. For European invoices where the comma is the decimal separator, switch the arguments. Getting this wrong in production is embarrassing.&lt;/p&gt;

&lt;p&gt;Don't render invoices synchronously inside a web request if you can avoid it. Queue it. Laravel's job system handles this naturally, and the API supports webhooks if you want the render itself to be asynchronous and get notified when it's done. For batch runs, the webhook flow is substantially faster because you're not blocking on each render.&lt;/p&gt;

&lt;p&gt;One operational note: version your invoice template. When you change the design, old invoices that get re-rendered will look different from their original version, which can cause accounting confusion. Either snapshot the rendered PNG to S3 at generation time and serve that, or include a&amp;nbsp;&lt;code&gt;template_version&lt;/code&gt;&amp;nbsp;column on the invoice and pick the right Blade view based on it. I do the former.&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;&lt;a href="https://html2img.com/docs/usage/laravel" rel="noopener noreferrer"&gt;Laravel usage guide&lt;/a&gt;&amp;nbsp;covers the framework integration in more depth, including the HTTP client setup and how to test the renderer without hitting the API. For longer documents or batch work, the&amp;nbsp;&lt;a href="https://html2img.com/docs/parameters/webhook-url" rel="noopener noreferrer"&gt;webhook parameter docs&lt;/a&gt;&amp;nbsp;walk through the async pattern. And if you want more examples of document-style templates beyond invoices, the&amp;nbsp;&lt;a href="https://html2img.com/docs/examples/invoice-receipt" rel="noopener noreferrer"&gt;invoice and receipt example&lt;/a&gt;&amp;nbsp;has a few variants.&lt;/p&gt;

&lt;p&gt;Render the view, post the HTML, save the PNG. Your Blade template is the source of truth for what the invoice looks like, the same way it's the source of truth for the rest of your app.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>html</category>
      <category>invoice</category>
    </item>
    <item>
      <title>Replacing Puppeteer on AWS Lambda for Screenshots</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Thu, 30 Apr 2026 08:44:00 +0000</pubDate>
      <link>https://dev.to/accreditly/replacing-puppeteer-on-aws-lambda-for-screenshots-3622</link>
      <guid>https://dev.to/accreditly/replacing-puppeteer-on-aws-lambda-for-screenshots-3622</guid>
      <description>&lt;p&gt;My chrome-aws-lambda function had been running fine for about eight months. Then Chrome shipped a new major version, the pinned Chromium binary in my layer went stale, pages started rendering with missing fonts, and the Lambda cold starts crept from two seconds up to nearly nine. I spent a weekend rebuilding the layer, swapping to&amp;nbsp;&lt;code&gt;@sparticuz/chromium&lt;/code&gt;, and arguing with webpack about why&amp;nbsp;&lt;code&gt;puppeteer-core&lt;/code&gt;&amp;nbsp;kept bundling things it shouldn't.&lt;/p&gt;

&lt;p&gt;That was the third time in two years I'd done some version of that weekend. At that point I started seriously looking at Puppeteer Lambda alternatives, because the pattern was clear: running headless Chrome inside a 250MB function limit is a thing you can make work, but it's not a thing that stays working without active maintenance.&lt;/p&gt;

&lt;p&gt;This article is about what I moved to instead. It's specifically for people who have a working Puppeteer-on-Lambda setup that's getting expensive to maintain, or who are about to build one and want to know if there's a less painful option. The short version: yes, and the trade-off is usually worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Puppeteer on Lambda gets expensive to own
&lt;/h2&gt;

&lt;p&gt;The problems compound. Individually none of them are deal-breakers, but together they chew through engineering time.&lt;/p&gt;

&lt;p&gt;Lambda has a 250MB unzipped deployment size limit. A full Chromium binary is around 170MB compressed, more unzipped, so you're immediately using a Lambda layer or container image. Every project that needs screenshots now has infrastructure decisions to make before it renders a single pixel.&lt;/p&gt;

&lt;p&gt;Chrome ships a new major version roughly every four weeks.&amp;nbsp;&lt;code&gt;chrome-aws-lambda&lt;/code&gt;&amp;nbsp;famously stopped receiving updates, and its successor&amp;nbsp;&lt;code&gt;@sparticuz/chromium&lt;/code&gt;&amp;nbsp;needs a matching&amp;nbsp;&lt;code&gt;puppeteer-core&lt;/code&gt;&amp;nbsp;version. Mismatch them and pages render oddly, or not at all. I've had a deployment where screenshots of the same URL produced different results locally (using a Chrome 120 binary) versus on Lambda (pinned to 114), and spent half a day figuring out why a flexbox layout was collapsing only in production.&lt;/p&gt;

&lt;p&gt;Cold starts are the user-facing tax. Booting a headless Chrome instance inside a Lambda container takes two to four seconds even on warm infrastructure, more if the binary has to be extracted from the layer first. You can keep functions warm, but that undoes some of the cost argument for serverless in the first place.&lt;/p&gt;

&lt;p&gt;Memory is the quiet killer. Chrome's per-page memory footprint is unpredictable. A page with a complex JavaScript-rendered chart can briefly spike to 700MB. Your function runs fine on a 1024MB allocation ninety-five percent of the time, and then a specific URL causes an OOM and the whole invocation fails. You discover this in production.&lt;/p&gt;

&lt;p&gt;None of these are bugs. They're the shape of the problem. Running a browser engine inside a serverless function is just an awkward fit, and the fit doesn't improve over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alternative: treat the browser as an API
&lt;/h2&gt;

&lt;p&gt;The pattern I settled on is to treat rendering as a third-party service rather than infrastructure I own. My Lambda function does what Lambdas are good at, which is receiving an event, doing some business logic, and making HTTP calls. When it needs an image, it posts to a rendering API and gets back a PNG. The browser lives somewhere else, maintained by someone whose entire job is keeping it running.&lt;/p&gt;

&lt;p&gt;There are a few services that do this. I landed on&amp;nbsp;&lt;a href="https://html2img.com/" rel="noopener noreferrer"&gt;html2img.com&lt;/a&gt;&amp;nbsp;because its API accepts raw HTML directly, which matters when the thing I'm trying to screenshot is something I've generated server-side (invoices, social cards, receipts) rather than a public URL. For URL-based screenshots it also works fine, but the HTML-first path is what got me off Puppeteer.&lt;/p&gt;

&lt;p&gt;The migration is mostly subtractive. You delete the Chromium layer, remove&amp;nbsp;&lt;code&gt;puppeteer-core&lt;/code&gt;&amp;nbsp;from your dependencies, swap the browser-launching block for a&amp;nbsp;&lt;code&gt;fetch&lt;/code&gt;&amp;nbsp;call, and your Lambda package size drops by about 80%.&lt;/p&gt;

&lt;p&gt;Here's what we're going to be making:&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.amazonaws.com%2Fuploads%2Farticles%2Fa6zq5t3vb5sidia6u94n.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.amazonaws.com%2Fuploads%2Farticles%2Fa6zq5t3vb5sidia6u94n.png" width="800" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the replacement looks like in practice
&lt;/h2&gt;

&lt;p&gt;Here's a before-and-after for a Lambda function that generates a receipt image when an order is placed. The before version uses&amp;nbsp;&lt;code&gt;@sparticuz/chromium&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;puppeteer-core&lt;/code&gt;. The after version uses&amp;nbsp;&lt;code&gt;fetch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The Puppeteer version, trimmed for readability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const chromium = require('@sparticuz/chromium');
const puppeteer = require('puppeteer-core');

exports.handler = async (event) =&amp;gt; {
  const { order } = JSON.parse(event.body);
  const html = buildReceiptHtml(order);

  const browser = await puppeteer.launch({
    args: chromium.args,
    executablePath: await chromium.executablePath(),
    headless: chromium.headless,
  });

  try {
    const page = await browser.newPage();
    await page.setViewport({ width: 600, height: 800, deviceScaleFactor: 2 });
    await page.setContent(html, { waitUntil: 'networkidle0' });
    const png = await page.screenshot({ type: 'png' });

    return {
      statusCode: 200,
      headers: { 'Content-Type': 'image/png' },
      body: png.toString('base64'),
      isBase64Encoded: true,
    };
  } finally {
    await browser.close();
  }
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works. It's also 180MB of dependencies, takes three seconds to cold start, and will break the next time Chrome updates and you forget to bump the Chromium package.&lt;/p&gt;

&lt;p&gt;The replacement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;exports.handler = async (event) =&amp;gt; {
  const { order } = JSON.parse(event.body);
  const html = buildReceiptHtml(order);

  const response = await fetch('https://api.html2img.com/v1/render', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.HTML2IMG_API_KEY}`,
    },
    body: JSON.stringify({
      html,
      viewport_width: 600,
      viewport_height: 800,
      device_scale_factor: 2,
      wait_for_selector: '.total',
    }),
  });

  if (!response.ok) {
    const err = await response.text();
    throw new Error(`Render failed: ${response.status} ${err}`);
  }

  const png = Buffer.from(await response.arrayBuffer());

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'image/png' },
    body: png.toString('base64'),
    isBase64Encoded: true,
  };
};

function buildReceiptHtml(order) {
  const rows = order.items.map(item =&amp;gt; `
    &amp;lt;tr&amp;gt;
      &amp;lt;td&amp;gt;${escape(item.name)}&amp;lt;/td&amp;gt;
      &amp;lt;td class="qty"&amp;gt;${item.quantity}&amp;lt;/td&amp;gt;
      &amp;lt;td class="price"&amp;gt;£${item.price.toFixed(2)}&amp;lt;/td&amp;gt;
    &amp;lt;/tr&amp;gt;
  `).join('');

  return `
    &amp;lt;!DOCTYPE html&amp;gt;
    &amp;lt;html&amp;gt;
      &amp;lt;head&amp;gt;
        &amp;lt;style&amp;gt;
          body {
            width: 600px;
            font-family: -apple-system, system-ui, sans-serif;
            padding: 48px;
            color: #111;
          }
          h1 { font-size: 28px; margin-bottom: 8px; }
          .meta { color: #666; font-size: 14px; margin-bottom: 32px; }
          table { width: 100%; border-collapse: collapse; }
          td { padding: 12px 0; border-bottom: 1px solid #eee; font-size: 16px; }
          .qty, .price { text-align: right; }
          .total {
            margin-top: 24px;
            padding-top: 24px;
            border-top: 2px solid #111;
            font-size: 22px;
            font-weight: 700;
            display: flex;
            justify-content: space-between;
          }
          footer { margin-top: 48px; color: #888; font-size: 13px; }
        &amp;lt;/style&amp;gt;
      &amp;lt;/head&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;h1&amp;gt;${escape(order.businessName)}&amp;lt;/h1&amp;gt;
        &amp;lt;div class="meta"&amp;gt;Order #${order.id} - ${order.date}&amp;lt;/div&amp;gt;
        &amp;lt;table&amp;gt;&amp;lt;tbody&amp;gt;${rows}&amp;lt;/tbody&amp;gt;&amp;lt;/table&amp;gt;
        &amp;lt;div class="total"&amp;gt;&amp;lt;span&amp;gt;Total&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;£${order.total.toFixed(2)}&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;footer&amp;gt;Thanks for your order.&amp;lt;/footer&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  `;
}

function escape(str) {
  return String(str)
    .replace(/&amp;amp;/g, '&amp;amp;amp;')
    .replace(/&amp;lt;/g, '&amp;amp;lt;')
    .replace(/&amp;gt;/g, '&amp;amp;gt;');
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same input, same output. No browser to boot, no layer to maintain, no Chromium version to track. The Lambda deployment is now just your handler code and whatever you were doing before the Puppeteer block, which in my case was about 40KB zipped.&lt;/p&gt;

&lt;p&gt;A couple of things worth pointing out in the replacement. The&amp;nbsp;&lt;code&gt;wait_for_selector&lt;/code&gt;&amp;nbsp;parameter tells the renderer to wait until&amp;nbsp;&lt;code&gt;.total&lt;/code&gt;&amp;nbsp;exists in the DOM before capturing, which matters if your HTML has any client-side rendering or webfonts.&amp;nbsp;&lt;code&gt;device_scale_factor: 2&lt;/code&gt;&amp;nbsp;gives you a retina-quality PNG rendered into the specified viewport, which is typically what you want for anything that'll be displayed or printed. And&amp;nbsp;&lt;code&gt;escape&lt;/code&gt;&amp;nbsp;is doing the job that&amp;nbsp;&lt;code&gt;page.setContent&lt;/code&gt;&amp;nbsp;was implicitly doing for you before, which is preventing user-supplied data from breaking your HTML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas worth knowing
&lt;/h2&gt;

&lt;p&gt;The big behavioural difference is latency. An API call adds a round-trip that your in-Lambda Puppeteer didn't have. In practice this has been faster overall for me, because the cold start I was paying on Lambda was longer than the API round-trip, but it's worth measuring for your own workload. If you're doing thousands of renders synchronously inside a single request handler, you'll want to parallelise the API calls or use the webhook flow so the render happens asynchronously.&lt;/p&gt;

&lt;p&gt;For anything bulk or latency-sensitive, the webhook pattern is worth knowing about. You post the HTML with a&amp;nbsp;&lt;code&gt;webhook_url&lt;/code&gt;&amp;nbsp;parameter, get back a job ID immediately, and html2img posts the finished PNG to your webhook when it's done. Your Lambda returns in milliseconds, and a separate function handles the completed image. This is how I run anything that generates more than one image per invocation.&lt;/p&gt;

&lt;p&gt;Error handling needs more thought than with Puppeteer, because you now have a network dependency. Wrap the fetch in a retry with backoff for 5xx responses. Log the response body on failure, not just the status code, because the API's error messages usually tell you exactly what's wrong (a missing selector, an HTML syntax error, a timeout). And make sure your function timeout is higher than the API's maximum render time plus a margin.&lt;/p&gt;

&lt;p&gt;Finally, don't commit your API key. It reads as obvious advice but I've seen people check a key into a repo because their Lambda needs it at runtime and "it's just for a test." Use Parameter Store, Secrets Manager, or at minimum an environment variable set through the console, never a hardcoded literal.&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;&lt;a href="https://html2img.com/docs/usage" rel="noopener noreferrer"&gt;Node.js usage guide&lt;/a&gt;&amp;nbsp;covers the full parameter surface, and the&amp;nbsp;&lt;a href="https://html2img.com/docs/parameters/webhook-url" rel="noopener noreferrer"&gt;webhook documentation&lt;/a&gt;&amp;nbsp;walks through the async flow if you're doing bulk work. If you're specifically migrating existing Puppeteer screenshot code, the&amp;nbsp;&lt;a href="https://html2img.com/docs/parameters" rel="noopener noreferrer"&gt;parameters reference&lt;/a&gt;&amp;nbsp;maps the Puppeteer options you're used to (viewport, wait conditions, full-page capture) to the equivalent API parameters.&lt;/p&gt;

&lt;p&gt;Less infrastructure, fewer weekends lost to Chrome version drift, same PNGs out the other end. For any Lambda that currently boots a browser, that's a good trade.&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>aws</category>
      <category>lambda</category>
      <category>screenshots</category>
    </item>
    <item>
      <title>Dynamic OG Images in Next.js Without @vercel/og (1,200 630)</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Mon, 27 Apr 2026 08:42:29 +0000</pubDate>
      <link>https://dev.to/accreditly/dynamic-og-images-in-nextjs-without-vercelog-1200x630-30ic</link>
      <guid>https://dev.to/accreditly/dynamic-og-images-in-nextjs-without-vercelog-1200x630-30ic</guid>
      <description>&lt;p&gt;I had a perfectly reasonable OG image template. Flexbox layout, custom font, a gradient background, a small CSS grid for a two-column footer with the author name and publish date. Worked in the browser. Looked great in Figma. Then I moved it into&amp;nbsp;&lt;code&gt;next/og&lt;/code&gt;&amp;nbsp;and half of it disappeared.&lt;/p&gt;

&lt;p&gt;No errors. No warnings. Satori, the renderer behind&amp;nbsp;&lt;code&gt;ImageResponse&lt;/code&gt;, just silently ignored the parts it doesn't support. CSS grid, gone. The&amp;nbsp;&lt;code&gt;calc()&lt;/code&gt;&amp;nbsp;I was using for spacing, ignored. A CSS variable for the brand colour, treated as an unknown value. The fallback rendered, which was worse than if it had thrown, because I didn't notice until someone shared the link on Slack and the preview was visibly broken.&lt;/p&gt;

&lt;p&gt;If you've been building dynamic OG images in Next.js and hit the same wall, this article is about the other way to do it: rendering your template in an actual browser via an API, and serving the resulting PNG from your&amp;nbsp;&lt;code&gt;opengraph-image&lt;/code&gt;&amp;nbsp;route. Same developer ergonomics, no CSS subset to memorise, and your template is literally just HTML and CSS that renders the way you'd expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why @vercel/og falls short once your template gets real
&lt;/h2&gt;

&lt;p&gt;Satori is clever. It parses JSX, approximates a subset of CSS, and produces an SVG which then gets rasterised to PNG. For simple cards, a title over a solid background with one font, it's lovely. The problems show up when your designer hands you something that looks like an actual marketing asset.&lt;/p&gt;

&lt;p&gt;Flexbox is supported. Grid is not.&amp;nbsp;&lt;code&gt;display: flex&lt;/code&gt;&amp;nbsp;has to be set explicitly on every container, even&amp;nbsp;&lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;, because Satori's defaults aren't the browser's. Custom fonts have to be fetched inside the request handler and passed into&amp;nbsp;&lt;code&gt;ImageResponse&lt;/code&gt;, not loaded at module scope. The entire bundle, including fonts and any inlined imagery, has to stay under 500KB on Edge. CSS variables don't resolve.&amp;nbsp;&lt;code&gt;calc()&lt;/code&gt;&amp;nbsp;doesn't compute. Box shadows work, mostly, until they don't.&lt;/p&gt;

&lt;p&gt;You can work around all of this. I did, for a while. The problem is that every design tweak becomes a translation exercise. You're not writing CSS any more, you're writing the subset of CSS that Satori understands, and the feedback loop is slow because rendering happens at request time on Edge.&lt;/p&gt;

&lt;p&gt;The alternative that I keep coming back to is rendering the template in a real Chromium instance. You write normal HTML and CSS, including grid, variables, custom fonts loaded with&amp;nbsp;&lt;code&gt;@font-face&lt;/code&gt;, animations you can freeze on a specific frame if you want, anything. Then you screenshot it at 1200×630 and serve the PNG. The only question is where that Chromium lives.&lt;/p&gt;

&lt;p&gt;Running Puppeteer yourself on Vercel or AWS Lambda is possible but tedious. The chromium binary is too large for the default Lambda size limit, so you end up on&amp;nbsp;&lt;code&gt;@sparticuz/chromium&lt;/code&gt;&amp;nbsp;or a layer, watching memory usage, dealing with cold starts, pinning versions when Chrome updates break things. For an OG image pipeline that has to work reliably every time someone shares a link, I'd rather not own that problem. An API that takes HTML and gives you back a PNG cuts out the whole category of infrastructure work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The approach
&lt;/h2&gt;

&lt;p&gt;The pattern is straightforward. In your route folder, you add an&amp;nbsp;&lt;code&gt;opengraph-image.tsx&lt;/code&gt;&amp;nbsp;file (Next.js picks this up automatically and wires it to the right meta tag). Inside it, you build the HTML and CSS for the card using whatever data the route has access to, post it to the html2img API, and return the resulting PNG as the response.&lt;/p&gt;

&lt;p&gt;Because Next.js caches OG image routes aggressively by default, you don't pay the API cost on every share. The image gets generated once per unique URL and then served from the framework's cache. If your content changes, you bust the cache the same way you'd bust any route cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the thing in Next.js
&lt;/h2&gt;

&lt;p&gt;Here's a working implementation for a blog where each post has its own OG image with the title, author, publish date, and category.&lt;/p&gt;

&lt;p&gt;Here's what we're going to be making:&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.amazonaws.com%2Fuploads%2Farticles%2Fzaiox25iklnsaaoix4ia.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.amazonaws.com%2Fuploads%2Farticles%2Fzaiox25iklnsaaoix4ia.png" width="800" height="420"&gt;&lt;/a&gt;Looks great, right?&lt;/p&gt;

&lt;p&gt;I'm using the App Router. If you're on Pages Router, the same idea works inside an API route, you just return the bytes yourself.&lt;/p&gt;

&lt;p&gt;Inside the post route folder,&amp;nbsp;&lt;code&gt;app/posts/[slug]/opengraph-image.tsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { getPostBySlug } from '@/lib/posts';

export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default async function OpengraphImage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPostBySlug(params.slug);

  const html = `
    &amp;lt;!DOCTYPE html&amp;gt;
    &amp;lt;html&amp;gt;
      &amp;lt;head&amp;gt;
        &amp;lt;style&amp;gt;
          @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&amp;amp;display=swap');
          :root {
            --brand: #0b1220;
            --accent: #f59e0b;
            --muted: #94a3b8;
          }
          * { box-sizing: border-box; margin: 0; padding: 0; }
          body {
            width: 1200px;
            height: 630px;
            background: linear-gradient(135deg, var(--brand) 0%, #1e293b 100%);
            font-family: 'Inter', system-ui, sans-serif;
            color: white;
            padding: 80px;
            display: grid;
            grid-template-rows: auto 1fr auto;
          }
          .category {
            font-size: 20px;
            font-weight: 700;
            color: var(--accent);
            text-transform: uppercase;
            letter-spacing: 2px;
          }
          h1 {
            font-size: 68px;
            font-weight: 900;
            line-height: 1.1;
            align-self: center;
            max-width: 900px;
          }
          footer {
            display: grid;
            grid-template-columns: 1fr auto;
            align-items: end;
            color: var(--muted);
            font-size: 22px;
          }
          .author { color: white; font-weight: 700; }
        &amp;lt;/style&amp;gt;
      &amp;lt;/head&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;div class="category"&amp;gt;${escapeHtml(post.category)}&amp;lt;/div&amp;gt;
        &amp;lt;h1&amp;gt;${escapeHtml(post.title)}&amp;lt;/h1&amp;gt;
        &amp;lt;footer&amp;gt;
          &amp;lt;div&amp;gt;&amp;lt;span class="author"&amp;gt;${escapeHtml(post.author)}&amp;lt;/span&amp;gt; - ${post.publishedAt}&amp;lt;/div&amp;gt;
          &amp;lt;div&amp;gt;yourdomain.com&amp;lt;/div&amp;gt;
        &amp;lt;/footer&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  `;

  const response = await fetch('https://api.html2img.com/v1/render', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.HTML2IMG_API_KEY}`,
    },
    body: JSON.stringify({
      html,
      viewport_width: 1200,
      viewport_height: 630,
      device_scale_factor: 2,
      wait_for_selector: 'h1',
    }),
  });

  if (!response.ok) {
    throw new Error(`html2img returned ${response.status}`);
  }

  const png = await response.arrayBuffer();

  return new Response(png, {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=31536000, immutable',
    },
  });
}

function escapeHtml(str: string) {
  return str
    .replace(/&amp;amp;/g, '&amp;amp;amp;')
    .replace(/&amp;lt;/g, '&amp;amp;lt;')
    .replace(/&amp;gt;/g, '&amp;amp;gt;')
    .replace(/"/g, '&amp;amp;quot;');
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth pointing out.&amp;nbsp;&lt;code&gt;device_scale_factor: 2&lt;/code&gt;&amp;nbsp;gives you a retina-quality 2400×1260 PNG, which is what Twitter actually wants these days, rendered into the 1200×630 viewport.&amp;nbsp;&lt;code&gt;wait_for_selector: 'h1'&lt;/code&gt;&amp;nbsp;makes sure the page has rendered the heading before the screenshot runs, which matters if you're pulling a font from Google Fonts. Without that wait, you can occasionally get an image where the font hasn't loaded yet and the fallback renders instead.&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;&lt;code&gt;escapeHtml&lt;/code&gt;&amp;nbsp;helper is not optional. If someone's post title contains a quote character or an ampersand and you interpolate it into an HTML string without escaping, you get either a broken image or, depending on what you're rendering, a small HTML injection vector on your own server. Always escape.&lt;/p&gt;

&lt;p&gt;Set your API key as an environment variable and you're done. Next.js will call this function when Twitter or LinkedIn or Slack hits&amp;nbsp;&lt;code&gt;/posts/my-post/opengraph-image.png&lt;/code&gt;, cache the result, and serve it from CDN on subsequent requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I've hit
&lt;/h2&gt;

&lt;p&gt;Fonts are the most common source of surprise. Google Fonts via&amp;nbsp;&lt;code&gt;@import&lt;/code&gt;&amp;nbsp;works but adds a network round-trip inside the render. If latency matters to you, either use a system font stack, inline a&amp;nbsp;&lt;code&gt;@font-face&lt;/code&gt;&amp;nbsp;with a base64-encoded woff2, or host the font on your own CDN. The&amp;nbsp;&lt;code&gt;wait_for_selector&lt;/code&gt;&amp;nbsp;parameter helps, but if the selector appears before the font loads, you still get a fallback. For guaranteed results, add a small&amp;nbsp;&lt;code&gt;ms_delay&lt;/code&gt;&amp;nbsp;on top.&lt;/p&gt;

&lt;p&gt;Image assets inside the HTML need to be publicly accessible URLs. If your logo is behind auth, inline it as a base64 data URL instead. Same for any background images.&lt;/p&gt;

&lt;p&gt;Caching is worth thinking about carefully. The&amp;nbsp;&lt;code&gt;Cache-Control: immutable&lt;/code&gt;&amp;nbsp;header on the route is safe because Next.js gives each OG image route a unique URL per slug, but if your post content changes and you want the OG image to reflect that, you need to either include a content hash in the route or manually revalidate. I lean towards including a short hash of the title in the URL for content that might be edited.&lt;/p&gt;

&lt;p&gt;Finally, error handling. If the API call fails, you don't want your share previews to break. Wrap the fetch in a try/catch and return a static fallback image on failure. A broken OG image is much worse than a generic one.&lt;/p&gt;

&lt;p&gt;The framework-specific setup is covered in more detail in&amp;nbsp;&lt;a href="https://html2img.com/docs/usage/react" rel="noopener noreferrer"&gt;the html2img React and Next.js usage guide&lt;/a&gt;. If you want to see more template examples beyond blog cards,&amp;nbsp;&lt;a href="https://html2img.com/docs/examples" rel="noopener noreferrer"&gt;the product card and testimonial card examples&lt;/a&gt;&amp;nbsp;are good starting points for social-share-style imagery. And for the full list of rendering parameters, including webhook support for async generation when you're batch-regenerating a lot of images, the&amp;nbsp;&lt;a href="https://html2img.com/docs/parameters" rel="noopener noreferrer"&gt;parameters reference&lt;/a&gt;&amp;nbsp;has the complete set.&lt;/p&gt;

&lt;p&gt;Write the HTML the way you'd write any landing page, let a real browser render it, cache the output. That's the whole pattern.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>css</category>
      <category>html</category>
      <category>vercel</category>
    </item>
    <item>
      <title>How to use Tailwinds `safelist` to handle dynamic classes</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Fri, 23 Aug 2024 09:25:00 +0000</pubDate>
      <link>https://dev.to/accreditly/how-to-use-tailwinds-safelist-to-handle-dynamic-classes-4o16</link>
      <guid>https://dev.to/accreditly/how-to-use-tailwinds-safelist-to-handle-dynamic-classes-4o16</guid>
      <description>&lt;p&gt;&lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;Tailwind CSS&lt;/a&gt; is a popular utility-first CSS framework that allows developers to create custom designs quickly and efficiently. By default, Tailwind CSS generates a wide range of utility classes, which can lead to large file sizes. To address this issue, Tailwind CSS comes with a built-in feature called PurgeCSS that removes unused styles from the production build, making the final CSS file smaller and more performant. However, this automatic removal may sometimes cause issues when certain styles are used dynamically or conditionally in your application. In this article, we'll dive deep into the &lt;code&gt;safelist&lt;/code&gt; feature in Tailwind CSS, learn how to whitelist specific styles, and explore various scenarios where using &lt;code&gt;safelist&lt;/code&gt; can be helpful.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Understanding PurgeCSS in Tailwind CSS
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://purgecss.com/" rel="noopener noreferrer"&gt;PurgeCSS&lt;/a&gt; is a powerful tool that scans your project files for any class names used and removes the unused ones from the final CSS file. This significantly reduces the size of the generated CSS, making your application load faster. &lt;/p&gt;

&lt;p&gt;By default, Tailwind CSS includes PurgeCSS configuration that scans your HTML, JavaScript, and Vue files for any class names. You can easily tweak what files are picked up within the &lt;code&gt;content&lt;/code&gt; array of the config file.&lt;/p&gt;

&lt;p&gt;In some situations, you might need to prevent specific styles from being removed, even if they're not detected in your files. This is where the &lt;code&gt;safelist&lt;/code&gt; feature comes into play.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Introducing Safelist
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Safelist&lt;/code&gt; is a feature in Tailwind CSS that allows you to whitelist certain styles so they don't get removed during the purging process. This is particularly useful when you have dynamic class names generated through JavaScript or applied based on user interaction. Another very common use-case for &lt;code&gt;safelist&lt;/code&gt; is when colors or styles are driven from a CMS or backend framework. One such example might be a system that allows a website admin to edit the color of a category in a CMS, which in turn changes the color of the nav items for that category. Tailwind won't see the actual class name as the file will contain server-side code that outputs the color.&lt;/p&gt;

&lt;p&gt;By adding these class names to the safelist, you ensure that they will always be included in your final CSS file, regardless of whether PurgeCSS can find them in your project files or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Configuring Safelist in &lt;code&gt;tailwind.config.js&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;To configure the &lt;code&gt;safelist&lt;/code&gt; in your Tailwind CSS project, you need to modify the &lt;code&gt;tailwind.config.js&lt;/code&gt; file. The &lt;code&gt;safelist&lt;/code&gt; is an array of class names that you want to keep in your final CSS file, even if they're not found in your project files. Here's an example of how to add class names to the &lt;code&gt;safelist&lt;/code&gt;:&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;// tailwind.config.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&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="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;// your content files here&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;safelist&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;bg-red-500&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;text-white&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;hover:bg-red-700&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;  
  &lt;span class="c1"&gt;// other configurations&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this example, the &lt;code&gt;bg-red-500&lt;/code&gt;, &lt;code&gt;text-white&lt;/code&gt;, and &lt;code&gt;hover:bg-red-700&lt;/code&gt; classes are added to the &lt;code&gt;safelist&lt;/code&gt;. These classes will always be included in your final CSS file, even if PurgeCSS doesn't find them in your project files.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. More advanced configurations
&lt;/h2&gt;

&lt;p&gt;If you have a lot of classes to manage within &lt;code&gt;safelist&lt;/code&gt;, perhaps due to multiple colors and the need to support variants/modifiers such as &lt;code&gt;:hover&lt;/code&gt;, &lt;code&gt;:focus&lt;/code&gt;, &lt;code&gt;:active&lt;/code&gt; and &lt;code&gt;dark:&lt;/code&gt; then it can quickly become very challenging to manage these within &lt;code&gt;safelist&lt;/code&gt;. The list will become huge very quickly.&lt;/p&gt;

&lt;p&gt;That's where patterns come in. Tailwind support regex within the &lt;code&gt;safelist&lt;/code&gt;:&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="nx"&gt;safelist&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;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/from-&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;blue|green|indigo|pink|orange|rose&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;-200/&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/to-&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;blue|green|indigo|pink|orange|rose&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;-100/&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;With these 2 entries we are effectively adding 12 classes. &lt;code&gt;from-{color}-200&lt;/code&gt; and &lt;code&gt;to-{color}-100&lt;/code&gt;, where &lt;code&gt;{color}&lt;/code&gt; is all of the colors in the list. It makes it much easier to manage the lists. Remember that &lt;code&gt;tailwind.config.js&lt;/code&gt; is just a JavaScript file, so you can manage variables at the top of the file if you're managing lists of colors that are repeated heavily.&lt;/p&gt;

&lt;p&gt;It's also possible to define variants for everything within the list without needing to explicitly list them in 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="nx"&gt;safelist&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;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/text-&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;blue|green|indigo|pink|orange|rose&lt;/span&gt;&lt;span class="se"&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;600|400&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;variants&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;hover&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;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/from-&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;blue|green|indigo|pink|orange|rose&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;-200/&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/to-&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;blue|green|indigo|pink|orange|rose&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;-100/&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;
  
  
  5. Safelist Examples and Use Cases
&lt;/h2&gt;

&lt;p&gt;There are several scenarios where using the &lt;code&gt;safelist&lt;/code&gt; feature can be helpful:&lt;/p&gt;

&lt;p&gt;Dynamic class names: If you're generating class names dynamically based on some data or user input, PurgeCSS may not detect these classes and remove them from the final CSS file. By adding these dynamic classes to the &lt;code&gt;safelist&lt;/code&gt;, you can ensure they're always available in your application.&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;// Example of a dynamic class name based on user input&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// This value might come from an API or user input&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alertClass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`alert-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userInput&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="c1"&gt;// Generated class name: 'alert-success'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this example, the &lt;code&gt;alertClass&lt;/code&gt; variable generates a class name based on user input or data from an API. Since PurgeCSS can't detect this dynamic class name, you should add it to the &lt;code&gt;safelist&lt;/code&gt; in your &lt;code&gt;tailwind.config.js&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;Conditional styles: If you have styles that only apply under specific conditions, such as a dark mode or a mobile view, you can use the &lt;code&gt;safelist&lt;/code&gt; to ensure those styles are always included in your final CSS file.&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;// Example of a conditional style based on a media query&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;767&lt;/span&gt;&lt;span class="nx"&gt;px&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;hidden&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;mobile&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="nx"&gt;none&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;In this example, the hidden-mobile class is only applied when the viewport width is less than 768 pixels. Since this class might not be detected by PurgeCSS, you should add it to the &lt;code&gt;safelist&lt;/code&gt; in your &lt;code&gt;tailwind.config.js&lt;/code&gt; file.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Best Practices for Safelisting
&lt;/h2&gt;

&lt;p&gt;When using the &lt;code&gt;safelist&lt;/code&gt; feature in Tailwind CSS, keep the following best practices in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only add classes to the &lt;code&gt;safelist&lt;/code&gt; that are truly necessary. Adding too many classes can bloat your final CSS file and negate the benefits of PurgeCSS.&lt;/li&gt;
&lt;li&gt;If you have many dynamic class names or a complex application, consider using a function or regular expression to generate the safelist array. This can help keep your configuration cleaner and more maintainable.&lt;/li&gt;
&lt;li&gt;Test your production build to ensure that all required styles are included. This can help you catch any issues early on and avoid surprises when deploying your application.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;safelist&lt;/code&gt; feature in Tailwind CSS provides a powerful way to whitelist specific styles and ensure they are included in your final CSS file, even if they are not detected by PurgeCSS. By understanding how to configure the &lt;code&gt;safelist&lt;/code&gt; and use it effectively in various scenarios, you can make your Tailwind CSS projects more robust and maintainable. Remember to follow best practices when using the &lt;code&gt;safelist&lt;/code&gt; to ensure your final CSS file remains lean and performant.&lt;/p&gt;

&lt;p&gt;Feel free to look over the &lt;a href="https://tailwindcss.com/docs/content-configuration#safelisting-classes" rel="noopener noreferrer"&gt;Tailwind Docs on Safelist&lt;/a&gt; usage.&lt;/p&gt;

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