WebZum Logo
WebZum

From Zero to Website Hero

Sign InSign Up
Back to Blog
apipuppeteerautomationstartup

We Built a Screenshot API That Captures Websites Before They're Deployed

WebZum Team•October 15, 2025•8 min read
We Built a Screenshot API That Captures Websites Before They're Deployed

We Built a Screenshot API That Captures Websites Before They’re Deployed

TL;DR: We built a screenshot API that captures websites from raw HTML/CSS/JS—before deployment. Used for previews, social sharing, and quality checks. Built with Puppeteer, Next.js API routes, and aggressive caching. Handles 1000+ screenshots/day at $0.02 per screenshot.

The Problem: Screenshot APIs Don’t Work for Generated Websites

We generate complete websites with AI—HTML, CSS, JavaScript, images. Users need to see what their website looks like BEFORE we deploy it.

Traditional screenshot APIs:

  • Require a live URL: Can’t screenshot something that doesn’t exist yet
  • Expensive: $0.10-0.50 per screenshot adds up fast
  • Slow: 5-10 seconds per screenshot kills UX
  • Limited control: Can’t inject custom CSS, wait for specific elements, or handle auth

What we needed:

  • Screenshot from raw HTML (not URLs)
  • Fast (< 2 seconds)
  • Cheap (< $0.05 per screenshot)
  • Flexible (custom viewports, wait conditions, element targeting)

So we built our own.

The Architecture: Puppeteer + Next.js API Routes

Core Components

1. API Endpoint (/api/screenshot)

export async function POST(req: Request) {
  const { html, css, js, options } = await req.json();
  
  // Generate screenshot from raw HTML/CSS/JS
  const screenshot = await captureScreenshot({
    html,
    css,
    js,
    viewport: options.viewport || { width: 1280, height: 720 },
    format: options.format || 'png',
    quality: options.quality || 80
  });
  
  return new Response(screenshot, {
    headers: {
      'Content-Type': `image/${options.format}`,
      'Cache-Control': 'public, max-age=31536000' // Cache for 1 year
    }
  });
}

2. Screenshot Engine (Puppeteer)

async function captureScreenshot(options: ScreenshotOptions) {
  const browser = await getBrowserInstance();
  const page = await browser.newPage();
  
  try {
    // Set viewport
    await page.setViewport(options.viewport);
    
    // Load HTML with inline CSS/JS
    const fullHtml = `
      <!DOCTYPE html>
      <html>
        <head>
          <style>${options.css}</style>
        </head>
        <body>
          ${options.html}
          <script>${options.js}</script>
        </body>
      </html>
    `;
    
    await page.setContent(fullHtml, {
      waitUntil: 'networkidle0' // Wait for all resources
    });
    
    // Take screenshot
    const screenshot = await page.screenshot({
      type: options.format,
      quality: options.quality,
      fullPage: options.fullPage || false
    });
    
    return screenshot;
  } finally {
    await page.close();
  }
}

3. Browser Instance Manager (Connection Pooling)

let browserInstance: Browser | null = null;

async function getBrowserInstance(): Promise<Browser> {
  if (!browserInstance || !browserInstance.isConnected()) {
    browserInstance = await puppeteer.launch({
      headless: true,
      args: [
        '--no-sandbox',
        '--disable-setuid-sandbox',
        '--disable-dev-shm-usage', // Overcome limited resource problems
        '--disable-accelerated-2d-canvas',
        '--no-first-run',
        '--no-zygote',
        '--disable-gpu'
      ]
    });
  }
  
  return browserInstance;
}

The Challenges We Solved

Challenge 1: Handling External Resources

Problem: Generated HTML references external resources (fonts, images, CDNs). Puppeteer needs to load these.

Solution: Inline everything critical, allow external loads with timeout

// Inline critical resources
const inlinedHtml = await inlineResources(html, {
  fonts: true,  // Convert font URLs to data URIs
  images: true, // Inline small images (< 50KB)
  css: true     // Inline external stylesheets
});

