feat: add developer blog with two posts
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled

- Blog index page (public/blog.html) with dark theme
- Post 1: Why You Need a Screenshot API (~800 words)
- Post 2: Screenshot API Performance & Caching (~600 words)
- Express routes: /blog → /blog.html, /blog/:slug → /blog/:slug.html
- Blog link added to nav and footer on index.html
- Sitemap updated with blog URLs
- Full test coverage (19 new tests, 190 total passing)
This commit is contained in:
Hoid 2026-03-02 21:10:29 +01:00
parent 9609501d7b
commit 56c7a87f3c
7 changed files with 729 additions and 0 deletions

136
public/blog.html Normal file
View file

@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Blog — SnapAPI Developer Blog</title>
<meta name="description" content="Technical articles about screenshot APIs, web rendering, performance optimization, and building developer tools. From the SnapAPI engineering team.">
<link rel="canonical" href="https://snapapi.eu/blog">
<meta property="og:title" content="Blog — SnapAPI Developer Blog">
<meta property="og:description" content="Technical articles about screenshot APIs, web rendering, performance optimization, and building developer tools.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://snapapi.eu/blog">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Blog — SnapAPI Developer Blog">
<meta name="twitter:description" content="Technical articles about screenshot APIs, web rendering, and performance.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;--border:#1e2a3f;--border-light:#2a3752;--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--primary-glow:rgba(79,143,255,0.15);--accent:#10b981;--purple:#a78bfa;--orange:#f59e0b;--pink:#ec4899;--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);--radius:12px;--radius-lg:16px}
html{scroll-behavior:smooth}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
a:hover{color:var(--primary)}
.container{max-width:900px;margin:0 auto;padding:0 24px}
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.nav-links{display:flex;gap:32px;align-items:center}
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500;transition:color .2s}
.nav-links a:hover{color:var(--text)}
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);color:#fff}
.btn-sm{padding:8px 18px;font-size:.85rem}
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
@media(max-width:768px){.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}.nav-links.show{display:flex}.nav-mobile{display:block}}
.page-header{padding:80px 0 40px;text-align:center}
.page-header h1{font-size:3rem;font-weight:900;margin-bottom:16px}
.page-header p{font-size:1.15rem;color:var(--text-secondary);max-width:600px;margin:0 auto}
.blog-grid{display:grid;gap:32px;padding:0 0 100px}
.blog-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:36px;transition:all .2s}
.blog-card:hover{background:var(--card-hover);border-color:var(--border-light);transform:translateY(-2px)}
.blog-card .meta{font-size:.82rem;color:var(--muted);margin-bottom:12px;display:flex;gap:16px}
.blog-card h2{font-size:1.5rem;font-weight:700;margin-bottom:12px}
.blog-card h2 a{color:var(--text)}
.blog-card h2 a:hover{color:var(--primary-light)}
.blog-card p{color:var(--text-secondary);line-height:1.7;margin-bottom:16px}
.blog-card .read-more{font-weight:600;font-size:.9rem;color:var(--primary-light)}
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0;transition:color .2s}
.footer-col a:hover{color:var(--text)}
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
@media(max-width:768px){.footer-grid{grid-template-columns:1fr}}
</style>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Blog","name":"SnapAPI Developer Blog","description":"Technical articles about screenshot APIs, web rendering, and performance optimization.","url":"https://snapapi.eu/blog","publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"}}</script>
</head>
<body>
<nav>
<div class="nav-inner">
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
<div class="nav-links">
<a href="/#features">Features</a>
<a href="/pricing">Pricing</a>
<a href="/docs">API Docs</a>
<a href="/blog">Blog</a>
<a href="/#pricing" class="btn btn-primary btn-sm">Get API Key</a>
</div>
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu"></button>
</div>
</nav>
<div class="container">
<div class="page-header">
<h1>Developer Blog</h1>
<p>Technical deep-dives on screenshot APIs, web rendering, caching strategies, and building reliable developer tools.</p>
</div>
<div class="blog-grid">
<article class="blog-card">
<div class="meta"><span>March 2, 2026</span><span>8 min read</span></div>
<h2><a href="/blog/why-screenshot-api">Why You Need a Screenshot API (And Why Building Your Own Is Harder Than You Think)</a></h2>
<p>Self-hosting Puppeteer sounds easy until you're debugging zombie Chrome processes at 3 AM. Here's why dedicated screenshot APIs exist and when they make sense for your project.</p>
<a href="/blog/why-screenshot-api" class="read-more">Read article →</a>
</article>
<article class="blog-card">
<div class="meta"><span>March 2, 2026</span><span>5 min read</span></div>
<h2><a href="/blog/screenshot-api-performance">Screenshot API Performance: Caching Strategies That Actually Work</a></h2>
<p>How we reduced p95 response times by 10x with intelligent caching, CDN integration, and browser pool management. A practical guide to making screenshot APIs fast.</p>
<a href="/blog/screenshot-api-performance" class="read-more">Read article →</a>
</article>
</div>
</div>
<footer>
<div class="footer-grid">
<div class="footer-brand">
<h4>📸 SnapAPI</h4>
<p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p>
</div>
<div class="footer-col">
<h5>Product</h5>
<a href="/#features">Features</a>
<a href="/pricing">Pricing</a>
<a href="/docs">API Docs</a>
<a href="/blog">Blog</a>
</div>
<div class="footer-col">
<h5>Developers</h5>
<a href="/docs">Swagger / OpenAPI</a>
<a href="/guides/quick-start">Quick Start</a>
<a href="/changelog">Changelog</a>
</div>
<div class="footer-col">
<h5>Legal</h5>
<a href="/impressum.html">Impressum</a>
<a href="/privacy.html">Privacy Policy</a>
<a href="/terms.html">Terms of Service</a>
</div>
</div>
<div class="footer-bottom">
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Screenshot API Performance: Caching Strategies That Actually Work — SnapAPI Blog</title>
<meta name="description" content="How we reduced p95 response times by 10x with intelligent caching, CDN integration, and browser pool management. A practical guide to fast screenshot APIs.">
<link rel="canonical" href="https://snapapi.eu/blog/screenshot-api-performance">
<meta property="og:title" content="Screenshot API Performance: Caching Strategies That Actually Work">
<meta property="og:description" content="How we reduced p95 response times by 10x with intelligent caching, CDN integration, and browser pool management.">
<meta property="og:type" content="article">
<meta property="og:url" content="https://snapapi.eu/blog/screenshot-api-performance">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Screenshot API Performance: Caching Strategies That Work">
<meta name="twitter:description" content="Practical caching strategies for screenshot APIs — browser pooling, content hashing, and CDN delivery.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;--border:#1e2a3f;--border-light:#2a3752;--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--accent:#10b981;--purple:#a78bfa;--orange:#f59e0b;--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);--radius:12px;--radius-lg:16px}
html{scroll-behavior:smooth}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
a:hover{color:var(--primary)}
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.nav-links{display:flex;gap:32px;align-items:center}
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500}
.nav-links a:hover{color:var(--text)}
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);color:#fff}
.btn-sm{padding:8px 18px;font-size:.85rem}
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
@media(max-width:768px){.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}.nav-links.show{display:flex}.nav-mobile{display:block}}
.article{max-width:740px;margin:0 auto;padding:80px 24px 100px}
.article .breadcrumb{font-size:.85rem;color:var(--muted);margin-bottom:32px}
.article .breadcrumb a{color:var(--muted)}
.article .breadcrumb a:hover{color:var(--text)}
.article h1{font-size:2.5rem;font-weight:900;line-height:1.2;margin-bottom:16px}
.article .meta{font-size:.85rem;color:var(--muted);margin-bottom:48px;display:flex;gap:16px}
.article h2{font-size:1.5rem;font-weight:700;margin:48px 0 16px;color:var(--text)}
.article h3{font-size:1.2rem;font-weight:600;margin:32px 0 12px;color:var(--text)}
.article p{color:var(--text-secondary);line-height:1.8;margin-bottom:20px;font-size:1.05rem}
.article ul,.article ol{color:var(--text-secondary);margin:0 0 20px 24px;line-height:1.8}
.article li{margin-bottom:8px}
.article code{font-family:'JetBrains Mono',monospace;background:var(--card);padding:2px 8px;border-radius:4px;font-size:.9rem;color:var(--primary-light)}
.article pre{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin:0 0 24px;overflow-x:auto}
.article pre code{background:none;padding:0;font-size:.85rem;line-height:1.7;color:var(--text-secondary)}
.article blockquote{border-left:3px solid var(--primary);padding:16px 24px;margin:0 0 24px;background:var(--card);border-radius:0 var(--radius) var(--radius) 0}
.article blockquote p{margin:0;color:var(--text-secondary);font-style:italic}
.article .cta-box{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:32px;margin:48px 0;text-align:center}
.article .cta-box h3{margin-top:0;font-size:1.3rem}
.article .cta-box p{margin-bottom:20px}
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0}
.footer-col a:hover{color:var(--text)}
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
@media(max-width:768px){.footer-grid{grid-template-columns:1fr}}
</style>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"Screenshot API Performance: Caching Strategies That Actually Work","description":"How we reduced p95 response times by 10x with intelligent caching, CDN integration, and browser pool management.","datePublished":"2026-03-02","author":{"@type":"Organization","name":"SnapAPI"},"publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"},"url":"https://snapapi.eu/blog/screenshot-api-performance","mainEntityOfPage":"https://snapapi.eu/blog/screenshot-api-performance"}</script>
</head>
<body>
<nav>
<div class="nav-inner">
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
<div class="nav-links">
<a href="/#features">Features</a>
<a href="/pricing">Pricing</a>
<a href="/docs">API Docs</a>
<a href="/blog">Blog</a>
<a href="/#pricing" class="btn btn-primary btn-sm">Get API Key</a>
</div>
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu"></button>
</div>
</nav>
<article class="article">
<div class="breadcrumb"><a href="/">Home</a> / <a href="/blog">Blog</a> / Screenshot API Performance</div>
<h1>Screenshot API Performance: Caching Strategies That Actually Work</h1>
<div class="meta"><span>March 2, 2026</span><span>5 min read</span></div>
<p>Screenshot generation is inherently expensive. Every request launches a browser context, navigates to a URL, waits for rendering, and captures pixels. Without optimization, you're looking at 3-8 seconds per screenshot — unacceptable for any production API. Here's how we brought our p95 response times down from 6 seconds to under 600 milliseconds.</p>
<h2>The Three Layers of Screenshot Caching</h2>
<p>Effective caching for screenshot APIs operates at three distinct layers, each addressing a different performance bottleneck. Getting all three right is what separates a fast API from a sluggish one.</p>
<h3>Layer 1: Content-Addressable Cache</h3>
<p>The most impactful optimization is also the most straightforward: don't re-render screenshots you've already taken. We hash the request parameters — URL, viewport dimensions, format, device scale factor, and any custom options — into a cache key. If the same combination was requested recently, we serve the cached result directly.</p>
<p>The tricky part is cache invalidation. Web pages change, and serving a stale screenshot is worse than serving a slow one. Our approach uses configurable TTLs with smart defaults: 1 hour for most pages, shorter for known-dynamic content, and instant invalidation via a <code>cache: false</code> parameter when freshness is critical.</p>
<pre><code>GET /v1/screenshot?url=example.com&cache_ttl=3600
# First request: ~3s (full render)
# Subsequent requests: ~50ms (cache hit)</code></pre>
<p>This single layer eliminates 60-70% of all rendering work in a typical production workload, because many applications request the same URLs repeatedly — social preview generators, monitoring dashboards, and report builders all exhibit high cache hit ratios.</p>
<h3>Layer 2: Browser Pool Management</h3>
<p>For cache misses, the next bottleneck is browser startup time. Launching a fresh Chrome instance takes 1-2 seconds. Multiply that by concurrent requests and you have a performance disaster combined with memory pressure that triggers garbage collection storms.</p>
<p>Browser pooling solves this by maintaining a warm pool of ready-to-use browser contexts. Instead of launching Chrome per request, we allocate a pre-warmed context from the pool, navigate to the target URL, capture the screenshot, and return the context to the pool for reuse.</p>
<p>Key considerations for browser pool management:</p>
<ul>
<li><strong>Pool sizing:</strong> Too small and requests queue up. Too large and you waste memory. We dynamically scale based on request concurrency, maintaining a buffer of 2-3 idle contexts above current demand.</li>
<li><strong>Context hygiene:</strong> Each context must be fully isolated — cleared cookies, fresh storage, no shared state. A screenshot of a banking login page shouldn't leak session data to the next request.</li>
<li><strong>Health checks:</strong> Browser contexts degrade over time. Memory leaks accumulate. We cycle contexts after a configurable number of uses (default: 50) and immediately replace any that fail health checks.</li>
<li><strong>Graceful degradation:</strong> When pool capacity is exhausted, we queue requests with backpressure rather than spawning unbounded Chrome instances. Better to return a slow response than to OOM the host.</li>
</ul>
<h3>Layer 3: CDN and Edge Delivery</h3>
<p>Once a screenshot is generated, delivering it fast is a standard CDN problem — but with nuances specific to dynamically generated images. We push rendered screenshots to edge locations, so subsequent requests for the same content are served from the nearest point of presence.</p>
<p>For our EU-hosted infrastructure, this means edge nodes across European cities. A user in Berlin gets their screenshot from Frankfurt, not from the origin server in Nuremberg. The latency difference is 5-10ms versus 30-50ms — small in absolute terms, but it compounds when your API is called in a rendering pipeline.</p>
<h2>Beyond Caching: Render Path Optimization</h2>
<p>Caching handles repeated requests, but the cold-start path still needs to be fast. Several techniques reduce raw rendering time:</p>
<ul>
<li><strong>Eager navigation:</strong> Start loading the page before all parameters are validated. By the time we've checked auth and parsed options, the page is already halfway loaded.</li>
<li><strong>Network idle detection:</strong> Instead of waiting a fixed duration, we monitor network activity and capture as soon as the page stabilizes. This adapts to fast-loading static sites (200ms) and heavy SPAs (2-3s) automatically.</li>
<li><strong>Resource blocking:</strong> Optional blocking of ads, trackers, and analytics scripts that slow page loads without affecting visual output. This alone can save 500ms-1s on ad-heavy pages.</li>
<li><strong>Viewport pre-configuration:</strong> Setting viewport dimensions before navigation avoids layout recalculation after the page loads, eliminating a common source of visual inconsistency and wasted time.</li>
</ul>
<h2>Measuring What Matters</h2>
<p>Performance optimization without measurement is guesswork. The metrics that actually matter for a screenshot API are:</p>
<ul>
<li><strong>p50/p95/p99 response times</strong> — split by cache hit vs. miss. Your p95 cache-miss time is the number your users feel most.</li>
<li><strong>Cache hit ratio</strong> — anything below 50% suggests your TTL strategy needs work or your traffic patterns are unusually diverse.</li>
<li><strong>Pool utilization</strong> — consistently above 80% means you need more capacity. Consistently below 30% means you're wasting memory.</li>
<li><strong>Error rate by type</strong> — timeouts, rendering failures, and OOM kills each have different root causes and different fixes.</li>
</ul>
<p>We expose these metrics via a <code>/health</code> endpoint and internal dashboards, making it easy to spot performance regressions before they affect users.</p>
<h2>Results</h2>
<p>With all three caching layers active plus render path optimizations, our production numbers look like this: cache-hit responses average 45ms at p50 and 120ms at p95. Cache-miss responses average 1.8s at p50 and 2.9s at p95. The overall cache hit ratio sits at 72% across all customers, meaning nearly three-quarters of all screenshot requests are served without touching a browser.</p>
<p>For most API consumers, the performance feels instant — because for the majority of their requests, it is. That's the power of treating caching as a first-class architectural concern rather than an afterthought bolted on later.</p>
<div class="cta-box">
<h3>Experience the Speed</h3>
<p>Try SnapAPI's playground and see sub-second screenshot delivery in action. No signup required for your first request.</p>
<a href="/#playground" class="btn btn-primary">Open Playground →</a>
</div>
</article>
<footer>
<div class="footer-grid">
<div class="footer-brand">
<h4>📸 SnapAPI</h4>
<p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p>
</div>
<div class="footer-col">
<h5>Product</h5>
<a href="/#features">Features</a>
<a href="/pricing">Pricing</a>
<a href="/docs">API Docs</a>
<a href="/blog">Blog</a>
</div>
<div class="footer-col">
<h5>Developers</h5>
<a href="/docs">Swagger / OpenAPI</a>
<a href="/guides/quick-start">Quick Start</a>
<a href="/changelog">Changelog</a>
</div>
<div class="footer-col">
<h5>Legal</h5>
<a href="/impressum.html">Impressum</a>
<a href="/privacy.html">Privacy Policy</a>
<a href="/terms.html">Terms of Service</a>
</div>
</div>
<div class="footer-bottom">
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Why You Need a Screenshot API — SnapAPI Blog</title>
<meta name="description" content="Self-hosting Puppeteer sounds easy until you're debugging zombie Chrome processes at 3 AM. Learn why dedicated screenshot APIs exist and when they make sense.">
<link rel="canonical" href="https://snapapi.eu/blog/why-screenshot-api">
<meta property="og:title" content="Why You Need a Screenshot API">
<meta property="og:description" content="Self-hosting Puppeteer sounds easy until you're debugging zombie Chrome processes at 3 AM. Learn why dedicated screenshot APIs exist.">
<meta property="og:type" content="article">
<meta property="og:url" content="https://snapapi.eu/blog/why-screenshot-api">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Why You Need a Screenshot API">
<meta name="twitter:description" content="Why dedicated screenshot APIs exist and when they make sense for your project.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;--card-hover:#1a2235;--border:#1e2a3f;--border-light:#2a3752;--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;--accent:#10b981;--purple:#a78bfa;--orange:#f59e0b;--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);--radius:12px;--radius-lg:16px}
html{scroll-behavior:smooth}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased}
a{color:var(--primary-light);text-decoration:none;transition:color .2s}
a:hover{color:var(--primary)}
nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.nav-links{display:flex;gap:32px;align-items:center}
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500}
.nav-links a:hover{color:var(--text)}
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:10px;font-weight:600;font-size:.95rem;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
.btn-primary{background:var(--primary);color:#fff;box-shadow:0 4px 20px rgba(79,143,255,0.3)}
.btn-primary:hover{background:var(--primary-dark);transform:translateY(-1px);color:#fff}
.btn-sm{padding:8px 18px;font-size:.85rem}
.nav-mobile{display:none;background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer}
@media(max-width:768px){.nav-links{display:none;flex-direction:column;position:absolute;top:64px;left:0;right:0;background:rgba(10,14,23,0.97);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:16px 24px;gap:16px;z-index:99}.nav-links.show{display:flex}.nav-mobile{display:block}}
.article{max-width:740px;margin:0 auto;padding:80px 24px 100px}
.article .breadcrumb{font-size:.85rem;color:var(--muted);margin-bottom:32px}
.article .breadcrumb a{color:var(--muted)}
.article .breadcrumb a:hover{color:var(--text)}
.article h1{font-size:2.5rem;font-weight:900;line-height:1.2;margin-bottom:16px}
.article .meta{font-size:.85rem;color:var(--muted);margin-bottom:48px;display:flex;gap:16px}
.article h2{font-size:1.5rem;font-weight:700;margin:48px 0 16px;color:var(--text)}
.article h3{font-size:1.2rem;font-weight:600;margin:32px 0 12px;color:var(--text)}
.article p{color:var(--text-secondary);line-height:1.8;margin-bottom:20px;font-size:1.05rem}
.article ul,.article ol{color:var(--text-secondary);margin:0 0 20px 24px;line-height:1.8}
.article li{margin-bottom:8px}
.article code{font-family:'JetBrains Mono',monospace;background:var(--card);padding:2px 8px;border-radius:4px;font-size:.9rem;color:var(--primary-light)}
.article pre{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin:0 0 24px;overflow-x:auto}
.article pre code{background:none;padding:0;font-size:.85rem;line-height:1.7;color:var(--text-secondary)}
.article blockquote{border-left:3px solid var(--primary);padding:16px 24px;margin:0 0 24px;background:var(--card);border-radius:0 var(--radius) var(--radius) 0}
.article blockquote p{margin:0;color:var(--text-secondary);font-style:italic}
.article .cta-box{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:32px;margin:48px 0;text-align:center}
.article .cta-box h3{margin-top:0;font-size:1.3rem}
.article .cta-box p{margin-bottom:20px}
footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(--bg2)}
.footer-grid{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
.footer-brand h4{font-size:1.1rem;font-weight:800;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.footer-brand p{color:var(--muted);font-size:.85rem;line-height:1.6;max-width:280px}
.footer-col h5{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:16px}
.footer-col a{display:block;color:var(--text-secondary);font-size:.88rem;padding:4px 0}
.footer-col a:hover{color:var(--text)}
.footer-bottom{max-width:1180px;margin:0 auto;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;font-size:.8rem;color:var(--muted)}
@media(max-width:768px){.footer-grid{grid-template-columns:1fr}}
</style>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"Why You Need a Screenshot API (And Why Building Your Own Is Harder Than You Think)","description":"Self-hosting Puppeteer sounds easy until you're debugging zombie Chrome processes at 3 AM.","datePublished":"2026-03-02","author":{"@type":"Organization","name":"SnapAPI"},"publisher":{"@type":"Organization","name":"SnapAPI","url":"https://snapapi.eu"},"url":"https://snapapi.eu/blog/why-screenshot-api","mainEntityOfPage":"https://snapapi.eu/blog/why-screenshot-api"}</script>
</head>
<body>
<nav>
<div class="nav-inner">
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
<div class="nav-links">
<a href="/#features">Features</a>
<a href="/pricing">Pricing</a>
<a href="/docs">API Docs</a>
<a href="/blog">Blog</a>
<a href="/#pricing" class="btn btn-primary btn-sm">Get API Key</a>
</div>
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu"></button>
</div>
</nav>
<article class="article">
<div class="breadcrumb"><a href="/">Home</a> / <a href="/blog">Blog</a> / Why You Need a Screenshot API</div>
<h1>Why You Need a Screenshot API (And Why Building Your Own Is Harder Than You Think)</h1>
<div class="meta"><span>March 2, 2026</span><span>8 min read</span></div>
<p>Every developer has the same thought at some point: "I just need a screenshot of a webpage. How hard can it be?" You spin up a quick Node.js script with Puppeteer, take a screenshot, and it works. Ship it. Done. Right?</p>
<p>Not quite. What starts as a ten-line script inevitably grows into a sprawling system that consumes more engineering time than the feature it was supposed to support. Let's talk about why screenshot APIs exist, what makes them genuinely hard to build, and when it makes sense to use one instead of rolling your own.</p>
<h2>The Puppeteer Trap</h2>
<p>Puppeteer is an excellent tool. It gives you full control over a headless Chrome instance, and for local development or one-off scripts, it's perfect. The problem isn't Puppeteer itself — it's what happens when you try to run it in production at any meaningful scale.</p>
<p>Here's what your "simple screenshot service" needs to handle in the real world:</p>
<ul>
<li><strong>Resource management:</strong> Each Chrome instance consumes 200-500 MB of RAM. Running ten concurrent screenshots means you need several gigabytes of memory just for the browser processes. Without careful pooling, you'll OOM your server in hours.</li>
<li><strong>Zombie processes:</strong> Chrome tabs crash. Pages hang on infinite loops. Scripts run forever. You need process monitoring, timeouts, and cleanup routines that kill orphaned processes before they eat your server alive.</li>
<li><strong>Font rendering:</strong> That page looks perfect on your MacBook but renders with missing glyphs on your Ubuntu server. You need a curated font library — CJK fonts alone add hundreds of megabytes to your Docker image.</li>
<li><strong>Network reliability:</strong> Target sites go down, return 503s, redirect endlessly, or serve CAPTCHAs. Your service needs retry logic, circuit breakers, and meaningful error reporting.</li>
<li><strong>Security:</strong> Accepting arbitrary URLs means you're opening your server to SSRF attacks. Without proper validation, someone will use your screenshot service to probe your internal network, access cloud metadata endpoints, or worse.</li>
</ul>
<p>Each of these is a project in itself. Together, they represent weeks of engineering work that has nothing to do with your actual product.</p>
<h2>The Hidden Costs of Self-Hosting</h2>
<p>Beyond the initial implementation, self-hosted screenshot services have ongoing costs that are easy to underestimate:</p>
<h3>Infrastructure</h3>
<p>Chrome is resource-hungry. A production screenshot service needs dedicated compute resources — you can't just tack it onto your existing application server. You're looking at dedicated containers or VMs with enough CPU and RAM to handle concurrent rendering, plus autoscaling if your traffic is bursty.</p>
<h3>Maintenance</h3>
<p>Chrome updates break things. Puppeteer version X works with Chrome version Y but not Z. Dependencies shift. Security patches need applying. Someone on your team needs to own this service, monitor it, and fix it when it breaks at 3 AM on a Saturday — because it will break at 3 AM on a Saturday.</p>
<h3>Edge Cases</h3>
<p>The long tail of web rendering is brutal. SPAs that need JavaScript execution. Pages with lazy-loaded content that require scroll simulation. Cookie consent banners that block the entire viewport. Dark mode detection. Viewport emulation for mobile screenshots. Each edge case is another conditional in your codebase, another test to maintain, another thing that can break.</p>
<blockquote>
<p>"The first 80% of a screenshot service takes a weekend. The remaining 20% takes six months."</p>
</blockquote>
<h2>When a Screenshot API Makes Sense</h2>
<p>A dedicated screenshot API isn't always the right choice. Here's a framework for deciding:</p>
<p><strong>Use a screenshot API when:</strong></p>
<ul>
<li>Screenshots aren't your core product — they support a feature (social previews, PDF reports, monitoring dashboards)</li>
<li>You need reliability at scale without dedicating engineering resources to browser infrastructure</li>
<li>You need consistent rendering across different types of pages without handling every edge case yourself</li>
<li>Compliance matters — EU hosting, GDPR, data residency requirements are handled for you</li>
<li>You want to move fast and ship the actual feature instead of building plumbing</li>
</ul>
<p><strong>Build your own when:</strong></p>
<ul>
<li>Screenshots are your core product and you need deep customization</li>
<li>You're taking screenshots of your own application (not arbitrary URLs) in a controlled environment</li>
<li>Volume is very low (a few screenshots per day) and reliability isn't critical</li>
<li>You have specific security requirements that prevent using third-party services</li>
</ul>
<h2>What to Look for in a Screenshot API</h2>
<p>Not all screenshot APIs are created equal. Here are the things that actually matter when evaluating options:</p>
<ul>
<li><strong>Rendering quality:</strong> Does it handle SPAs, web fonts, and modern CSS? Can it wait for dynamic content to load? The difference between a screenshot API that renders the loading spinner and one that waits for the actual content is everything.</li>
<li><strong>Response time:</strong> Screenshot generation is inherently slow (browsers are complex), but good APIs use caching, browser pooling, and CDN delivery to minimize latency. Look for p95 response times under 3 seconds for cached content.</li>
<li><strong>Output formats:</strong> PNG for quality, JPEG for size, WebP for the best of both. Full-page capture, viewport-only, or element-specific — flexibility matters when your use case evolves.</li>
<li><strong>SSRF protection:</strong> Any API that accepts arbitrary URLs must validate them against internal network ranges, metadata endpoints, and redirect chains. This isn't optional — it's a security fundamental.</li>
<li><strong>Data residency:</strong> If you're building for European users, your screenshots shouldn't be rendered on servers in Virginia. Look for APIs with explicit EU hosting and GDPR compliance documentation.</li>
<li><strong>Transparent pricing:</strong> Per-screenshot pricing is the standard, but watch for hidden costs — bandwidth charges, storage fees, or premium features locked behind enterprise tiers.</li>
</ul>
<h2>The Build vs. Buy Calculation</h2>
<p>Let's do the math. A senior developer costs roughly €80-120/hour fully loaded. Building a production-ready screenshot service takes 2-4 weeks minimum. That's €6,400-€19,200 in development costs alone, before ongoing maintenance.</p>
<p>A screenshot API at typical pricing (€9-79/month depending on volume) would need to run for years before matching the upfront development cost. And during those years, someone else handles the infrastructure, the Chrome updates, and the 3 AM incidents.</p>
<p>The calculation becomes even clearer when you factor in opportunity cost. Those 2-4 weeks of engineering time could go toward features that actually differentiate your product.</p>
<h2>Conclusion</h2>
<p>Screenshot APIs exist because taking screenshots of web pages at scale is a deceptively hard infrastructure problem. The initial implementation is easy; the production hardening is where the real work lives. For most teams, delegating this to a specialized service is the pragmatic choice — it lets you ship the feature your users actually care about instead of maintaining browser infrastructure.</p>
<p>The best engineering decisions aren't about what you <em>can</em> build. They're about what you <em>should</em> build. Save your complexity budget for the things that make your product unique.</p>
<div class="cta-box">
<h3>Try SnapAPI Free</h3>
<p>Get your first 100 screenshots free. No credit card required. EU-hosted, GDPR compliant, and ready in under a minute.</p>
<a href="/#playground" class="btn btn-primary">Open Playground →</a>
</div>
</article>
<footer>
<div class="footer-grid">
<div class="footer-brand">
<h4>📸 SnapAPI</h4>
<p>The EU-hosted screenshot API for developers. Convert any URL to a pixel-perfect image with a simple API call.</p>
</div>
<div class="footer-col">
<h5>Product</h5>
<a href="/#features">Features</a>
<a href="/pricing">Pricing</a>
<a href="/docs">API Docs</a>
<a href="/blog">Blog</a>
</div>
<div class="footer-col">
<h5>Developers</h5>
<a href="/docs">Swagger / OpenAPI</a>
<a href="/guides/quick-start">Quick Start</a>
<a href="/changelog">Changelog</a>
</div>
<div class="footer-col">
<h5>Legal</h5>
<a href="/impressum.html">Impressum</a>
<a href="/privacy.html">Privacy Policy</a>
<a href="/terms.html">Terms of Service</a>
</div>
</div>
<div class="footer-bottom">
<span>© 2026 Cloonar Technologies GmbH · FN 631089y · ATU81280034</span>
<span>EU-hosted 🇪🇺 · All data stays in Europe</span>
</div>
</footer>
</body>
</html>

View file

@ -246,6 +246,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
<a href="/usage">Usage</a> <a href="/usage">Usage</a>
<a href="/compare">Compare</a> <a href="/compare">Compare</a>
<a href="/guides/quick-start">Quick Start</a> <a href="/guides/quick-start">Quick Start</a>
<a href="/blog">Blog</a>
<a href="#pricing" class="btn btn-primary btn-sm nav-cta">Get API Key</a> <a href="#pricing" class="btn btn-primary btn-sm nav-cta">Get API Key</a>
</div> </div>
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu"></button> <button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu"></button>
@ -739,6 +740,7 @@ screenshot = snap.<span class="fn">capture</span>(
<a href="/health">Status</a> <a href="/health">Status</a>
<a href="/usage">Usage Dashboard</a> <a href="/usage">Usage Dashboard</a>
<a href="/changelog">Changelog</a> <a href="/changelog">Changelog</a>
<a href="/blog">Blog</a>
</div> </div>
<div class="footer-col"> <div class="footer-col">
<h5>Legal</h5> <h5>Legal</h5>

View file

@ -10,6 +10,9 @@
<url><loc>https://snapapi.eu/pricing</loc><changefreq>monthly</changefreq><priority>0.8</priority></url> <url><loc>https://snapapi.eu/pricing</loc><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://snapapi.eu/changelog</loc><changefreq>monthly</changefreq><priority>0.6</priority></url> <url><loc>https://snapapi.eu/changelog</loc><changefreq>monthly</changefreq><priority>0.6</priority></url>
<url><loc>https://snapapi.eu/status</loc><changefreq>always</changefreq><priority>0.3</priority></url> <url><loc>https://snapapi.eu/status</loc><changefreq>always</changefreq><priority>0.3</priority></url>
<url><loc>https://snapapi.eu/blog</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>
<url><loc>https://snapapi.eu/blog/why-screenshot-api</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>
<url><loc>https://snapapi.eu/blog/screenshot-api-performance</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>
<url><loc>https://snapapi.eu/impressum.html</loc><changefreq>yearly</changefreq><priority>0.2</priority></url> <url><loc>https://snapapi.eu/impressum.html</loc><changefreq>yearly</changefreq><priority>0.2</priority></url>
<url><loc>https://snapapi.eu/privacy.html</loc><changefreq>yearly</changefreq><priority>0.2</priority></url> <url><loc>https://snapapi.eu/privacy.html</loc><changefreq>yearly</changefreq><priority>0.2</priority></url>
<url><loc>https://snapapi.eu/terms.html</loc><changefreq>yearly</changefreq><priority>0.2</priority></url> <url><loc>https://snapapi.eu/terms.html</loc><changefreq>yearly</changefreq><priority>0.2</priority></url>

View file

@ -141,6 +141,10 @@ app.get("/guides/quick-start", (_req, res) => res.redirect(301, "/guides/quick-s
app.get("/pricing", (_req, res) => res.redirect(301, "/pricing.html")); app.get("/pricing", (_req, res) => res.redirect(301, "/pricing.html"));
app.get("/changelog", (_req, res) => res.redirect(301, "/changelog.html")); app.get("/changelog", (_req, res) => res.redirect(301, "/changelog.html"));
// Blog routes
app.get("/blog", (_req, res) => res.redirect(301, "/blog.html"));
app.get("/blog/:slug([^.]+)", (req, res) => res.redirect(301, `/blog/${req.params.slug}.html`));
// Static files (landing page) // Static files (landing page)
app.use(express.static(path.join(__dirname, "../public"), { etag: true })); app.use(express.static(path.join(__dirname, "../public"), { etag: true }));

View file

@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest'
import request from 'supertest'
import express from 'express'
import path from 'path'
import { fileURLToPath } from 'url'
import fs from 'fs'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicDir = path.join(__dirname, '../../../public')
function createApp() {
const app = express()
// Blog routes matching index.ts
app.get('/blog', (_req, res) => res.redirect(301, '/blog.html'))
app.get('/blog/:slug([^.]+)', (_req, res) => res.redirect(301, `/blog/${_req.params.slug}.html`))
app.use(express.static(publicDir, { etag: true }))
return app
}
describe('Blog Index Page', () => {
const app = createApp()
it('GET /blog.html returns 200', async () => {
const res = await request(app).get('/blog.html')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toContain('text/html')
})
it('GET /blog redirects 301 to /blog.html', async () => {
const res = await request(app).get('/blog')
expect(res.status).toBe(301)
expect(res.headers.location).toBe('/blog.html')
})
it('contains required SEO elements', async () => {
const res = await request(app).get('/blog.html')
const html = res.text
expect(html).toMatch(/<title>.+<\/title>/)
expect(html).toMatch(/<meta name="description" content=".+"/)
expect(html).toMatch(/<meta property="og:title"/)
expect(html).toMatch(/<meta property="og:description"/)
expect(html).toMatch(/<link rel="canonical" href="https:\/\/snapapi\.eu\/blog"/)
expect(html).toMatch(/<meta name="twitter:card"/)
})
it('contains JSON-LD Blog schema', async () => {
const res = await request(app).get('/blog.html')
const html = res.text
expect(html).toContain('"@type":"Blog"')
})
it('links to both blog posts', async () => {
const res = await request(app).get('/blog.html')
const html = res.text
expect(html).toContain('/blog/why-screenshot-api')
expect(html).toContain('/blog/screenshot-api-performance')
})
})
describe('Blog Post: Why Screenshot API', () => {
const app = createApp()
it('GET /blog/why-screenshot-api.html returns 200', async () => {
const res = await request(app).get('/blog/why-screenshot-api.html')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toContain('text/html')
})
it('GET /blog/why-screenshot-api redirects 301 to .html', async () => {
const res = await request(app).get('/blog/why-screenshot-api')
expect(res.status).toBe(301)
expect(res.headers.location).toBe('/blog/why-screenshot-api.html')
})
it('contains required SEO elements', async () => {
const res = await request(app).get('/blog/why-screenshot-api.html')
const html = res.text
expect(html).toMatch(/<title>.+<\/title>/)
expect(html).toMatch(/<meta name="description" content=".+"/)
expect(html).toMatch(/<meta property="og:title"/)
expect(html).toMatch(/<meta property="og:description"/)
expect(html).toMatch(/<link rel="canonical" href="https:\/\/snapapi\.eu\/blog\/why-screenshot-api"/)
expect(html).toMatch(/<meta name="twitter:card"/)
})
it('contains JSON-LD BlogPosting schema', async () => {
const res = await request(app).get('/blog/why-screenshot-api.html')
const html = res.text
expect(html).toContain('"@type":"BlogPosting"')
})
it('has substantial content (~800 words)', async () => {
const res = await request(app).get('/blog/why-screenshot-api.html')
const text = res.text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')
const wordCount = text.split(' ').filter(w => w.length > 0).length
expect(wordCount).toBeGreaterThan(600)
})
it('contains relevant keywords', async () => {
const res = await request(app).get('/blog/why-screenshot-api.html')
const html = res.text
expect(html).toContain('screenshot API')
expect(html).toContain('Puppeteer')
})
})
describe('Blog Post: Screenshot API Performance', () => {
const app = createApp()
it('GET /blog/screenshot-api-performance.html returns 200', async () => {
const res = await request(app).get('/blog/screenshot-api-performance.html')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toContain('text/html')
})
it('GET /blog/screenshot-api-performance redirects 301 to .html', async () => {
const res = await request(app).get('/blog/screenshot-api-performance')
expect(res.status).toBe(301)
expect(res.headers.location).toBe('/blog/screenshot-api-performance.html')
})
it('contains required SEO elements', async () => {
const res = await request(app).get('/blog/screenshot-api-performance.html')
const html = res.text
expect(html).toMatch(/<title>.+<\/title>/)
expect(html).toMatch(/<meta name="description" content=".+"/)
expect(html).toMatch(/<meta property="og:title"/)
expect(html).toMatch(/<meta property="og:description"/)
expect(html).toMatch(/<link rel="canonical" href="https:\/\/snapapi\.eu\/blog\/screenshot-api-performance"/)
expect(html).toMatch(/<meta name="twitter:card"/)
})
it('contains JSON-LD BlogPosting schema', async () => {
const res = await request(app).get('/blog/screenshot-api-performance.html')
const html = res.text
expect(html).toContain('"@type":"BlogPosting"')
})
it('has substantial content (~600 words)', async () => {
const res = await request(app).get('/blog/screenshot-api-performance.html')
const text = res.text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')
const wordCount = text.split(' ').filter(w => w.length > 0).length
expect(wordCount).toBeGreaterThan(400)
})
it('contains relevant keywords', async () => {
const res = await request(app).get('/blog/screenshot-api-performance.html')
const html = res.text
expect(html).toContain('caching')
expect(html).toContain('performance')
})
})
describe('Blog Sitemap & Navigation', () => {
it('sitemap contains blog URLs', () => {
const sitemap = fs.readFileSync(path.join(publicDir, 'sitemap.xml'), 'utf-8')
expect(sitemap).toContain('https://snapapi.eu/blog')
expect(sitemap).toContain('https://snapapi.eu/blog/why-screenshot-api')
expect(sitemap).toContain('https://snapapi.eu/blog/screenshot-api-performance')
})
it('index.html has Blog link in nav or footer', () => {
const index = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf-8')
expect(index).toContain('/blog')
})
})