From 740c70f905a31851d1503bf55a85e61616ca3d9c Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Tue, 3 Mar 2026 18:06:56 +0100 Subject: [PATCH] Add status route tests, OG images blog post, and blog tests - Create src/routes/__tests__/status.test.ts (GET /status and /status.html) - Add blog post: public/blog/automating-og-images.html (~1000 words) - Update public/blog.html with new post entry - Update public/sitemap.xml with new URL - Add blog tests for automating-og-images post - Update existing blog tests for new post references Tests: 332 passed, 1 skipped --- public/blog.html | 7 + public/blog/automating-og-images.html | 284 ++++++++++++++++++++++++++ public/sitemap.xml | 1 + src/routes/__tests__/blog.test.ts | 51 ++++- src/routes/__tests__/status.test.ts | 36 ++++ 5 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 public/blog/automating-og-images.html create mode 100644 src/routes/__tests__/status.test.ts diff --git a/public/blog.html b/public/blog.html index 4851054..3cb35e3 100644 --- a/public/blog.html +++ b/public/blog.html @@ -93,6 +93,13 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
+ +
March 2, 20268 min read

Why You Need a Screenshot API (And Why Building Your Own Is Harder Than You Think)

diff --git a/public/blog/automating-og-images.html b/public/blog/automating-og-images.html new file mode 100644 index 0000000..de822ab --- /dev/null +++ b/public/blog/automating-og-images.html @@ -0,0 +1,284 @@ + + + + + +Automating OG Image Generation with Screenshot APIs โ€” SnapAPI Blog + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +

Automating OG Image Generation with Screenshot APIs

+
March 3, 20267 min read
+ +

You've just published a new blog post. You share it on Twitter, LinkedIn, and Slack. But instead of a rich preview with a beautiful image, your link shows up as a sad little text snippet โ€” or worse, a broken image placeholder. Sound familiar?

+ +

Open Graph (OG) images are the social preview cards that appear when someone shares your URL. They're one of those things that seem trivial until you realize they directly impact click-through rates. Posts with compelling preview images get 2-3x more engagement than plain text links. Yet most developers either skip them entirely or spend hours manually designing them in Figma for each page.

+ +

There's a better way: use a screenshot API to generate OG images automatically, on the fly, for every single page on your site.

+ +

The OG Image Problem

+ +

Let's start with why this is harder than it should be. The Open Graph protocol is simple โ€” you add a few meta tags to your HTML, including og:image, and social platforms use that image as a preview. The challenge isn't the protocol. It's producing the images themselves.

+ +

The traditional approaches all have significant drawbacks:

+ +
    +
  • Manual design: Create each image by hand in Figma or Canva. Looks great, doesn't scale. You'll stop doing it after the fifth blog post.
  • +
  • Static templates: Use the same generic image for everything. Low effort, but also low engagement โ€” every link looks identical.
  • +
  • Canvas/SVG generation: Write code to programmatically compose images using node-canvas, Sharp, or SVG rendering. Works, but you're essentially building a graphics engine. Text wrapping, font rendering, and layout calculations become your problem.
  • +
  • Self-hosted Puppeteer: Spin up a headless browser to screenshot an HTML template. Powerful, but now you're managing Chrome instances in production โ€” the exact problem we discussed in our previous article.
  • +
+ +

Each approach trades off between quality, scalability, and engineering effort. What if you could get all three?

+ +

The Screenshot API Approach

+ +

The idea is elegant in its simplicity: design your OG image as an HTML page, then use a screenshot API to render it as a PNG. You get the full power of CSS for layout and styling, web fonts for typography, and zero infrastructure to maintain.

+ +

Here's the workflow:

+ +
    +
  1. Create an HTML template for your OG images (a simple page with your branding, title, and any dynamic content)
  2. +
  3. Host that template at a URL, passing page-specific data via query parameters
  4. +
  5. Call a screenshot API to capture it at 1200ร—630 pixels (the standard OG image size)
  6. +
  7. Set the resulting image URL as your og:image meta tag
  8. +
+ +

The HTML Template

+ +

Your OG image template is just HTML and CSS. This means you can use flexbox for layout, Google Fonts for typography, gradients, shadows โ€” anything the browser can render. Here's a minimal example:

