How to Take Screenshots with Fastify Using a Screenshot API
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.