// Load with timeout for external resources
await page.setContent(inlinedHtml, {
  waitUntil: 'networkidle0',
  timeout: 10000 // Max 10 seconds
});

Challenge 2: Performance (Cold Starts)

Problem: Launching Puppeteer takes 2-3 seconds. Every screenshot was slow.

Solution: Browser instance pooling + keep-alive

// Keep browser instance alive between requests
const BROWSER_IDLE_TIMEOUT = 60000; // 1 minute

let idleTimer: NodeJS.Timeout | null = null;

async function getBrowserInstance() {
  // Clear idle timer (browser is being used)
  if (idleTimer) {
    clearTimeout(idleTimer);
    idleTimer = null;
  }
  
  // Reuse existing browser or launch new one
  if (!browserInstance || !browserInstance.isConnected()) {
    browserInstance = await puppeteer.launch(config);
  }
  
  return browserInstance;
}

function scheduleBrowserCleanup() {
  // Close browser after 1 minute of inactivity
  idleTimer = setTimeout(async () => {
    if (browserInstance) {
      await browserInstance.close();
      browserInstance = null;
    }
  }, BROWSER_IDLE_TIMEOUT);
}

Result: First screenshot: 2.5s. Subsequent screenshots: 0.8s.

Challenge 3: Memory Leaks

Problem: Puppeteer pages accumulate memory. After 100 screenshots, server crashes.

Solution: Aggressive page cleanup + browser restart

async function captureScreenshot(options) {
  const browser = await getBrowserInstance();
  const page = await browser.newPage();
  
  try {
    // ... screenshot logic ...
    return screenshot;
  } finally {
    // CRITICAL: Always close page
    await page.close();
    
    // Track page count, restart browser periodically
    pageCount++;
    if (pageCount > 100) {
      await browser.close();
      browserInstance = null;
      pageCount = 0;
    }
  }
}

Challenge 4: Cost Optimization

Problem: Generating 1000 screenshots/day = expensive compute

Solution: Aggressive caching + CDN

// Generate cache key from HTML content
function getCacheKey(html: string, options: ScreenshotOptions) {
  const hash = crypto
    .createHash('md5')
    .update(html + JSON.stringify(options))
    .digest('hex');
  
  return `screenshots/${hash}.${options.format}`;
}

// Check S3 cache before generating
async function getOrGenerateScreenshot(html, options) {
  const cacheKey = getCacheKey(html, options);
  
  // Try cache first
  const cached = await s3.getObject(cacheKey).catch(() => null);
  if (cached) {
    return cached.Body;
  }
  
  // Generate new screenshot
  const screenshot = await captureScreenshot({ html, options });
  
  // Store in cache
  await s3.putObject({
    Key: cacheKey,
    Body: screenshot,
    ContentType: `image/${options.format}`,
    CacheControl: 'public, max-age=31536000'
  });
  
  return screenshot;
}

Result: Cache hit rate: 78%. Cost: $0.02 per unique screenshot.

Advanced Features We Added

1. Element-Specific Screenshots

Sometimes you only want a specific section (e.g., hero section, pricing table):

async function captureElement(page: Page, selector: string) {
  const element = await page.$(selector);
  if (!element) {
    throw new Error(`Element not found: ${selector}`);
  }
  
  return await element.screenshot({
    type: 'png',
    omitBackground: true // Transparent background
  });
}

2. Mobile vs Desktop Views

const VIEWPORTS = {
  mobile: { width: 375, height: 667, isMobile: true },
  tablet: { width: 768, height: 1024, isMobile: true },
  desktop: { width: 1280, height: 720, isMobile: false }
};

// Generate screenshots for all viewports
const screenshots = await Promise.all(
  Object.entries(VIEWPORTS).map(([device, viewport]) =>
    captureScreenshot({ html, viewport, filename: `${device}.png` })
  )
);

3. Wait for Specific Conditions

// Wait for animations to complete
await page.waitForFunction(() => {
  return document.querySelectorAll('.loading').length === 0;
});

// Wait for specific element
await page.waitForSelector('.hero-section', {
  visible: true,
  timeout: 5000
});

