How to Take Screenshots with Fastify Using a Screenshot API

2026-05-14 | Tags: [fastify, nodejs, typescript, api, screenshot, backend, performance]

Fastify is the high-performance Node.js web framework — benchmarks consistently show 2-3x higher throughput than Express for comparable workloads. If you're building a Fastify application that needs web page screenshots, this guide covers Fastify's idiomatic patterns from a basic route through plugins, schema validation, hooks, and decorators.

Setup

npm install fastify @fastify/env undici
npm install --save-dev typescript @types/node

Get a free API key at hermesforge.dev/screenshot. Store it in .env:

SCREENSHOT_API_KEY=your_api_key_here

Basic Route

A minimal Fastify route with JSON schema validation:

// server.js
import Fastify from 'fastify'
import undici from 'undici'

const fastify = Fastify({ logger: true })

const API_KEY = process.env.SCREENSHOT_API_KEY
const API_BASE = 'https://hermesforge.dev/api/screenshot'

// JSON Schema for query parameter validation
const querySchema = {
  type: 'object',
  required: ['url'],
  properties: {
    url:       { type: 'string', format: 'uri' },
    width:     { type: 'integer', minimum: 1, maximum: 3840, default: 1280 },
    height:    { type: 'integer', minimum: 1, maximum: 2160, default: 800 },
    format:    { type: 'string', enum: ['webp', 'png', 'jpeg'], default: 'webp' },
    full_page: { type: 'boolean', default: false },
    delay:     { type: 'integer', minimum: 0, maximum: 10000 },
  },
}

fastify.get('/screenshot', {
  schema: { querystring: querySchema },
}, async (request, reply) => {
  const { url, width, height, format, full_page, delay } = request.query

  const params = new URLSearchParams({ url, width, height, format, full_page })
  if (delay !== undefined) params.set('delay', delay)

  const response = await undici.fetch(`${API_BASE}?${params}`, {
    headers: { 'X-API-Key': API_KEY },
  })

  if (!response.ok) {
    reply.code(response.status)
    return { error: 'Screenshot failed', status: response.status }
  }

  const buffer = await response.arrayBuffer()
  const contentType = response.headers.get('content-type') ?? 'image/webp'

  reply
    .header('Content-Type', contentType)
    .header('Cache-Control', 'public, max-age=300')
    .send(Buffer.from(buffer))
})

await fastify.listen({ port: 3000 })

Fastify validates the querystring against the schema automatically — invalid requests get a 400 with a descriptive error before your handler runs.

Plugin: Screenshot Service

Encapsulate the screenshot logic as a reusable Fastify plugin:

// plugins/screenshot.js
import fp from 'fastify-plugin'
import undici from 'undici'

async function screenshotPlugin(fastify, options) {
  const apiKey = options.apiKey ?? process.env.SCREENSHOT_API_KEY
  const apiBase = options.apiBase ?? 'https://hermesforge.dev/api/screenshot'

  async function capture({ url, width = 1280, height = 800, format = 'webp', fullPage = false, delay }) {
    const params = new URLSearchParams({
      url, width, height, format, full_page: fullPage,
    })
    if (delay !== undefined) params.set('delay', delay)

    const response = await undici.fetch(`${apiBase}?${params}`, {
      headers: { 'X-API-Key': apiKey },
      signal: AbortSignal.timeout(30_000),
    })

    if (!response.ok) {
      const err = new Error(`Screenshot API error: HTTP ${response.status}`)
      err.statusCode = response.status
      throw err
    }

    const buffer = await response.arrayBuffer()
    return {
      data: Buffer.from(buffer),
      contentType: response.headers.get('content-type') ?? 'image/webp',
    }
  }

  // Decorate the fastify instance so all routes can access it
  fastify.decorate('screenshot', { capture })
}

export default fp(screenshotPlugin, { name: 'screenshot-plugin' })

Register and use the plugin:

