DEV Community

Cover image for How we generate code screenshots for socials
Accreditly
Accreditly

Posted on

How we generate code screenshots for socials

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.

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 Code Screenshot Generator, 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.

Why screenshotting the editor does not scale

Grabbing the editor window is fine for one image. The trouble starts when you do it repeatedly.

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.

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.

The quick route: a browser tool for one-offs

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 online code screenshot tool. Paste the snippet, pick the language, and you get a clean image back.

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.

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

The repeatable route: a code screenshot API

When the image needs to be produced by a machine, at publish time, in CI, or off a CMS hook, we use the Code Screenshot API. 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.

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 curl:

curl -X POST https://app.html2img.com/api/v1/templates/code-screenshot \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "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<User>;\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"
  }'
Enter fullscreen mode Exit fullscreen mode

The fields map onto what you would set in the browser tool. code is the snippet, with newlines escaped in the JSON string. language drives the highlighting. title is the filename shown in the window bar. theme, background, and padding are the styling, and the two show_ flags toggle the window chrome and line numbers. Only code 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 Code Screenshot template reference.

By default the template renders a 1600 by 1000 PNG, which you can override with width and height. A successful call returns JSON:

{
  "success": true,
  "id": "abc123",
  "url": "https://i.html2img.com/abc123.png",
  "credits_remaining": 1234,
  "template": "code-screenshot"
}
Enter fullscreen mode Exit fullscreen mode

The url 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.

Wiring it into the publishing flow

In practice you do not run curl, 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:

async function codeScreenshot(code, language, title) {
  const res = await fetch('https://app.html2img.com/api/v1/templates/code-screenshot', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.HTML2IMG_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      code,
      language,
      title,
      theme: 'atom-one-dark',
      background: 'linear-gradient(135deg, #6366F1 0%, #8B5CF6 50%, #EC4899 100%)',
      padding: 72,
      show_window_chrome: 'true',
      show_line_numbers: 'false',
    }),
  });

  if (!res.ok) {
    throw new Error(`code-screenshot render failed: ${res.status}`);
  }

  const { url } = await res.json();
  return url;
}
Enter fullscreen mode Exit fullscreen mode

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.

We mostly publish from Laravel, and the shape is identical there. The HTTP client does the work:

use Illuminate\Support\Facades\Http;

$url = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
    ->post('https://app.html2img.com/api/v1/templates/code-screenshot', [
        'code' => $snippet,
        'language' => 'php',
        'title' => 'app/Actions/RenderSnippet.php',
        'theme' => 'atom-one-dark',
        'show_window_chrome' => 'true',
    ])
    ->throw()
    ->json('url');
Enter fullscreen mode Exit fullscreen mode

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.

Getting the screenshots to actually read on social

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.

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.

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.

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.

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.

Which one we reach for

The split is simple. One-offs, and anything where you are already away from the editor, go through the code screenshot generator in the browser. Anything repeatable, or that has to happen at publish time, goes through the code screenshot template and its API. Because both run the same renderer, the two never drift apart, which was the original problem we set out to fix.

If you want to go deeper, the Code Screenshot API reference lists every input, including line numbers, diff highlighting by setting the language to diff, and custom themes via a Prism variables block.

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.

Top comments (0)