How to Take Screenshots with Express.js Using a Screenshot API
Express.js is the foundation of the Node.js web ecosystem. If you're building an Express app that needs to capture web page screenshots — for monitoring dashboards, PDF generation, visual testing, or social preview images — a screenshot API handles the headless browser complexity so you don't have to. This guide covers the full range of Express integration patterns.
Setup
Install dependencies:
npm install express axios
npm install --save-dev @types/express @types/node typescript
Get a free API key at hermesforge.dev/screenshot. Set it in your environment:
export SCREENSHOT_API_KEY=your_api_key_here
Basic Route Handler
A minimal Express route that proxies screenshot requests:
// routes/screenshot.js
const express = require('express')
const axios = require('axios')
const router = express.Router()
const API_KEY = process.env.SCREENSHOT_API_KEY
const API_BASE = 'https://hermesforge.dev/api/screenshot'
router.get('/screenshot', async (req, res) => {
const { url, width = 1280, height = 800, format = 'webp' } = req.query
if (!url) {
return res.status(400).json({ error: 'url parameter is required' })
}
try {
const params = new URLSearchParams({ url, width, height, format })
const response = await axios.get(`${API_BASE}?${params}`, {
headers: { 'X-API-Key': API_KEY },
responseType: 'arraybuffer',
})
const contentType = response.headers['content-type'] || 'image/webp'
res.set('Content-Type', contentType)
res.set('Cache-Control', 'public, max-age=300') // 5-minute cache
res.send(response.data)
} catch (err) {
const status = err.response?.status || 500
res.status(status).json({ error: 'Screenshot failed', detail: err.message })
}
})
module.exports = router
Register in your main app:
// app.js
const express = require('express')
const screenshotRouter = require('./routes/screenshot')
const app = express()
app.use('/api', screenshotRouter)
app.listen(3000, () => console.log('Running on :3000'))
Test it:
curl "http://localhost:3000/api/screenshot?url=https://example.com" --output example.webp
Streaming Response
For large screenshots, stream directly to the client instead of buffering in memory:
router.get('/screenshot/stream', async (req, res) => {
const { url } = req.query
if (!url) return res.status(400).json({ error: 'url required' })
try {
const params = new URLSearchParams({
url,
format: 'webp',
width: req.query.width || '1280',
height: req.query.height || '800',
})
const response = await axios.get(
`https://hermesforge.dev/api/screenshot?${params}`,
{
headers: { 'X-API-Key': process.env.SCREENSHOT_API_KEY },
responseType: 'stream', // Stream from upstream to client
}
)
res.set('Content-Type', 'image/webp')
res.set('Cache-Control', 'public, max-age=300')
response.data.pipe(res)
response.data.on('error', (err) => {
if (!res.headersSent) res.status(500).json({ error: err.message })
})
} catch (err) {
if (!res.headersSent) {
res.status(err.response?.status || 500).json({ error: err.message })
}
}
})
Streaming is preferable when clients are on slow connections or the screenshot is large — it reduces time-to-first-byte and peak memory usage.
Middleware: API Key Authentication
Protect your screenshot endpoint from unauthorized use:
// middleware/auth.js
function requireApiKey(req, res, next) {
const key = req.headers['x-api-key'] || req.query.api_key
if (!key || key !== process.env.CLIENT_API_KEY) {
return res.status(401).json({ error: 'Unauthorized' })
}
next()
}
module.exports = { requireApiKey }
const { requireApiKey } = require('./middleware/auth')
router.get('/screenshot', requireApiKey, async (req, res) => { /* ... */ })
Middleware: Rate Limiting
Prevent abuse with per-IP rate limiting using express-rate-limit:
npm install express-rate-limit
const rateLimit = require('express-rate-limit')
const screenshotLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute window
max: 10, // 10 requests per window per IP
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many screenshot requests, please slow down' },
})
router.get('/screenshot', screenshotLimiter, async (req, res) => { /* ... */ })
Middleware: Input Validation
Validate and sanitize request parameters before forwarding upstream:
// middleware/validateScreenshot.js
const ALLOWED_FORMATS = new Set(['webp', 'png', 'jpeg'])
const MAX_WIDTH = 3840
const MAX_HEIGHT = 2160
function validateScreenshotParams(req, res, next) {
const { url, width, height, format } = req.query
if (!url) return res.status(400).json({ error: 'url is required' })
try {
const parsed = new URL(url)
if (!['http:', 'https:'].includes(parsed.protocol)) {
return res.status(400).json({ error: 'Only http/https URLs are allowed' })
}
} catch {
return res.status(400).json({ error: 'Invalid URL' })
}
if (width && (isNaN(width) || width < 1 || width > MAX_WIDTH)) {
return res.status(400).json({ error: `width must be 1–${MAX_WIDTH}` })
}
if (height && (isNaN(height) || height < 1 || height > MAX_HEIGHT)) {
return res.status(400).json({ error: `height must be 1–${MAX_HEIGHT}` })
}
if (format && !ALLOWED_FORMATS.has(format)) {
return res.status(400).json({ error: `format must be one of: ${[...ALLOWED_FORMATS].join(', ')}` })
}
next()
}
module.exports = { validateScreenshotParams }
Apply both in sequence:
router.get('/screenshot', requireApiKey, screenshotLimiter, validateScreenshotParams, screenshotHandler)
In-Memory Cache
Cache screenshot results to avoid redundant API calls for the same URL:
// Simple LRU-style cache (production: use Redis)
const cache = new Map()
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
const CACHE_MAX_SIZE = 100
function cacheKey(url, width, height, format) {
return `${url}:${width}:${height}:${format}`
}
function getFromCache(key) {
const entry = cache.get(key)
if (!entry) return null
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
cache.delete(key)
return null
}
return entry.data
}
function setInCache(key, data) {
if (cache.size >= CACHE_MAX_SIZE) {
// Evict oldest entry
const oldest = cache.keys().next().value
cache.delete(oldest)
}
cache.set(key, { data, timestamp: Date.now() })
}
router.get('/screenshot', async (req, res) => {
const { url, width = '1280', height = '800', format = 'webp' } = req.query
if (!url) return res.status(400).json({ error: 'url required' })
const key = cacheKey(url, width, height, format)
const cached = getFromCache(key)
if (cached) {
res.set('X-Cache', 'HIT')
res.set('Content-Type', 'image/webp')
return res.send(cached)
}
try {
const params = new URLSearchParams({ url, width, height, format })
const response = await axios.get(
`https://hermesforge.dev/api/screenshot?${params}`,
{ headers: { 'X-API-Key': process.env.SCREENSHOT_API_KEY }, responseType: 'arraybuffer' }
)
const buffer = Buffer.from(response.data)
setInCache(key, buffer)
res.set('X-Cache', 'MISS')
res.set('Content-Type', response.headers['content-type'] || 'image/webp')
res.send(buffer)
} catch (err) {
res.status(err.response?.status || 500).json({ error: err.message })
}
})
For production, replace the in-memory map with Redis using ioredis.
Batch Screenshot Endpoint
Accept an array of URLs and return all screenshots:
router.post('/screenshots/batch', express.json(), async (req, res) => {
const { urls, width = 1280, height = 800, format = 'webp' } = req.body
if (!Array.isArray(urls) || urls.length === 0) {
return res.status(400).json({ error: 'urls must be a non-empty array' })
}
if (urls.length > 10) {
return res.status(400).json({ error: 'Maximum 10 URLs per batch' })
}
const results = await Promise.allSettled(
urls.map(async (url) => {
const params = new URLSearchParams({ url, width, height, format })
const response = await axios.get(
`https://hermesforge.dev/api/screenshot?${params}`,
{ headers: { 'X-API-Key': process.env.SCREENSHOT_API_KEY }, responseType: 'arraybuffer' }
)
return {
url,
data: Buffer.from(response.data).toString('base64'),
contentType: response.headers['content-type'],
}
})
)
const formatted = results.map((result, i) => ({
url: urls[i],
status: result.status === 'fulfilled' ? 'ok' : 'error',
...(result.status === 'fulfilled'
? { data: result.value.data, contentType: result.value.contentType }
: { error: result.reason.message }),
}))
res.json({ results: formatted })
})
TypeScript Version
Full TypeScript handler with typed request and response:
// routes/screenshot.ts
import { Router, Request, Response } from 'express'
import axios from 'axios'
interface ScreenshotQuery {
url?: string
width?: string
height?: string
format?: 'webp' | 'png' | 'jpeg'
full_page?: string
delay?: string
}
const router = Router()
router.get('/screenshot', async (req: Request<{}, {}, {}, ScreenshotQuery>, res: Response) => {
const {
url,
width = '1280',
height = '800',
format = 'webp',
full_page = 'false',
delay,
} = req.query
if (!url) {
return res.status(400).json({ error: 'url is required' })
}
const params = new URLSearchParams({ url, width, height, format, full_page })
if (delay) params.set('delay', delay)
try {
const response = await axios.get<ArrayBuffer>(
`https://hermesforge.dev/api/screenshot?${params}`,
{
headers: { 'X-API-Key': process.env.SCREENSHOT_API_KEY! },
responseType: 'arraybuffer',
}
)
const buffer = Buffer.from(response.data)
res.set('Content-Type', response.headers['content-type'] || 'image/webp')
res.set('Cache-Control', 'public, max-age=300')
res.set('Content-Length', String(buffer.length))
res.send(buffer)
} catch (err: unknown) {
if (axios.isAxiosError(err)) {
return res.status(err.response?.status ?? 500).json({
error: 'Screenshot failed',
detail: err.message,
})
}
res.status(500).json({ error: 'Internal server error' })
}
})
export default router
Error Handling Middleware
Centralize error handling for screenshot failures:
// Catch async errors without try/catch boilerplate
// Requires Node 18+ or express-async-errors package
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next)
router.get('/screenshot', asyncHandler(async (req, res) => {
const { url } = req.query
if (!url) throw Object.assign(new Error('url required'), { status: 400 })
const params = new URLSearchParams({ url, format: 'webp', width: '1280', height: '800' })
const response = await axios.get(
`https://hermesforge.dev/api/screenshot?${params}`,
{ headers: { 'X-API-Key': process.env.SCREENSHOT_API_KEY }, responseType: 'arraybuffer' }
)
res.set('Content-Type', 'image/webp')
res.send(Buffer.from(response.data))
}))
// Global error handler (must be last middleware, 4 args)
app.use((err, req, res, next) => {
const status = err.status || err.response?.status || 500
res.status(status).json({ error: err.message })
})
Webhook Notification on Completion
For long-running screenshot captures, notify a webhook when done:
router.post('/screenshot/async', express.json(), async (req, res) => {
const { url, webhook_url } = req.body
if (!url) return res.status(400).json({ error: 'url required' })
// Respond immediately — capture happens in background
res.json({ status: 'queued', message: 'Screenshot capture started' })
// Fire-and-forget
setImmediate(async () => {
try {
const params = new URLSearchParams({ url, format: 'webp', width: '1280', height: '800' })
const response = await axios.get(
`https://hermesforge.dev/api/screenshot?${params}`,
{ headers: { 'X-API-Key': process.env.SCREENSHOT_API_KEY }, responseType: 'arraybuffer' }
)
if (webhook_url) {
await axios.post(webhook_url, {
url,
status: 'ok',
data: Buffer.from(response.data).toString('base64'),
contentType: 'image/webp',
})
}
} catch (err) {
if (webhook_url) {
await axios.post(webhook_url, { url, status: 'error', error: err.message }).catch(() => {})
}
}
})
})
Complete App Example
Putting it together with all middleware:
// app.js
const express = require('express')
const rateLimit = require('express-rate-limit')
const axios = require('axios')
const app = express()
app.use(express.json())
const limiter = rateLimit({ windowMs: 60000, max: 20 })
app.get('/api/screenshot', limiter, async (req, res) => {
const { url, width = '1280', height = '800', format = 'webp', full_page } = req.query
if (!url) return res.status(400).json({ error: 'url required' })
try {
new URL(url)
} catch {
return res.status(400).json({ error: 'invalid URL' })
}
try {
const params = new URLSearchParams({ url, width, height, format })
if (full_page) params.set('full_page', full_page)
const { data, headers } = await axios.get(
`https://hermesforge.dev/api/screenshot?${params}`,
{ headers: { 'X-API-Key': process.env.SCREENSHOT_API_KEY }, responseType: 'arraybuffer' }
)
res.set('Content-Type', headers['content-type'] || 'image/webp')
res.set('Cache-Control', 'public, max-age=300')
res.send(Buffer.from(data))
} catch (err) {
res.status(err.response?.status || 500).json({ error: err.message })
}
})
app.listen(process.env.PORT || 3000)
Get Your API Key
Free API key at hermesforge.dev/screenshot. Full parameter reference at hermesforge.dev/api/docs.