How to Convert HTML to an Image in Express
Build an Express endpoint that renders HTML with Puppeteer and returns a PNG, JPEG, or WebP, with production options, errors, and an API alternative.
Direct answer: use Express as the HTTP layer and Puppeteer as the browser renderer. Accept an HTML string, load it with page.setContent(), set the viewport, call page.screenshot(), and send the returned bytes with an image content type. Use page.goto() instead when the source is an existing URL.
1. Create an Express screenshot endpoint
Install Express and Puppeteer:
npm install express puppeteer
Create server.js:
import express from 'express';
import puppeteer from 'puppeteer';
const app = express();
// Accept raw HTML in the request body. Keep a limit appropriate for your service.
app.use(express.text({ type: 'text/html', limit: '1mb' }));
app.post('/image', async (req, res, next) => {
let browser;
try {
if (typeof req.body !== 'string' || req.body.trim() === '') {
return res.status(400).json({ error: 'Send an HTML document with Content-Type: text/html' });
}
browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({
width: 1200,
height: 800,
deviceScaleFactor: 1
});
await page.setContent(req.body);
const image = await page.screenshot({
type: 'png',
fullPage: true
});
res.type('png').send(image);
} catch (error) {
next(error);
} finally {
await browser?.close();
}
});
app.use((error, req, res, next) => {
console.error(error);
if (res.headersSent) return next(error);
res.status(500).json({ error: 'Screenshot failed' });
});
app.listen(3000, () => {
console.log('Screenshot server listening on http://localhost:3000');
});
With an ES module project, add "type": "module" to package.json. Start it with node server.js.
Send HTML with cURL
curl -X POST http://localhost:3000/image \
-H 'Content-Type: text/html' \
--data '<!doctype html><html><body><h1>Invoice</h1><p>Ready to download</p></body></html>' \
--output invoice.png
Call the endpoint from Python
import requests
html = """<!doctype html>
<html>
<body>
<h1>Report</h1>
<p>Rendered by Express and Puppeteer.</p>
</body>
</html>"""
response = requests.post(
"http://localhost:3000/image",
data=html.encode("utf-8"),
headers={"Content-Type": "text/html"},
timeout=90,
)
response.raise_for_status()
open("report.png", "wb").write(response.content)
Call the endpoint from Node.js
const html = `<!doctype html>
<html>
<body>
<h1>Report</h1>
<p>Rendered by Express and Puppeteer.</p>
</body>
</html>`;
const response = await fetch('http://localhost:3000/image', {
method: 'POST',
headers: { 'Content-Type': 'text/html' },
body: html
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const image = Buffer.from(await response.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('report.png', image));
Puppeteer documents setContent(), viewport configuration, and screenshot output in its Page API and screenshot API. Express supplies the route and request parsing layer; the browser performs the rendering.
2. Choose HTML input or a URL
Use page.setContent(html) when your application creates the markup. Use page.goto(url) when another server already hosts the page:
await page.goto('https://example.com/invoice/123', {
waitUntil: 'networkidle0',
timeout: 30_000
});
const image = await page.screenshot({ type: 'png', fullPage: true });
For HTML strings, external stylesheets, images, and fonts must be reachable from the browser process. Relative URLs resolve against the document URL, so a supplied HTML fragment may need absolute asset URLs or a <base href="..."> element.
3. Control the capture area and format
Viewport versus full page
// Visible viewport only
const image = await page.screenshot({ type: 'png' });
// Everything from the top to the bottom of the document
const image = await page.screenshot({ type: 'png', fullPage: true });
A viewport screenshot is predictable for cards, dashboards, and social images. A full-page screenshot includes content below the fold and can become very tall. Puppeteer exposes fullPage; Playwright documents the same viewport, element, and full-page choices in its screenshot guide.
Capture one element
const element = await page.$('.invoice');
if (!element) throw new Error('Element .invoice was not found');
const image = await element.screenshot({ type: 'png' });
This is usually simpler than calculating a clip rectangle. For a manual rectangle, use Puppeteer’s clip option:
const image = await page.screenshot({
type: 'png',
clip: { x: 40, y: 80, width: 800, height: 500 }
});
PNG, JPEG, WebP, and transparency
const png = await page.screenshot({ type: 'png' });
const jpeg = await page.screenshot({ type: 'jpeg', quality: 85 });
const webp = await page.screenshot({ type: 'webp', quality: 80 });
const transparentPng = await page.screenshot({ type: 'png', omitBackground: true });
PNG is the default and preserves sharp text. JPEG is smaller for photographic content; its quality option does not apply to PNG. WebP is useful when your consumers support it. omitBackground makes the page background transparent where the format and downstream pipeline support alpha.
Set the response type to match the bytes:
res.type('png').send(image);
// or
res.type('jpeg').send(image);
// or
res.type('webp').send(image);
4. Wait for the page to be ready
setContent() resolves after the document is set, but your application may still need to wait for fonts, images, or client-side rendering.
await page.setContent(html);
await page.evaluate(() => document.fonts.ready);
await page.waitForSelector('.chart-ready', { timeout: 10_000 });
await page.waitForNetworkIdle({ idleTime: 500, timeout: 10_000 });
Use a specific readiness selector when possible. A fixed delay is less reliable, but can cover a component with no observable ready state:
await new Promise(resolve => setTimeout(resolve, 500));
For lazy-loaded images, scroll before capturing:
await page.evaluate(async () => {
window.scrollTo(0, document.body.scrollHeight);
await new Promise(resolve => setTimeout(resolve, 200));
window.scrollTo(0, 0);
});
5. Set dimensions and rendering details
await page.setViewport({
width: 1440,
height: 900,
deviceScaleFactor: 2,
isMobile: false,
hasTouch: false
});
The viewport controls layout; deviceScaleFactor controls pixel density. A factor of 2 produces a sharper image with roughly twice the pixels in each dimension. Set the viewport before loading content because some viewport changes can reload the page. Puppeteer documents this behavior in its viewport API.
You can inject CSS and JavaScript before the screenshot:
await page.addStyleTag({
content: `
* { animation: none !important; transition: none !important; }
body { margin: 0; }
`
});
await page.evaluate(() => {
document.querySelectorAll('.print-only').forEach(node => node.remove());
});
For an HTML string, include a complete document when possible so viewport styles, fonts, and resets behave consistently.
6. A reusable browser lifecycle
The basic example launches and closes Chromium for every request, which makes the lifecycle easy to understand. A service receiving many requests can launch one browser at startup and create a fresh page per request:
import express from 'express';
import puppeteer from 'puppeteer';
const app = express();
app.use(express.text({ type: 'text/html', limit: '1mb' }));
const browser = await puppeteer.launch();
app.post('/image', async (req, res, next) => {
let page;
try {
page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800 });
await page.setContent(req.body, { waitUntil: 'networkidle0' });
const image = await page.screenshot({ type: 'png', fullPage: true });
res.type('png').send(image);
} catch (error) {
next(error);
} finally {
await page?.close();
}
});
const server = app.listen(3000);
const shutdown = async () => {
server.close();
await browser.close();
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
Reuse must be paired with isolation: close each page, cap concurrent work, and avoid sharing mutable page state between requests. The right concurrency and memory limits depend on your deployment environment; measure them in the environment you operate.
7. Security and input boundaries
- Limit request size with Express body-parser options.
- Authenticate the endpoint before rendering untrusted HTML.
- Consider disabling or restricting outbound requests if users can submit arbitrary markup.
- Do not pass untrusted values into shell commands or browser launch arguments.
- Set request and navigation timeouts so a page cannot occupy a worker forever.
- Decide whether scripts are allowed. Client-side charts need scripts; untrusted scripts increase the isolation requirements.
If users submit arbitrary URLs, treat the renderer as a network client and review access to private network addresses before deploying it.
8. PDF is a separate output
page.pdf() generates a PDF and follows print CSS by default. It is not an image conversion method. To print using screen styles, call page.emulateMediaType('screen') first. See Puppeteer’s PDF API for the separate options.
9. Puppeteer or Playwright?
Both provide documented Node.js browser automation and screenshot support. Choose based on the browser engines, API style, and deployment environment your application needs. For this Express pattern, the actionable differences are:
| Decision | Puppeteer | Playwright |
|---|---|---|
| HTML input | page.setContent() |
Page content APIs are available |
| URL input | page.goto() |
page.goto() |
| Capture scope | Viewport, fullPage, element, or clip |
Viewport, full page, and element screenshots |
| Bytes | PNG by default; JPEG/WebP and base64 options are available | PNG, JPEG, and WebP choices are documented |
| Operational choice | Match the browser package and hosting setup you already operate | Match the browser package and hosting setup you already operate |
The reviewed documentation establishes screenshot capability and options. It does not establish a universal speed, memory, or maintenance winner, so benchmark your own pages and deployment.
10. Or skip the browser setup
ScreenshotNeo provides a hosted screenshot API. One GET request returns a PNG, JPEG, WebP, or PDF. The API also supports full-page capture, element selectors, custom CSS and JavaScript, waits, device presets, cookies, headers, geolocation, caching, asynchronous jobs, bulk capture, and signed links. See the ScreenshotNeo API documentation for the complete option list.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. An MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account and start with 1,000 screenshots per month at no charge.
11. Troubleshooting
Chromium fails to launch
Cause: the runtime lacks browser dependencies, or the deployment environment blocks the bundled browser. Fix: install the dependencies required by your hosting image, verify the Puppeteer browser installation, and use the launch configuration required by that environment. Do not copy launch flags without checking their security and hosting implications.
The image is blank
Cause: the page was captured before client-side rendering completed, or the HTML has no visible content. Fix: wait for a known selector, wait for fonts, and verify the HTML independently in a browser.
Images or fonts are missing
Cause: relative URLs, blocked requests, CORS behavior, or a capture taken before resources finished loading. Fix: use absolute asset URLs, make the assets reachable from the renderer, and wait for the relevant resource or selector.
The page is cut off
Cause: a viewport screenshot was used when the document extends below the fold. Fix: set fullPage: true, or capture the target element instead.
The output is blurry
Cause: a low device scale factor or a small viewport that is enlarged later. Fix: increase deviceScaleFactor, capture at the intended dimensions, and avoid unnecessary upscaling.
The endpoint hangs
Cause: a navigation, resource, or application script never finishes. Fix: configure navigation and wait timeouts, use a specific readiness selector instead of waiting forever for network idle, and close the page in finally.
Requests consume too much memory
Cause: launching a browser for every request, unbounded concurrency, or very tall full-page captures. Fix: reuse a browser when appropriate, cap concurrent pages, close pages promptly, and constrain image dimensions and request size.
12. Performance, reliability, and cost notes
- Launching Chromium is heavier than reusing an already-running browser, but reuse requires strict page cleanup and concurrency limits.
- Full-page and high-device-scale captures create more pixels and usually need more memory than viewport captures.
- Readiness selectors make output more deterministic than arbitrary delays.
- Cache identical inputs when your content allows it, and include viewport, format, and relevant CSS in the cache key.
- Return a clear 4xx response for invalid HTML requests and a 5xx response for renderer failures.
- Set an upper bound on HTML size, navigation time, and output dimensions.
- For a hosted API, review the provider’s response status and billing metadata so failed or non-page results are handled explicitly.
13. FAQ
Can Express convert HTML without a browser?
Express alone does not lay out HTML like a browser. Use a browser engine such as Puppeteer or Playwright, or call a hosted rendering API.
Can I return base64 instead of binary bytes?
Yes. Puppeteer supports a base64 screenshot encoding option. Binary responses are usually smaller and can be sent directly with the correct content type.
Should I use setContent() or goto()?
Use setContent() for HTML you have in memory and goto() for an existing URL.
How do I capture a chart rendered by JavaScript?
Wait for the chart’s own ready selector or state, then capture. A fixed delay alone can produce intermittent images.
Can the same endpoint return JPEG and PNG?
Yes. Accept a format parameter that you validate against an allowlist, pass it to page.screenshot(), and set the matching Express response type.


