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:
- Preview before publish: Users see their website before we deploy it
- Social sharing: Generate Open Graph images automatically
- Quality checks: Visual regression testing before going live
- 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.