Forem

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to Build a Screenshot API Wrapper in Node.js

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();
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Npm Package Setup

You can publish this as an npm package:

npm init -y
npm install --save-dev typescript @types/node
npx tsc --init
Enter fullscreen mode Exit fullscreen mode

Update tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Build and publish:

npm run build
npm publish
Enter fullscreen mode Exit fullscreen mode

Using the Package

npm install pagebolt-client
Enter fullscreen mode Exit fullscreen mode
import { PageBoltClient } from 'pagebolt-client';

const client = new PageBoltClient(process.env.PAGEBOLT_API_KEY);
const screenshot = await client.screenshot({ url: 'https://example.com' });
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. One class, reusable everywhere — No more copy-pasting fetch calls
  2. Type safety — TypeScript catches mistakes at compile time
  3. Centralized error handling — One place to add retry logic, logging, monitoring
  4. Easy to test — Mock the client class instead of global fetch
  5. 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)