Astro's content collections are a perfect fit for build-time thumbnail generation. You write Markdown or MDX with frontmatter, Astro typechecks it via Zod, and at build time you render every page once. If a post needs a thumbnail or OG image, you can generate it during the build and ship it as a static asset — zero runtime cost.
In this tutorial we'll integrate SnapSharp into an Astro blog. You'll get auto-generated OG images for every post, plus a runtime endpoint for live screenshots if you ever need one. Works with Astro 4 and 5.
Prerequisites
- Node.js 20+ and pnpm.
- An Astro 4+ project (
pnpm create astro@latest) with content collections enabled. - A free SnapSharp API key from snapsharp.dev/sign-up.
- Basic familiarity with Astro's
getCollectionand frontmatter schemas.
Step 1: install and configure
The Node SDK works in Astro's static build, SSR endpoints, and adapter-based deployments.
pnpm add @snapsharp/sdkAdd the API key to .env:
SNAPSHARP_API_KEY=sk_live_YOUR_API_KEYAstro reads .env at build time. To use it inside scripts, prefer import.meta.env:
// src/lib/snapsharp.ts
import { SnapSharp } from '@snapsharp/sdk';
const apiKey = import.meta.env.SNAPSHARP_API_KEY;
if (!apiKey) {
throw new Error('SNAPSHARP_API_KEY is required');
}
export const snap = new SnapSharp(apiKey);Don't prefix the variable with PUBLIC_ — that would expose it to the browser. Anything not prefixed is server-only in Astro.
Step 2: define the content collection
Create src/content/config.ts:
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
author: z.string().default('Anonymous'),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { blog };Add a sample post at src/content/blog/hello-world.md:
---
title: "Hello World"
description: "First post on the new blog."
date: "2026-04-26"
author: "Jane Doe"
tags: ["intro", "astro"]
---
Welcome to the blog.Astro will typecheck the frontmatter against the Zod schema during the build. Anything that doesn't match fails fast.
Step 3: generate OG images at build time
The cleanest pattern in Astro is to generate the OG image as a regular page that returns binary content. Astro's static build will save it as a .png file in dist/, and your post pages can reference it.
Create src/pages/blog/[slug]/og.png.ts:
import type { APIRoute } from 'astro';
import { getCollection, getEntry } from 'astro:content';
import { snap } from '../../../../lib/snapsharp';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
export const GET: APIRoute = async ({ props }) => {
const { post } = props as { post: Awaited<ReturnType<typeof getEntry>> };
if (!post) return new Response('Not found', { status: 404 });
const image = await snap.ogImage({
template: 'blog-post',
variables: {
title: post.data.title,
author: post.data.author,
date: post.data.date.toISOString().slice(0, 10),
tag: post.data.tags?.[0] ?? '',
},
width: 1200,
height: 630,
});
return new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
};getStaticPaths tells Astro which pages to generate. For a static build (output: 'static' in astro.config.mjs), Astro will call this once per post during the build, save the resulting PNG to dist/blog/[slug]/og.png, and you'll never call SnapSharp again at runtime.
For SSR builds (output: 'server' or output: 'hybrid'), the endpoint runs on every request — add caching headers to keep costs down.
Step 4: reference the OG image from post pages
In your post layout src/layouts/BlogPost.astro:
---
import type { CollectionEntry } from 'astro:content';
interface Props {
post: CollectionEntry<'blog'>;
}
const { post } = Astro.props;
const ogImageUrl = new URL(`/blog/${post.slug}/og.png`, Astro.site).toString();
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{post.data.title}</title>
<meta property="og:title" content={post.data.title} />
<meta property="og:description" content={post.data.description} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImageUrl} />
</head>
<body>
<article>
<h1>{post.data.title}</h1>
<p class="meta">
By {post.data.author} on {post.data.date.toLocaleDateString()}
</p>
<slot />
</article>
</body>
</html>Astro.site must be set in astro.config.mjs for the absolute URL to work:
export default {
site: 'https://yourdomain.com',
};Now wire the layout to a dynamic route at src/pages/blog/[...slug].astro:
---
import { getCollection } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<BlogPost post={post}>
<Content />
</BlogPost>Run pnpm build and Astro will generate every post page plus its OG image as static assets. Deploy to S3, Cloudflare Pages, Netlify, GitHub Pages — anywhere static.
Step 5: site thumbnails for a directory or homepage
If you want a thumbnail of each post's actual rendered page (rather than a templated OG card), capture the post URL with a screenshot. Useful for "recently published" grids on your homepage.
src/pages/blog/[slug]/thumb.png.ts:
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import { snap } from '../../../../lib/snapsharp';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { slug: post.slug },
}));
}
export const GET: APIRoute = async ({ props, site }) => {
const { slug } = props as { slug: string };
const url = new URL(`/blog/${slug}/`, site).toString();
const image = await snap.screenshot(url, {
width: 1280,
height: 720,
format: 'webp',
quality: 85,
blockAds: true,
});
return new Response(image, {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
};The build process screenshots each post's rendered page and saves it as a thumbnail. Reference it in your homepage:
{posts.map((post) => (
<a href={`/blog/${post.slug}`}>
<img
src={`/blog/${post.slug}/thumb.png`}
alt={post.data.title}
width="400"
height="225"
loading="lazy"
/>
<h3>{post.data.title}</h3>
</a>
))}There's a chicken-and-egg moment here: the screenshot needs the page to be live. For a fully static build, deploy the site once without thumbnails, then run a second build that captures the deployed URLs. Or use a placeholder during the first build and regenerate as a follow-up step.
A cleaner approach: capture the local dev URL during the build, but only if you're hosting the build output the same place. For most teams, OG cards (Step 3) are easier and look more polished anyway.
Step 6: SSR endpoints for runtime captures
If you've enabled SSR with an adapter (Vercel, Netlify, Node), you can also accept arbitrary URLs from users and screenshot them on demand.
src/pages/api/screenshot.ts:
import type { APIRoute } from 'astro';
import { snap } from '../../lib/snapsharp';
export const prerender = false; // force SSR for this endpoint
export const GET: APIRoute = async ({ url }) => {
const target = url.searchParams.get('url');
if (!target) {
return new Response(JSON.stringify({ error: 'url required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const image = await snap.screenshot(target, {
width: 1280,
height: 720,
format: 'png',
blockAds: true,
});
return new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
},
});
} catch (err) {
console.error('Screenshot failed:', err);
return new Response(JSON.stringify({ error: 'capture failed' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
};prerender = false is the magic — it tells Astro to skip this route during the static build and serve it from the SSR adapter instead. Mix-and-match with prerendered pages on the same site.
Common pitfalls
Pitfall 1: missing Astro.site. Without it, new URL(..., Astro.site) throws. Set site in astro.config.mjs early.
Pitfall 2: build-time OG generation hits rate limits. A blog with 200 posts generates 200 OG images during the build. The free tier (5 req/min) bottlenecks this. Either upgrade to Starter, or chunk the build with concurrency throttling.
Pitfall 3: import.meta.env outside Astro context. If you import the SnapSharp client from a Node script (e.g., a cron job), import.meta.env is undefined. Use process.env in those contexts.
Pitfall 4: Astro's output: 'static' doesn't run SSR endpoints. If you need a runtime screenshot endpoint, switch to output: 'hybrid' and mark only that endpoint as prerender = false.
Pitfall 5: stale OG cards on social platforms. When you regenerate an OG card, Twitter/LinkedIn cache the old one. Either wait 24-48 hours, use the platform's debug tool to force re-fetch, or version the OG URL with a hash.
Final code
Three files:
src/lib/snapsharp.ts— typed client.src/pages/blog/[slug]/og.png.ts— per-post OG (build-time).src/pages/api/screenshot.ts— runtime capture endpoint (SSR only).
Plus a content collection schema and post layout. ~80 lines total for the integration.
Conclusion
Astro's static-first model means OG images can be generated once at build time and served as immutable assets. No runtime cost, no cold starts, perfect cache behavior. SnapSharp's templated OG endpoint pairs naturally with content collections — pass post data in, get back a pixel-perfect card. For runtime needs, hybrid SSR endpoints close the gap.
Next steps: design custom OG templates, browse the OG image API, or compare integration patterns in the SvelteKit tutorial.
Related: OG image best practices · Pricing · Site audit and design tokens