- Why an image instead of a gist or an embed
- What a good code image needs
- Highlight the code first
- Wrap it in chrome and render to a PNG
- Automate it on every release
- Render once, then reuse
You want a clean, syntax-highlighted picture of a code snippet for a changelog, a release post, or your docs. Pasting into a web tool and downloading the PNG by hand works once, but it does not scale and it cannot run in your pipeline. This article shows how to generate code screenshots from source with an API, and how to automate them so a release can post its own snippet.
Why an image instead of a gist or an embed
A code image works in places a live embed cannot. It renders in a tweet, a LinkedIn post, an email, a slide, and a README, none of which will run an iframe or a script. It also gives you one consistent look across all of those, set by you rather than by each platform's own rendering.
The trade-off is real and worth stating: an image is not selectable, so nobody can copy the code out of it. Use images where the code is illustrative and pair them with a link to the real source when the reader needs to copy it. For a changelog entry or a launch post, illustrative is exactly what you want.
What a good code image needs
Keep the list short and it will read well at small sizes: a monospaced font, real syntax highlighting rather than flat text, a little window chrome so it looks intentional, generous padding, and a theme with enough contrast. Everything else is decoration.
Highlight the code first
The renderer draws HTML, so the highlighting has to already be in the HTML before you send it. Do that server-side with a highlighter like Shiki, which produces styled markup with the colours inlined, so you do not have to ship a stylesheet.
import { codeToHtml } from 'shiki'
async function highlight(code, lang = 'ts') {
return codeToHtml(code, {
lang,
theme: 'github-dark',
})
}
Shiki returns a <pre class="shiki">...</pre> block with each token wrapped in a span that carries its own colour. That block is the core of your image; the rest is framing.
Wrap it in chrome and render to a PNG
Now build the surrounding HTML, drop the highlighted block inside it, and POST the whole thing to the rendering API. It returns a URL to the finished PNG.
async function renderCodeImage(highlightedHtml) {
const html = `<!doctype html><html><head><meta charset="utf-8"><style>
* { margin: 0; box-sizing: border-box; }
body { background: #0d1117; padding: 56px; font-family: ui-monospace, monospace; }
.window { background: #161b22; border-radius: 14px; overflow: hidden;
box-shadow: 0 24px 60px rgba(0,0,0,.5); }
.bar { display: flex; gap: 9px; padding: 16px 18px; background: #21262d; }
.bar i { width: 13px; height: 13px; border-radius: 50%; }
.r { background: #ff5f56; } .y { background: #ffbd2e; } .g { background: #27c93f; }
.shiki { padding: 26px 30px; font-size: 22px; line-height: 1.6; }
</style></head><body>
<div class="window">
<div class="bar"><i class="r"></i><i class="y"></i><i class="g"></i></div>
${highlightedHtml}
</div>
</body></html>`
const res = await fetch('https://app.html2img.com/api/html', {
method: 'POST',
headers: {
'X-API-Key': process.env.HTML2IMG_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ html, width: 1000, height: 0 }),
})
if (!res.ok) throw new Error(`Render failed: ${res.status}`)
return (await res.json()).url
}
Setting height: 0 lets the image size to the content, so a short snippet does not sit in a tall empty box. If you would rather not build and maintain the window chrome yourself, HTML to Image has a named code screenshot template that takes the code, language and theme as a JSON payload and returns the styled PNG. You can also try the design in the browser first with the code screenshot generator before wiring the API into your code.
Put the pieces together and one function turns a string of code into a hosted image:
const html = await highlight(source, 'ts')
const url = await renderCodeImage(html)
console.log(url) // https://i.html2img.com/....png
Automate it on every release
The point of doing this in code is that a machine can now do it. Generate the image in CI when you cut a release, then attach it to the release or post it. Here is a GitHub Actions step that renders a snippet and saves the URL for later steps to use.
# .github/workflows/release-image.yml
on:
release:
types: [published]
jobs:
code-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Render release snippet
env:
HTML2IMG_KEY: ${{ secrets.HTML2IMG_KEY }}
run: node scripts/render-snippet.mjs "examples/usage.ts" >> "$GITHUB_OUTPUT"
The render-snippet.mjs script reads a file, highlights it, renders it, and prints url=... so the workflow can hand the image to whatever posts your announcements. If your builds are slow to install dependencies, caching npm on GitHub Actions keeps this step quick.
Render once, then reuse
Rendering is the only part that costs anything, so do not repeat it for identical input. Hash the code, language, theme and dimensions together, and use that hash as a cache key. If you have generated an image for that exact hash before, return the stored URL instead of calling the API again.
import { createHash } from 'node:crypto'
const key = createHash('sha256')
.update(JSON.stringify({ source, lang, theme, width }))
.digest('hex')
A snippet that never changes gets rendered once in its life, and every changelog rebuild after that reads the cached URL. This is the same principle behind rendering URL screenshots from Node.js: the render is the expensive step, so cache it and the cost stops mattering.
That is the full path from source to shareable image: highlight it, frame it, render it, and let your release pipeline do it for you. The HTML to Image docs cover DPI control if you want the images sharp on retina screens, and webhook delivery if a render is slow enough that you would rather not wait on the response.