HTML to image
Send your own HTML and get back a PNG, JPEG, WebP or PDF. This is how most teams build social cards, certificates, receipts, charts for emails and other images from a template. You keep full control of the markup, and the browser does the layout. The HTML to image API page covers the use cases; this guide covers the details that make the output correct. It is part of the capture guides.
The minimal request
Use POST with a JSON body. The viewport is the canvas, so set it to the image size you want. You need an API key in RS_API_KEY; a free account includes one.
curl -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "html": "<!doctype html><html><body style=\"margin:0;width:1200px;height:630px;display:grid;place-items:center;background:#0f172a;color:#fff;font:700 64px system-ui\">Hello from HTML</body></html>", "viewport": { "width": 1200, "height": 630 }, "output": { "format": "png" } }' \ -o card.png
GET also accepts html as a query parameter. It is handy for a quick test in a terminal, and nothing else:
curl -G "https://api.renderscreenshot.com/v1/screenshot" \ -H "Authorization: Bearer $RS_API_KEY" \ --data-urlencode "html=<h1 style='font:48px system-ui'>Quick test</h1>" \ -d width=800 -d height=200 \ -o test.png
Parameters that matter
| Parameter | Default | Notes |
|---|---|---|
html |
none | A full document (<!doctype html>...) gives the most predictable result. One of url, html or markdown is required. |
html_base_url |
none | Base address for relative links, images, stylesheets and scripts in your HTML. |
viewport.width, viewport.height |
1280, 800 |
The image size in CSS pixels. Width 1 to 7,680, height 1 to 4,320. |
viewport.scale |
1 |
1 to 3. 2 gives a 2400 x 1260 file for a 1200 x 630 card. |
output.format |
png |
png, jpeg, webp or pdf. |
output.transparent |
false |
Transparent background, PNG and WebP only. |
wait.until |
load |
Use networkidle if your HTML fetches data or fonts from JavaScript. |
wait.timeout |
30 |
Seconds, maximum 60. |
Relative URLs and htmlbaseurl
HTML that you send has no address of its own. A tag such as <img src="/img/logo.svg"> has nothing to resolve against and the image is missing. Fix it one of three ways:
html_base_url: set it to the site that hosts your assets.
{ "html": "<!doctype html><html><head><link rel=\"stylesheet\" href=\"/css/card.css\"></head><body><img src=\"/img/logo.svg\"></body></html>", "html_base_url": "https://assets.example.com", "viewport": { "width": 1200, "height": 630 } }
A
<base>element at the top of<head>, which keeps the template self-describing:<base href="https://assets.example.com/">.Absolute URLs everywhere. This is the most explicit choice and works with any tool that renders your template, including a local preview.
The assets must be reachable from the public internet. The renderer refuses addresses on private networks, such as localhost or 10.x.x.x: the asset does not load, or the whole request fails with a target_error. During development, inline small assets as data: URIs or upload them to a public bucket.
Web fonts that load in time
Fonts are the most common reason an HTML render looks different from your browser. Three rules:
- Load fonts with a
<link>or@font-facein the document, not from JavaScript, so the browser requests them during the first load. - Use
font-display: blockin your own@font-facerules (or&display=blockon a Google Fonts URL). Withswap, the browser paints the fallback font at once and swaps later, so an early capture shows the fallback. - Wait for
loadat least. In our local test of a stylesheet that arrived after 300 ms and a font that arrived 800 ms later,domcontentloadedreturned while the font was still loading.loadreturned after the font was loaded. If a script adds the text, wait for a ready flag, as shown in wait strategies.
Do not test for fonts with document.fonts.check() alone. It returns true when no matching font face is declared yet, for example while the stylesheet that declares it is still downloading. Wait for document.fonts.ready after the stylesheet has loaded instead.
For full control, embed the font as a data: URI inside @font-face. The render then makes no font request at all, and the output is the same every time.
Escape everything you insert
A template becomes HTML injection the moment you paste user input into it. A title such as <img src=x onerror=...> would run in the renderer, and a stray < breaks the layout. Escape every value before you insert it.
// build-card.mjs: turn untrusted strings into a safe HTML template. const escapeHtml = (s) => String(s) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); export function cardHtml({ title, author }) { return `<!doctype html> <html> <head> <meta charset="utf-8"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=block" rel="stylesheet"> <style> html, body { margin: 0; width: 1200px; height: 630px; } body { display: flex; flex-direction: column; justify-content: center; padding: 0 80px; box-sizing: border-box; background: #0f172a; color: #f8fafc; font-family: Inter, sans-serif; } h1 { font-size: 64px; line-height: 1.1; margin: 0 0 24px; } p { font-size: 28px; margin: 0; color: #94a3b8; } </style> </head> <body> <h1>${escapeHtml(title)}</h1> <p>${escapeHtml(author)}</p> </body> </html>`; }
In Python, html.escape(value) does the same job (it escapes quotes by default). In Ruby use ERB::Util.html_escape, in PHP htmlspecialchars($value, ENT_QUOTES).
Tested on 2026-10-15 with Node 22.18: the title <img src=x onerror=alert(1)> Q&A "2026" came out as <img src=x onerror=alert(1)> Q&A "2026", and the whole JSON request body for this card was 868 bytes.
Two more rules:
- Build the request body with a JSON encoder (
JSON.stringify,json.dumps,to_json). Never concatenate HTML into a JSON string by hand; one unescaped quote breaks the request with avalidation_error. - Escape for the context. HTML escaping is right for text between tags and inside quoted attributes. It is not enough inside
<script>,styleattributes orhrefvalues. Keep user values out of those places.
Keep the payload small
The API does not publish a fixed maximum for html, but a small body renders faster and is easier to debug:
- Reference large images by URL instead of inlining them as base64. Base64 adds about a third to the size.
- Keep CSS to what the template uses. A full CSS framework build is often larger than the rest of the template.
- Use POST for anything longer than a test string. Many proxies and servers limit the length of a URL, and URLs are written to access logs.
SDK examples
Node.js
import { writeFile } from 'node:fs/promises'; import { Client, TakeOptions } from 'renderscreenshot'; import { cardHtml } from './build-card.mjs'; const client = new Client(process.env.RS_API_KEY); const image = await client.take( TakeOptions.html(cardHtml({ title: 'Release 2.0', author: 'Simon Chiu' })) .width(1200) .height(630) .format('png') ); await writeFile('card.png', image);
Python
import html import os from renderscreenshot import Client, TakeOptions client = Client(os.environ['RS_API_KEY']) title = html.escape('Release 2.0') image = client.take( TakeOptions.html(f'<!doctype html><body style="margin:0"><h1>{title}</h1></body>') .width(1200) .height(630) .format('png') ) with open('card.png', 'wb') as f: f.write(image)
Ruby
require 'erb' require 'renderscreenshot' client = RenderScreenshot.client(ENV.fetch('RS_API_KEY')) title = ERB::Util.html_escape('Release 2.0') image = client.take( RenderScreenshot::TakeOptions .html("<!doctype html><body style=\"margin:0\"><h1>#{title}</h1></body>") .width(1200) .height(630) .format('png') ) File.binwrite('card.png', image)
PHP
use RenderScreenshot\Client; use RenderScreenshot\TakeOptions; $client = new Client(getenv('RS_API_KEY')); $title = htmlspecialchars('Release 2.0', ENT_QUOTES); $image = $client->take( TakeOptions::html("<!doctype html><body style=\"margin:0\"><h1>{$title}</h1></body>") ->width(1200) ->height(630) ->format('png') ); file_put_contents('card.png', $image);
Java
// Escape user values before you build the string, for example with // org.apache.commons.text.StringEscapeUtils.escapeHtml4(title) String html = "<!doctype html><body style=\"margin:0\"><h1>" + safeTitle + "</h1></body>"; byte[] image = client.take( TakeOptions.html(html) .width(1200) .height(630) .format("png") ); Files.write(Path.of("card.png"), image);
Go
title := html.EscapeString("Release 2.0") // import "html" image, err := client.Take(ctx, rs.HTML(`<!doctype html><body style="margin:0"><h1>`+title+`</h1></body>`). Width(1200). Height(630). Format(rs.FormatPNG), ) if err != nil { log.Fatal(err) } os.WriteFile("card.png", image, 0o644)
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Images or CSS missing | Relative paths with no base | Set html_base_url, add <base href>, or use absolute URLs |
| Asset loads in your browser but not in the render, or the request fails | It is on localhost or a private network |
Host it publicly or inline it as a data: URI |
| Fallback font in the image | Capture before the font loaded, or font-display: swap |
Use font-display: block and wait.until: load; inline the font for certainty |
| White border around the design | Default body margin |
Add margin: 0 to html, body |
| Content cut off at the bottom | Viewport smaller than the design | Match viewport.height, or use capture.mode: full_page |
Text such as & shows literally |
Value escaped twice | Escape once, at the point where you insert it into HTML |
validation_error before rendering |
Hand-built JSON with an unescaped quote | Build the body with a JSON encoder |
| Blurry on phones | 1x output | Set viewport.scale: 2 |
Related
- Content input reference for
url,htmlandmarkdown. - Transparent backgrounds for badges and stickers.
- Security best practices if users control any part of the template.