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:

  1. 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 }
}
  1. A <base> element at the top of <head>, which keeps the template self-describing: <base href="https://assets.example.com/">.

  2. 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-face in the document, not from JavaScript, so the browser requests them during the first load.
  • Use font-display: block in your own @font-face rules (or &display=block on a Google Fonts URL). With swap, the browser paints the fallback font at once and swaps later, so an early capture shows the fallback.
  • Wait for load at least. In our local test of a stylesheet that arrived after 300 ms and a font that arrived 800 ms later, domcontentloaded returned while the font was still loading. load returned 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('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');

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 &lt;img src=x onerror=alert(1)&gt; Q&amp;A &quot;2026&quot;, 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 a validation_error.
  • Escape for the context. HTML escaping is right for text between tags and inside quoted attributes. It is not enough inside <script>, style attributes or href values. 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 &amp; 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 in this topic

Was this page helpful?