+ +
<div style="width:1200px;height:630px;display:flex;
+  align-items:center;justify-content:center;
+  background:linear-gradient(135deg,#667eea,#764ba2);
+  font-family:'Inter',sans-serif;padding:60px">
+  <h1 style="color:#fff;font-size:56px;
+    text-align:center;line-height:1.2">
+    {{title}}
+  </h1>
+</div>
+ +

Replace {{title}} with your page title dynamically โ€” either server-side or via query parameters โ€” and you have a unique OG image for every page.

+ +

Calling the API

+ +

With SnapAPI, generating the OG image is a single API call:

+ +
curl "https://api.snapapi.eu/v1/screenshot?url=https://yoursite.com/og-template?title=My+Blog+Post&width=1200&height=630&format=png" \
+  -H "X-API-Key: your-api-key" \
+  --output og-image.png
+ +

That's it. The API launches a browser, renders your template, captures it at exactly 1200ร—630, and returns a pixel-perfect PNG. No Chrome processes to manage, no memory leaks to debug, no zombie browsers consuming your server's RAM.

+ +

Static vs. Dynamic Generation

+ +

There are two strategies for integrating OG images into your site, and the right choice depends on your content velocity and caching requirements.

+ +

Build-Time Generation (Static)

+ +

Generate all OG images during your build step and serve them as static files. This works well for blogs and documentation sites where content changes infrequently. Your CI pipeline calls the screenshot API for each page, saves the PNGs to your public directory, and deploys them alongside your HTML.

+ +

The advantage is zero runtime dependencies โ€” your OG images are just static files on a CDN. The downside is that adding or updating content requires a rebuild.

+ +

On-Demand Generation (Dynamic)

+ +

Generate OG images on the fly when they're first requested, then cache them aggressively. This is ideal for sites with user-generated content, e-commerce product pages, or any scenario where you can't enumerate all pages at build time.

+ +

A typical implementation uses a serverless function or edge worker as a proxy:

+ +
// /api/og-image.ts (Edge Function)
+export default async function handler(req) {
+  const { title, subtitle } = new URL(req.url).searchParams;
+  const templateUrl = `https://yoursite.com/og?title=${title}`;
+
+  const response = await fetch(
+    `https://api.snapapi.eu/v1/screenshot?` +
+    `url=${encodeURIComponent(templateUrl)}` +
+    `&width=1200&height=630&format=png`,
+    { headers: { "X-API-Key": process.env.SNAPAPI_KEY } }
+  );
+
+  return new Response(response.body, {
+    headers: {
+      "Content-Type": "image/png",
+      "Cache-Control": "public, max-age=86400, s-maxage=604800",
+    },
+  });
+}
+ +

With proper cache headers, each OG image is generated exactly once and then served from the CDN for subsequent requests. Your og:image tag simply points to /api/og-image?title=My+Page+Title.

+ +

Design Tips for Better OG Images

+ +

Since you're designing with HTML and CSS, you have enormous creative freedom. But social preview images have specific constraints worth respecting:

+ +
    +
  • Size matters: Always render at 1200ร—630 pixels. This is the standard that works across Twitter, LinkedIn, Facebook, and Slack. Smaller images get upscaled and look blurry.
  • +
  • Keep text large: Your title should be at least 48px. Remember, these images are often displayed at small sizes on mobile feeds. If you can't read the text at 300px wide, it's too small.
  • +
  • Brand consistency: Include your logo and use your brand colors. OG images are branding real estate โ€” make every share reinforce your visual identity.
  • +
  • Contrast is king: Social feeds are visually noisy. High contrast between text and background ensures your preview stands out. Dark backgrounds with light text tend to perform well.
  • +
  • Avoid text near edges: Some platforms crop OG images slightly. Keep all important content within 90% of the image area.
  • +
  • Test everywhere: Use tools like opengraph.xyz to preview how your OG images render across different platforms before shipping.
  • +
+ +

Performance Considerations

+ +

OG images don't need to be fast for end users โ€” they're fetched by social platform crawlers, not by browsers loading your page. That said, there are performance aspects worth considering:

+ +
    +
  • Cache aggressively: Set long Cache-Control headers. Social platforms cache OG images themselves, but your CDN should too. A week or more is typical.
  • +
  • Use PNG for text-heavy images: PNG preserves sharp text better than JPEG. The file sizes are larger, but since these images are fetched by crawlers (not loaded on every page view), it's an acceptable tradeoff.
  • +
  • Pre-generate when possible: For content you know about at build time, generate OG images during the build. Save the runtime generation for truly dynamic content.
  • +
  • Monitor your API usage: Each unique OG image is one API call. If you have 1,000 blog posts and rebuild nightly, that's 1,000 calls per day. Most screenshot APIs (including SnapAPI) offer generous free tiers and predictable per-screenshot pricing, but it's worth tracking.
  • +
+ +

Beyond Social Previews

+ +

Once you've set up the infrastructure for OG images, you'll find other uses for dynamically generated images. The same template approach works for:

+ +
    +
  • Email headers: Personalized banner images for marketing emails
  • +
  • Certificate generation: Course completion certificates with the student's name rendered beautifully
  • +
  • Invoice previews: Thumbnail previews of invoices in dashboard UIs
  • +
  • Documentation screenshots: Auto-generated screenshots of your own UI for docs that stay current
  • +
+ +

The pattern is always the same: design it in HTML, screenshot it via API, serve the result. HTML becomes your universal image template language.

+ +

Conclusion

+ +

OG images shouldn't be an afterthought, and they shouldn't require a design team to maintain. By combining HTML templates with a screenshot API, you get the visual quality of hand-designed images with the scalability of automation. Every page gets a unique, branded social preview โ€” automatically.

+ +

The best part? Your OG image templates are just code. They live in version control, they're easy to iterate on, and they update everywhere when you change your branding. No more stale Figma exports, no more missing preview images, no more sad text-only link shares.

+ +

Start with a simple template, wire it up to a screenshot API, add caching, and you're done. Your links will never look naked again.

+ +
+

Generate OG Images with SnapAPI

+

100 free screenshots to get started. Pixel-perfect rendering, EU-hosted, and ready in under a minute. Perfect for automated OG image generation.

+ Try the Playground โ†’ +
+
+ +
+ + + + diff --git a/public/sitemap.xml b/public/sitemap.xml index c0208c0..3164f9a 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -13,6 +13,7 @@ https://snapapi.eu/blogweekly0.8 https://snapapi.eu/blog/why-screenshot-apimonthly0.7 https://snapapi.eu/blog/screenshot-api-performancemonthly0.7 + https://snapapi.eu/blog/automating-og-imagesmonthly0.7 https://snapapi.eu/impressum.htmlyearly0.2 https://snapapi.eu/privacy.htmlyearly0.2 https://snapapi.eu/terms.htmlyearly0.2 diff --git a/src/routes/__tests__/blog.test.ts b/src/routes/__tests__/blog.test.ts index 301e9ec..8b210d3 100644 --- a/src/routes/__tests__/blog.test.ts +++ b/src/routes/__tests__/blog.test.ts @@ -51,11 +51,12 @@ describe('Blog Index Page', () => { expect(html).toContain('"@type":"Blog"') }) - it('links to both blog posts', async () => { + it('links to all 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') + expect(html).toContain('/blog/automating-og-images') }) }) @@ -153,12 +154,60 @@ describe('Blog Post: Screenshot API Performance', () => { }) }) +describe('Blog Post: Automating OG Images', () => { + const app = createApp() + + it('GET /blog/automating-og-images.html returns 200', async () => { + const res = await request(app).get('/blog/automating-og-images.html') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toContain('text/html') + }) + + it('GET /blog/automating-og-images redirects 301 to .html', async () => { + const res = await request(app).get('/blog/automating-og-images') + expect(res.status).toBe(301) + expect(res.headers.location).toBe('/blog/automating-og-images.html') + }) + + it('contains required SEO elements', async () => { + const res = await request(app).get('/blog/automating-og-images.html') + const html = res.text + expect(html).toMatch(/.+<\/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\/automating-og-images"/) + expect(html).toMatch(/<meta name="twitter:card"/) + }) + + it('contains JSON-LD BlogPosting schema', async () => { + const res = await request(app).get('/blog/automating-og-images.html') + const html = res.text + expect(html).toContain('"@type":"BlogPosting"') + }) + + it('has substantial content (~800 words)', async () => { + const res = await request(app).get('/blog/automating-og-images.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/automating-og-images.html') + const html = res.text + expect(html).toContain('OG image') + expect(html).toContain('screenshot API') + }) +}) + 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') + expect(sitemap).toContain('https://snapapi.eu/blog/automating-og-images') }) it('index.html has Blog link in nav or footer', () => { diff --git a/src/routes/__tests__/status.test.ts b/src/routes/__tests__/status.test.ts new file mode 100644 index 0000000..0cb69c2 --- /dev/null +++ b/src/routes/__tests__/status.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import request from 'supertest' +import express from 'express' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const publicDir = path.join(__dirname, '../../../public') + +function createApp() { + const app = express() + app.use(express.static(publicDir, { etag: true })) + + // Mirror the status route from index.ts + app.get('/status', (_req, res) => { + res.sendFile(path.join(publicDir, 'status.html')) + }) + + return app +} + +describe('Status Route', () => { + const app = createApp() + + it('GET /status returns 200', async () => { + const res = await request(app).get('/status') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toContain('text/html') + }) + + it('GET /status.html returns 200', async () => { + const res = await request(app).get('/status.html') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toContain('text/html') + }) +})