// server.js
import Fastify from 'fastify'
import screenshotPlugin from './plugins/screenshot.js'

const fastify = Fastify({ logger: true })

await fastify.register(screenshotPlugin, {
  apiKey: process.env.SCREENSHOT_API_KEY,
})

fastify.get('/screenshot', {
  schema: {
    querystring: {
      type: 'object',
      required: ['url'],
      properties: {
        url: { type: 'string', format: 'uri' },
        width: { type: 'integer', default: 1280 },
        height: { type: 'integer', default: 800 },
      },
    },
  },
}, async (request, reply) => {
  const { data, contentType } = await fastify.screenshot.capture(request.query)
  return reply.header('Content-Type', contentType).send(data)
})

fastify-plugin is important here — it unwraps the plugin's encapsulation scope so the decorator is available on the root instance (and all child scopes).

Schema Validation with TypeScript

Fastify has first-class TypeScript support via type providers:

// server.ts
import Fastify from 'fastify'
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import { Type, Static } from '@sinclair/typebox'

const fastify = Fastify({ logger: true }).withTypeProvider<TypeBoxTypeProvider>()

const ScreenshotQuery = Type.Object({
  url:       Type.String({ format: 'uri' }),
  width:     Type.Optional(Type.Integer({ minimum: 1, maximum: 3840, default: 1280 })),
  height:    Type.Optional(Type.Integer({ minimum: 1, maximum: 2160, default: 800 })),
  format:    Type.Optional(Type.Union([
    Type.Literal('webp'), Type.Literal('png'), Type.Literal('jpeg')
  ], { default: 'webp' })),
  full_page: Type.Optional(Type.Boolean({ default: false })),
  delay:     Type.Optional(Type.Integer({ minimum: 0, maximum: 10000 })),
})

type ScreenshotQueryType = Static<typeof ScreenshotQuery>

fastify.get<{ Querystring: ScreenshotQueryType }>('/screenshot', {
  schema: { querystring: ScreenshotQuery },
}, async (request, reply) => {
  // request.query is fully typed — no casting needed
  const { url, width = 1280, height = 800, format = 'webp' } = request.query
  // ...
})

Hook: Rate Limiting

Use onRequest hooks for per-route rate limiting without a plugin:

// Simple in-memory rate limiter (use @fastify/rate-limit for production)
const requestCounts = new Map()

function rateLimitHook(maxRequests, windowMs) {
  return async function(request, reply) {
    const key = request.ip
    const now = Date.now()
    const entry = requestCounts.get(key) ?? { count: 0, windowStart: now }

    if (now - entry.windowStart > windowMs) {
      entry.count = 0
      entry.windowStart = now
    }

    entry.count++
    requestCounts.set(key, entry)

    if (entry.count > maxRequests) {
      reply.header('Retry-After', Math.ceil(windowMs / 1000))
      reply.code(429).send({ error: 'Too many requests' })
    }
  }
}

fastify.get('/screenshot', {
  onRequest: [rateLimitHook(10, 60_000)],
  schema: { querystring: querySchema },
}, screenshotHandler)

For production, use @fastify/rate-limit:

npm install @fastify/rate-limit
await fastify.register(import('@fastify/rate-limit'), {
  max: 10,
  timeWindow: '1 minute',
})

Caching with @fastify/caching

npm install @fastify/caching abstract-cache
import fastifyCaching from '@fastify/caching'
import AbCache from 'abstract-cache'

await fastify.register(fastifyCaching, {
  privacy: fastifyCaching.privacy.PUBLIC,
  expiresIn: 300,  // 5 minutes
  cache: new AbCache({ useAwait: false }),
})

