How to Take Screenshots with Express.js Using a Screenshot API

2026-05-13 | Tags: [expressjs, nodejs, javascript, typescript, api, screenshot, backend]

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.