How to Build a Screenshot API with Node.js, Puppeteer, and easyCDN

Back to Blog
Lukas Gisder-Dubé
7 min read
cdnweb-performancepuppeteerscreenshotsnode.jsapi

How to Build a Screenshot API with Node.js, Puppeteer, and easyCDN

Last week, a friend asked me to help with their SaaS product. They needed to generate social preview images for user-created content. You know, those nice looking cards that show up when you share a link on Twitter or LinkedIn.

"Just use a screenshot service," I said. "They're like $30/month," he replied.

Here's the thing: taking screenshots with Puppeteer is maybe 50 lines of code. The hard part is everything that comes after—storing images reliably, serving them fast globally, handling cache invalidation, managing storage costs.

That's exactly what easyCDN handles. So we wrote the simple screenshot code ourselves and let easyCDN do the heavy lifting. Total setup: about an hour. Monthly cost: pennies.

Here's how we did it, step by step.

The Build vs. Buy Tradeoff

Screenshot services charge $30+/month because they bundle two things: the capture logic and the delivery infrastructure. But these aren't equally complex:

  • Screenshot capture: ~50 lines of Puppeteer code
  • Global delivery & caching: CDN configuration, edge locations, cache headers, storage management...

The smart move? Write the simple code yourself, use easyCDN for the infrastructure you'd rather not manage.

The use cases are endless:

  • Social preview images (Open Graph, Twitter cards)
  • PDF generation from HTML
  • Automated testing and visual regression
  • Creating thumbnails for user-generated content
  • Archiving web pages

The tech stack is straightforward: Puppeteer handles the browser automation, and easyCDN handles storage and global delivery. You get back CDN URLs that load in under 100ms globally—perfect for snappy social previews.

Setting Up the Project

Before diving in, sign up for easyCDN, grab your secret key from the dashboard, and set it as an environment variable (EASYCDN_SECRET_KEY). It's free to start and takes under a minute.

Now let's get our dependencies sorted:

bash
npm init -y
npm install puppeteer @easycdn/server express
npm install -D typescript @types/node @types/express

Create a basic TypeScript config if you don't have one:

json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist"
  }
}

Note for serverless deployments: Puppeteer requires Chrome dependencies on some servers. If you're deploying to AWS Lambda or Vercel, you'll need the chrome-aws-lambda package. Check the Puppeteer troubleshooting docs for platform-specific setup.

The Core Screenshot Function

Here's where the magic happens. Puppeteer spins up a headless Chrome instance, navigates to a URL, and captures a screenshot:

ts
import puppeteer from 'puppeteer'

async function captureScreenshot(url: string): Promise<Buffer> {
  // Note: This creates a new browser each time.
  // For production, we'll reuse the browser instance (see full example below)
  const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  })

  const page = await browser.newPage()
  
  await page.setViewport({
    width: 1200,
    height: 630, // Standard OG image dimensions per ogp.me spec
  })

  await page.goto(url, {
    waitUntil: 'networkidle0', // Wait for network to be idle
    timeout: 30000,
  })

  const screenshot = await page.screenshot({
    type: 'webp',
    quality: 85,
  })

  await browser.close()

  return screenshot as Buffer
}

A few things to note here. The networkidle0 option waits until there are no more than 0 network connections for 500ms. This ensures all images and fonts have loaded before we capture. The viewport is set to 1200x630—the recommended size for Open Graph images.

Uploading to easyCDN

Now we need to get these previews onto a CDN so they're fast to load from anywhere in the world. easyCDN makes this dead simple:

ts
import { createClient } from '@easycdn/server'

// Validate environment variable early
if (!process.env.EASYCDN_SECRET_KEY) {
  throw new Error('Missing EASYCDN_SECRET_KEY environment variable')
}

const cdn = createClient({
  secretKey: process.env.EASYCDN_SECRET_KEY,
})

async function uploadScreenshot(buffer: Buffer, filename: string): Promise<string> {
  const result = await cdn.upload(buffer, {
    fileName: `screenshots/${filename}.webp`,
    contentType: 'image/webp',
  })

  return result.asset.url
}

The SDK handles all the complexity—authentication, multipart uploads, error handling. You get back a URL that's already optimized and cached on the edge.

Putting It All Together

Let's wrap this in a simple Express API. Notice how we reuse the browser instance for much better performance compared to the basic example above:

ts
import express from 'express'
import puppeteer from 'puppeteer'
import { createClient } from '@easycdn/server'
import crypto from 'crypto'

const app = express()
app.use(express.json())

// Validate environment variable at startup
if (!process.env.EASYCDN_SECRET_KEY) {
  throw new Error('Missing EASYCDN_SECRET_KEY environment variable')
}

