How to Build a Screenshot API Wrapper in Node.js
You're integrating a screenshot API into your app. You paste the API call everywhere:
const response = await fetch('https://api.pagebolt.dev/v1/screenshot', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({ url: 'https://example.com' })
});
const buffer = await response.arrayBuffer();
Now you have the same fetch boilerplate in 5 files. Error handling is inconsistent. You want to add retry logic—but that means changing every call.
There's a better way: build a client class.
One TypeScript class. Typed methods. Built-in error handling and retries. Use it anywhere.
Here's how to build a production-ready PageBolt client for Node.js.
The Problem: Raw API Calls Are Scattered & Hard to Maintain
Raw fetch() calls have downsides:
// Problem 1: Duplication across files
// file1.ts
const screenshot = await fetch('https://api.pagebolt.dev/v1/screenshot', {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.PAGEBOLT_API_KEY}` },
body: JSON.stringify({ url: 'https://example.com' })
});
// file2.ts — same thing, copy-pasted
const screenshot = await fetch('https://api.pagebolt.dev/v1/screenshot', {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.PAGEBOLT_API_KEY}` },
body: JSON.stringify({ url: 'https://example.com' })
});
// Problem 2: No error handling
// What if the API returns 429 (rate limited)? What if the network times out?
// You have to handle it in every file.
// Problem 3: Hard to test
// You have to mock fetch in every test file instead of mocking one client class.
The Solution: A Reusable Client Class
One class. Typed methods. Centralized error handling.
// pagebolt-client.ts
import * as fs from 'fs';
export interface ScreenshotOptions {
url: string;
width?: number;
height?: number;
format?: 'png' | 'jpeg' | 'webp';
fullPage?: boolean;
blockAds?: boolean;
blockBanners?: boolean;
blockChats?: boolean;
blockTrackers?: boolean;
}
export interface PDFOptions {
url?: string;
html?: string;
width?: string;
height?: string;
format?: 'A4' | 'Letter' | 'Legal';
landscape?: boolean;
margin?: string;
printBackground?: boolean;
}
export interface OGImageOptions {
title: string;
subtitle?: string;
template?: 'default' | 'minimal' | 'gradient';
bgColor?: string;
accentColor?: string;
logo?: string;
width?: number;
height?: number;
}
export type PageBoltError =
| { code: 'INVALID_API_KEY'; message: string }
| { code: 'RATE_LIMITED'; retryAfter: number }
| { code: 'INVALID_REQUEST'; message: string }
| { code: 'SERVER_ERROR'; status: number; message: string }
| { code: 'NETWORK_ERROR'; message: string }
| { code: 'TIMEOUT'; message: string };
export class PageBoltClient {
private apiKey: string;
private baseUrl: string = 'https://api.pagebolt.dev/v1';
private timeout: number = 30000;
private maxRetries: number = 3;
constructor(apiKey?: string) {
this.apiKey = apiKey || process.env.PAGEBOLT_API_KEY || '';
if (!this.apiKey) {
throw new Error('PAGEBOLT_API_KEY is required');
}
}
/**
* Take a screenshot of a URL
*/
async screenshot(options: ScreenshotOptions): Promise<Buffer> {
const payload = {
url: options.url,
width: options.width || 1280,
height: options.height || 720,
format: options.format || 'png',
fullPage: options.fullPage ?? false,
blockAds: options.blockAds ?? true,
blockBanners: options.blockBanners ?? true,
blockChats: options.blockChats ?? true,
blockTrackers: options.blockTrackers ?? true,
};
return this.request<Buffer>('/screenshot', payload, { binary: true });
}
/**
* Generate a PDF from a URL or HTML
*/
async pdf(options: PDFOptions): Promise<Buffer> {
if (!options.url && !options.html) {
throw this.error({
code: 'INVALID_REQUEST',
message: 'Either url or html is required'
});
}
const payload = {
url: options.url,
html: options.html,
width: options.width,
height: options.height,
format: options.format || 'A4',
landscape: options.landscape ?? false,
margin: options.margin || '1cm',
printBackground: options.printBackground ?? true,
};
return this.request<Buffer>('/pdf', payload, { binary: true });
}
/**
* Generate an Open Graph image
*/
async ogImage(options: OGImageOptions): Promise<Buffer> {
const payload = {
title: options.title,
subtitle: options.subtitle,
template: options.template || 'default',
bgColor: options.bgColor,
accentColor: options.accentColor,
logo: options.logo,
width: options.width || 1200,
height: options.height || 630,
};
return this.request<Buffer>('/create_og_image', payload, { binary: true });
}
/**
* Internal: Make a request with retry logic
*/
private async request<T>(
endpoint: string,
payload: Record<string, any>,
options: { binary?: boolean } = {}
): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
try {
const response = await this.fetch(endpoint, payload);
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '5');
if (attempt < this.maxRetries - 1) {
await this.delay(retryAfter * 1000);
continue;
}
throw this.error({
code: 'RATE_LIMITED',
retryAfter,
message: `Rate limited. Retry after ${retryAfter}s`
});
}
if (!response.ok) {
const text = await response.text();
if (response.status === 401) {
throw this.error({
code: 'INVALID_API_KEY',
message: 'Invalid API key'
});
}
throw this.error({
code: 'SERVER_ERROR',
status: response.status,
message: text || `HTTP ${response.status}`
});
}
if (options.binary) {
return (await response.arrayBuffer()) as unknown as T;
}
return (await response.json()) as T;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (err instanceof Error && err.message.includes('ECONNREFUSED')) {
throw this.error({
code: 'NETWORK_ERROR',
message: 'Could not connect to API'
});
}
if (attempt < this.maxRetries - 1) {
await this.delay((attempt + 1) * 1000);
}
}
}
throw lastError || this.error({
code: 'SERVER_ERROR',
status: 500,
message: 'Unknown error'
});
}
/**
* Internal: Fetch with timeout
*/
private fetch(endpoint: string, payload: Record<string, any>) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
return fetch(this.baseUrl + endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
signal: controller.signal,
}).finally(() => clearTimeout(timeoutId));
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private error(err: PageBoltError): Error {
return new Error(`[${err.code}] ${err.message}`);
}
}
Usage: Simple & Type-Safe
import { PageBoltClient } from './pagebolt-client';
import * as fs from 'fs';
const client = new PageBoltClient();
// Take a screenshot
const screenshot = await client.screenshot({
url: 'https://example.com',
width: 1920,
height: 1080,
format: 'png'
});
fs.writeFileSync('screenshot.png', screenshot);
// Generate a PDF
const pdf = await client.pdf({
url: 'https://example.com/invoice',
format: 'A4',
printBackground: true
});
fs.writeFileSync('invoice.pdf', pdf);
// Generate an OG image
const ogImage = await client.ogImage({
title: 'My Amazing Product',
subtitle: 'Powered by PageBolt',
template: 'gradient'
});
fs.writeFileSync('og-image.png', ogImage);
Npm Package Setup
You can publish this as an npm package:
npm init -y
npm install --save-dev typescript @types/node
npx tsc --init
Update tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
Create package.json:
{
"name": "pagebolt-client",
"version": "1.0.0",
"description": "TypeScript client for PageBolt API",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"files": ["dist"],
"keywords": ["screenshot", "pdf", "api", "pagebolt"],
"author": "Your Name",
"license": "MIT"
}
Build and publish:
npm run build
npm publish
Using the Package
npm install pagebolt-client
import { PageBoltClient } from 'pagebolt-client';
const client = new PageBoltClient(process.env.PAGEBOLT_API_KEY);
const screenshot = await client.screenshot({ url: 'https://example.com' });
Error Handling in Production
import { PageBoltClient } from './pagebolt-client';
const client = new PageBoltClient();
async function captureScreenshotSafely(url: string) {
try {
const screenshot = await client.screenshot({ url });
return screenshot;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
if (error.message.includes('[RATE_LIMITED]')) {
console.log('Rate limited — queuing for retry');
// Add to queue, retry later
return null;
}
if (error.message.includes('[INVALID_API_KEY]')) {
console.error('Invalid API key — check environment variables');
process.exit(1);
}
if (error.message.includes('[NETWORK_ERROR]')) {
console.error('Network error — check internet connection');
return null;
}
throw error;
}
}
Advanced: Custom Configuration
const client = new PageBoltClient(process.env.PAGEBOLT_API_KEY);
// Override timeout and retry count
client.timeout = 60000; // 60 seconds
client.maxRetries = 5; // Retry up to 5 times
// Take screenshot with all options
const screenshot = await client.screenshot({
url: 'https://example.com',
width: 1920,
height: 1080,
format: 'webp',
fullPage: true,
blockAds: true,
blockBanners: true,
blockChats: true,
blockTrackers: true
});
Key Takeaways
- One class, reusable everywhere — No more copy-pasting fetch calls
- Type safety — TypeScript catches mistakes at compile time
- Centralized error handling — One place to add retry logic, logging, monitoring
- Easy to test — Mock the client class instead of global fetch
- Publishable — Turn it into an npm package for teams
Next step: Get your API key at pagebolt.dev. Free tier: 100 requests/month.
Try it free: https://pagebolt.dev/pricing
Top comments (0)