The Quickest Way (REST API)
If you just need a URL→image and don't want to manage headless browsers, a single HTTP call does it:
// Node.js — URL to PNG in 4 lines
const res = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: { 'X-Api-Key': 'YOUR_KEY', 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com', full_page: true })
});
const { screenshot } = await res.json();
require('fs').writeFileSync('out.png', Buffer.from(screenshot, 'base64'));
# Python — URL to PNG in 4 lines
import requests, base64
r = requests.post('https://api.snapapi.pics/v1/screenshot',
headers={'X-Api-Key': 'YOUR_KEY'},
json={'url': 'https://example.com', 'full_page': True})
open('out.png', 'wb').write(base64.b64decode(r.json()['screenshot']))
The rest of this post covers self-hosted options for when you need full control.
Playwright (Node.js)
const { chromium } = require('playwright');
const fs = require('fs');
async function urlToImage(url, outputPath, options = {}) {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
viewport: { width: options.width || 1280, height: options.height || 800 }
});
// Disable animations for clean capture
await page.addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' });
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
const screenshot = await page.screenshot({
fullPage: options.fullPage !== false,
type: options.format || 'png',
quality: options.format === 'jpeg' ? (options.quality || 85) : undefined
});
fs.writeFileSync(outputPath, screenshot);
await browser.close();
return screenshot;
}
// Usage
urlToImage('https://news.ycombinator.com', 'hn.png');
urlToImage('https://example.com', 'example.jpg', { format: 'jpeg', quality: 90 });
Playwright (Python)
from playwright.sync_api import sync_playwright
def url_to_image(url: str, output_path: str, full_page: bool = True, width: int = 1280) -> bytes:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': width, 'height': 800})
# Remove animations
page.add_style_tag(content='*, *::before, *::after { animation-duration: 0s !important; }')
page.goto(url, wait_until='networkidle')
buf = page.screenshot(full_page=full_page)
browser.close()
with open(output_path, 'wb') as f:
f.write(buf)
return buf
url_to_image('https://example.com', 'out.png')
Puppeteer (Node.js)
const puppeteer = require('puppeteer');
async function urlToImage(url, outputPath) {
const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(url, { waitUntil: 'networkidle2' });
// Resize to full page height
const height = await page.evaluate(() => document.body.scrollHeight);
await page.setViewport({ width: 1280, height });
await page.screenshot({ path: outputPath, fullPage: true });
await browser.close();
}
urlToImage('https://example.com', 'output.png');
Output Format Options
| Format | Typical size | Use when |
|---|---|---|
| PNG | 500KB–3MB | Pixel-perfect, transparency, text clarity |
| JPEG | 80KB–500KB | Photos, thumbnails, bandwidth-sensitive |
| WebP | 50KB–300KB | Modern browsers, best size/quality ratio |
| 100KB–2MB | Print, archival, multi-page documents |
// SnapAPI — specify output format
const body = {
url: 'https://example.com',
full_page: true,
format: 'webp' // 'png' | 'jpeg' | 'webp'
};
// For JPEG, add quality parameter:
// format: 'jpeg', quality: 85 // 1-100
Handling Single-Page Applications (SPAs)
React, Vue, Next.js, and Angular apps load content via JavaScript after the initial HTML. If you capture too early, you get a blank or partial page.
// Playwright — wait for SPA to finish rendering
await page.goto(url, { waitUntil: 'networkidle' });
// Or wait for a specific element that only appears after data loads
await page.waitForSelector('[data-loaded="true"]', { timeout: 10000 });
// Or wait for a specific network request to complete
await page.waitForResponse(res => res.url().includes('/api/data') && res.status() === 200);
const screenshot = await page.screenshot({ fullPage: true });
// SnapAPI — networkidle handles SPAs automatically
const body = {
url: 'https://react-app.example.com',
wait_for: 'networkidle', // Waits for all XHR/fetch to finish
full_page: true
};
Bypassing Cloudflare and Bot Detection
Many sites use Cloudflare, Akamai Bot Manager, or reCAPTCHA. A plain headless browser will get a 403 or CAPTCHA page instead of the actual content.
# Playwright — basic stealth (not foolproof)
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
args=[
'--disable-blink-features=AutomationControlled',
'--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
]
)
context = browser.new_context(
user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
)
page = context.new_page()
# Remove webdriver flag
page.add_init_script('delete Object.getPrototypeOf(navigator).webDriver')
page.goto('https://cloudflare-protected-site.com', wait_until='networkidle')
page.screenshot(path='out.png', full_page=True)
browser.close()
Note: DIY stealth is a constant arms race. Cloudflare updates its detection regularly. SnapAPI's stealth mode is maintained and updated — use
"stealth": true in the API request for consistently bypassed captures.
URL-to-Image API Endpoint (Express)
// GET /screenshot?url=https://example.com
const express = require('express');
const app = express();
app.get('/screenshot', async (req, res) => {
const { url, format = 'png', fullPage = 'true' } = req.query;
if (!url) return res.status(400).json({ error: 'url required' });
const response = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ url, format, full_page: fullPage === 'true', wait_for: 'networkidle' })
});
const data = await response.json();
const buf = Buffer.from(data.screenshot, 'base64');
res.set('Content-Type', `image/${format}`);
res.set('Content-Disposition', `inline; filename="screenshot.${format}"`);
res.set('Cache-Control', 'public, max-age=3600');
res.send(buf);
});
app.listen(3000, () => console.log('Screenshot service on :3000'));
// Usage: GET http://localhost:3000/screenshot?url=https://example.com
Bulk URL-to-Image Conversion
import asyncio, aiohttp, base64, os
SNAPAPI_KEY = os.environ['SNAPAPI_KEY']
URLS = [
'https://example.com',
'https://news.ycombinator.com',
'https://github.com/trending',
]
async def capture(session: aiohttp.ClientSession, url: str, idx: int) -> None:
async with session.post(
'https://api.snapapi.pics/v1/screenshot',
headers={'X-Api-Key': SNAPAPI_KEY},
json={'url': url, 'full_page': True, 'wait_for': 'networkidle'}
) as resp:
data = await resp.json()
with open(f'output_{idx:03d}.png', 'wb') as f:
f.write(base64.b64decode(data['screenshot']))
print(f'✅ {url}')
async def bulk_convert(urls: list[str], concurrency: int = 5) -> None:
semaphore = asyncio.Semaphore(concurrency)
async with aiohttp.ClientSession() as session:
async def bounded(url, idx):
async with semaphore:
return await capture(session, url, idx)
await asyncio.gather(*[bounded(u, i) for i, u in enumerate(urls)])
asyncio.run(bulk_convert(URLS))
Free to start: SnapAPI gives you 200 free URL-to-image conversions per month. No credit card. Get your key →
Method Comparison
| Method | Setup | SPA support | Anti-bot | Serverless | Cost |
|---|---|---|---|---|---|
| Playwright (self) | Medium | ✅ | Partial | ⚠️ tricky | Free |
| Puppeteer (self) | Medium | ✅ | Partial | ⚠️ tricky | Free |
| Selenium (self) | Heavy | ⚠️ manual | Partial | ❌ | Free |
| SnapAPI REST | Minimal | ✅ | ✅ stealth | ✅ | $0.0016/call |