const cdn = createClient({
  secretKey: process.env.EASYCDN_SECRET_KEY,
})

const FALLBACK_IMAGE = 'https://cdn.easycdn.co/screenshots/fallback.webp'

// Reuse browser instance for better performance
let browser: puppeteer.Browser | null = null

async function getBrowser() {
  if (!browser) {
    browser = await puppeteer.launch({
      headless: true,
      args: ['--no-sandbox', '--disable-setuid-sandbox'],
    })
  }
  return browser
}

app.post('/screenshot', async (req, res) => {
  const { url, width = 1200, height = 630 } = req.body

  if (!url) {
    return res.status(400).json({ error: 'URL is required' })
  }

  try {
    const browser = await getBrowser()
    const page = await browser.newPage()

    await page.setViewport({ width, height })
    await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 })

    const screenshot = await page.screenshot({
      type: 'webp',
      quality: 85,
    })

    await page.close()

    // Generate a unique filename based on URL and dimensions
    const hash = crypto.createHash('md5').update(`${url}-${width}-${height}`).digest('hex')
    
    const result = await cdn.upload(screenshot as Buffer, {
      fileName: `screenshots/${hash}.webp`,
      contentType: 'image/webp',
    })

    res.json({
      success: true,
      url: result.asset.url,
    })
  } catch (error) {
    console.error('Screenshot failed:', error)
    // Return fallback image instead of failing completely
    res.json({
      success: true,
      url: FALLBACK_IMAGE,
      fallback: true,
    })
  }
})

app.listen(3000, () => {
  console.log('Screenshot API running on port 3000')
})

Pro Tips for Production

After running this in production for a few projects, here are some lessons learned:

Cache aggressively. Before capturing a new screenshot, check if one already exists for that URL. Use a database or Redis to store the URL hash and the resulting CDN URL:

ts
// Store screenshot URLs in your database or Redis
const screenshotCache = new Map<string, string>() // Use Redis in production

async function getOrCreateScreenshot(url: string, width: number, height: number) {
  const hash = crypto.createHash('md5').update(`${url}-${width}-${height}`).digest('hex')

  // Check cache first
  const cached = screenshotCache.get(hash)
  if (cached) {
    return { url: cached, cached: true }
  }

  // Capture and upload new screenshot
  const screenshot = await captureScreenshot(url, width, height)
  const result = await cdn.upload(screenshot, {
    fileName: `screenshots/${hash}.webp`,
    contentType: 'image/webp',
  })

  // Store in cache
  screenshotCache.set(hash, result.asset.url)

  return { url: result.asset.url, cached: false }
}

Set reasonable timeouts. Some pages take forever to load. Don't let one slow request tie up your server. 30 seconds is generous—consider dropping it to 15 for most use cases.

Handle errors gracefully. Pages fail to load, SSL certificates expire, servers go down. Always have a fallback image ready (as shown in the full example above).

Consider queuing for high volume. If you're processing hundreds of captures, use a job queue like BullMQ. Puppeteer is resource-intensive—you don't want to spawn 50 browser instances simultaneously.

Deploying to Production

For traditional servers (DigitalOcean, EC2), the code works as-is. For serverless platforms, you'll need some adjustments:

AWS Lambda / Vercel: Use the chrome-aws-lambda package instead of the full Puppeteer:

bash
npm install chrome-aws-lambda puppeteer-core

Then swap your import:

ts
import chromium from 'chrome-aws-lambda'
import puppeteer from 'puppeteer-core'

const browser = await puppeteer.launch({
  args: chromium.args,
  executablePath: await chromium.executablePath,
  headless: chromium.headless,
})

What You Can Build With This

Once you have a working screenshot API, the possibilities open up:

  • Dynamic OG images: Pass parameters to a template page, capture it, instant social cards
  • Website thumbnails: Show previews of links in your app
  • Automated monitoring: Capture daily screenshots to track visual changes
  • Receipt generation: Render HTML invoices and convert to images or PDFs

The best part? Your previews are served from easyCDN's edge network, so they load fast no matter where your users are.

Wrapping Up

Building a screenshot API isn't rocket science, but the combination of Puppeteer and a solid CDN makes it genuinely useful. We went from "I need screenshots" to "here's a working API" in under 100 lines of code.

Remember my friend's SaaS? They've been running this exact setup for months now. Social previews generate automatically, load instantly, and their bill is basically a rounding error.

If this sounds like your jam, give easyCDN a spin—it's built for folks like us who hate overcomplicated tools. Sign up takes 30 seconds, and you can be uploading images in minutes.

Happy coding!

Ready to host your assets?

Create your free account and start serving your assets in minutes.

No credit card required • Get started in under 2 minutes