fastify.get('/screenshot', async (request, reply) => {
  const cacheKey = `screenshot:${new URLSearchParams(request.query).toString()}`

  // Check cache
  const cached = await fastify.cache.get(cacheKey)
  if (cached?.item) {
    return reply
      .header('Content-Type', 'image/webp')
      .header('X-Cache', 'HIT')
      .send(cached.item)
  }

  // Fetch from API
  const { data, contentType } = await fastify.screenshot.capture(request.query)

  // Store in cache (TTL in milliseconds)
  await fastify.cache.set(cacheKey, data, 300_000)

  return reply
    .header('Content-Type', contentType)
    .header('X-Cache', 'MISS')
    .send(data)
})

Streaming Response

Stream the upstream response directly to the client using Node.js streams:

import { pipeline } from 'node:stream/promises'
import undici from 'undici'

fastify.get('/screenshot/stream', {
  schema: { querystring: { type: 'object', required: ['url'], properties: { url: { type: 'string' } } } },
}, async (request, reply) => {
  const { url } = request.query
  const params = new URLSearchParams({ url, format: 'webp', width: 1280, height: 800 })

  const response = await undici.fetch(`${API_BASE}?${params}`, {
    headers: { 'X-API-Key': API_KEY },
  })

  if (!response.ok) {
    return reply.code(response.status).send({ error: 'Screenshot failed' })
  }

  reply.header('Content-Type', response.headers.get('content-type') ?? 'image/webp')

  // Pipe the readable stream directly to the reply
  return reply.send(response.body)
})

undici.fetch() returns a native ReadableStream body, which Fastify can pipe directly — no intermediate buffer.

Batch Endpoint

fastify.post('/screenshots/batch', {
  schema: {
    body: {
      type: 'object',
      required: ['urls'],
      properties: {
        urls:   { type: 'array', items: { type: 'string', format: 'uri' }, maxItems: 10 },
        width:  { type: 'integer', default: 1280 },
        height: { type: 'integer', default: 800 },
      },
    },
  },
}, async (request) => {
  const { urls, width = 1280, height = 800 } = request.body

  const results = await Promise.allSettled(
    urls.map((url) => fastify.screenshot.capture({ url, width, height }))
  )

  return {
    results: results.map((result, i) => ({
      url: urls[i],
      status: result.status === 'fulfilled' ? 'ok' : 'error',
      data: result.status === 'fulfilled'
        ? result.value.data.toString('base64')
        : undefined,
      error: result.status === 'rejected'
        ? result.reason?.message
        : undefined,
    })),
  }
})

Error Handler

Register a global error handler for consistent error responses:

fastify.setErrorHandler(async (error, request, reply) => {
  const status = error.statusCode ?? error.status ?? 500

  fastify.log.error({ err: error, url: request.url }, 'Request error')

  reply.code(status).send({
    error: error.message,
    statusCode: status,
  })
})

Fastify's built-in validation errors (schema mismatches) go through setErrorHandler automatically with a 400 status code.

Production Setup

// server.js
import Fastify from 'fastify'
import { fastifyEnv } from '@fastify/env'

const schema = {
  type: 'object',
  required: ['SCREENSHOT_API_KEY'],
  properties: {
    SCREENSHOT_API_KEY: { type: 'string' },
    PORT: { type: 'integer', default: 3000 },
    HOST: { type: 'string', default: '0.0.0.0' },
  },
}

const fastify = Fastify({
  logger: {
    level: process.env.LOG_LEVEL ?? 'info',
    transport: process.env.NODE_ENV !== 'production'
      ? { target: 'pino-pretty' }
      : undefined,
  },
  trustProxy: true,  // For correct IP behind reverse proxy
})

await fastify.register(fastifyEnv, { schema, dotenv: true })
await fastify.register(import('./plugins/screenshot.js'), {
  apiKey: fastify.config.SCREENSHOT_API_KEY,
})

// Routes
await fastify.register(import('./routes/screenshot.js'))

await fastify.listen({
  port: fastify.config.PORT,
  host: fastify.config.HOST,
})

Get Your API Key

Free API key at hermesforge.dev/screenshot. Full parameter reference at hermesforge.dev/api/docs.