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:
npm init -y
npm install puppeteer @easycdn/server express
npm install -D typescript @types/node @types/expressCreate a basic TypeScript config if you don't have one:
{
"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:
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:
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:
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:
// 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:
npm install chrome-aws-lambda puppeteer-coreThen swap your import:
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!