// Wait for network idle (all images loaded)
await page.waitForNetworkIdle({
  idleTime: 500,
  timeout: 10000
});

4. Quality vs Speed Tradeoffs

const QUALITY_PRESETS = {
  thumbnail: {
    viewport: { width: 400, height: 300 },
    format: 'jpeg',
    quality: 60,
    fullPage: false
  },
  preview: {
    viewport: { width: 1280, height: 720 },
    format: 'png',
    quality: 80,
    fullPage: false
  },
  highres: {
    viewport: { width: 1920, height: 1080 },
    format: 'png',
    quality: 95,
    fullPage: true
  }
};

Real-World Usage

Use Case 1: Website Preview Cards

// Generate preview for social sharing
POST /api/screenshot
{
  "html": "<html>...</html>",
  "options": {
    "viewport": { "width": 1200, "height": 630 },
    "format": "jpeg",
    "quality": 85
  }
}

// Returns: Open Graph image for social media

Use Case 2: Quality Assurance

// Screenshot before and after deployment
const beforeScreenshot = await captureScreenshot({ html: oldHtml });
const afterScreenshot = await captureScreenshot({ html: newHtml });

// Visual diff to catch regressions
const diff = await compareImages(beforeScreenshot, afterScreenshot);
if (diff.percentChanged > 5) {
  console.warn('Significant visual changes detected!');
}

Use Case 3: Email Previews

// Show website preview in email notifications
const screenshot = await captureScreenshot({
  html: generatedWebsite,
  options: {
    viewport: { width: 600, height: 400 },
    format: 'jpeg',
    quality: 70
  }
});

await sendEmail({
  to: user.email,
  subject: 'Your website is ready!',
  html: `<img src="data:image/jpeg;base64,${screenshot.toString('base64')}" />`
});

The Results

Performance:

  • First screenshot: 2.5s (cold start)
  • Cached screenshots: 0.3s (CDN)
  • Warm screenshots: 0.8s (browser reuse)

Cost:

  • Unique screenshot: $0.02 (compute + storage)
  • Cached screenshot: $0.0001 (CDN bandwidth)
  • Average cost per screenshot: $0.005 (78% cache hit rate)

Scale:

  • 1,200 screenshots/day
  • 78% cache hit rate
  • 99.7% success rate
  • 1.2s average response time

Why This Matters for AI-Generated Websites

Most website builders can screenshot live sites. But AI-generated websites exist as HTML/CSS/JS BEFORE deployment. Our screenshot API bridges that gap:

  1. Preview before publish: Users see their website before we deploy it
  2. Social sharing: Generate Open Graph images automatically
  3. Quality checks: Visual regression testing before going live
  4. Email notifications: Show website previews in emails

The startup lesson: Sometimes you need to build infrastructure that doesn’t exist. Screenshot APIs exist, but none solved our specific problem. Building our own gave us:

  • Lower cost: $0.02 vs $0.50 per screenshot
  • More control: Custom viewports, wait conditions, element targeting
  • Better UX: Screenshot before deployment, not after

What’s Next

We’re exploring:

  • Video capture: Record website interactions (form fills, animations)
  • PDF generation: Convert websites to PDFs for offline viewing
  • Accessibility screenshots: Highlight accessibility issues visually
  • Comparison mode: Side-by-side before/after screenshots

But the core insight remains: Build the infrastructure you need, even if it doesn’t exist yet.


Try it yourself: Generate a website with WebZum. Notice how you see a preview image immediately? That’s our screenshot API at work—capturing your website before it’s even deployed.

Building something similar? Key takeaway: Puppeteer + aggressive caching + browser pooling = fast, cheap screenshots. Don’t pay $0.50/screenshot when you can build it for $0.02.

The future of web automation isn’t third-party APIs—it’s owning your infrastructure.

Ready to Build Your Website?

Join hundreds of businesses using WebZum to create professional websites in minutes, not weeks.

Get Started Free
Live in 5 minutesNo credit card required
Home•Free Tools•Blog•Directory•About•Agencies•Partners
FAQ•Privacy•Terms•© 2026 WebZum