Screenshot API with Astro: Endpoints, SSG, and Content Collections
Astro's architecture is different from Next.js, Nuxt, or SvelteKit — it's HTML-first, ships zero JavaScript by default, and separates static generation from server-rendered endpoints cleanly. This guide covers the patterns that match Astro's model.
Prerequisites
A free API key from the screenshot API. Set SCREENSHOT_API_KEY in your .env file.
API Endpoint: src/pages/api/screenshot.ts
Astro's file-based API endpoints live in src/pages/api/. They have access to server environment variables and never ship to the client.
// src/pages/api/screenshot.ts
import type { APIRoute } from 'astro'
export const GET: APIRoute = async ({ url }) => {
const targetUrl = url.searchParams.get('url')
if (!targetUrl) {
return new Response(JSON.stringify({ error: 'Missing url parameter' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const apiKey = import.meta.env.SCREENSHOT_API_KEY
const params = new URLSearchParams({
url: targetUrl,
format: url.searchParams.get('format') ?? 'webp',
width: url.searchParams.get('width') ?? '1280',
height: url.searchParams.get('height') ?? '800',
})
const response = await fetch(
`https://hermesforge.dev/api/screenshot?${params}`,
{ headers: { 'X-API-Key': apiKey } }
)
if (!response.ok) {
return new Response(JSON.stringify({ error: 'Screenshot failed' }), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
})
}
const buffer = await response.arrayBuffer()
return new Response(buffer, {
headers: {
'Content-Type': response.headers.get('content-type') ?? 'image/webp',
'Cache-Control': 'public, max-age=3600',
},
})
}
Important: API endpoints require SSR mode or hybrid rendering. Add to astro.config.mjs:
// astro.config.mjs
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
export default defineConfig({
output: 'hybrid', // or 'server' for full SSR
adapter: node({ mode: 'standalone' }),
})
Call from any Astro component or React/Vue/Svelte island:
const params = new URLSearchParams({ url: 'https://example.com', format: 'webp' })
const res = await fetch(`/api/screenshot?${params}`)
const blob = await res.blob()
const src = URL.createObjectURL(blob)
Static Site Generation: Pre-render Screenshots at Build Time
The most Astro-native pattern is generating screenshots during astro build. No server needed — screenshots become static assets.
// src/pages/previews/[slug].png.ts
import type { GetStaticPaths, APIRoute } from 'astro'
// Define which pages to screenshot
export const getStaticPaths: GetStaticPaths = async () => {
const pages = [
{ slug: 'home', url: 'https://yoursite.com' },
{ slug: 'about', url: 'https://yoursite.com/about' },
{ slug: 'pricing', url: 'https://yoursite.com/pricing' },
]
return pages.map(page => ({
params: { slug: page.slug },
props: { url: page.url },
}))
}
export const GET: APIRoute = async ({ props }) => {
const apiKey = import.meta.env.SCREENSHOT_API_KEY
const params = new URLSearchParams({
url: props.url,
width: '1200',
height: '630',
format: 'png',
})
const response = await fetch(
`https://hermesforge.dev/api/screenshot?${params}`,
{ headers: { 'X-API-Key': apiKey } }
)
const buffer = await response.arrayBuffer()
return new Response(buffer, {
headers: { 'Content-Type': 'image/png' },
})
}
After astro build, screenshots are at /previews/home.png, /previews/about.png, etc. — fully static, served from the CDN, no API calls at runtime.
Use in your layout for OG images:
---
// src/layouts/Layout.astro
const { slug } = Astro.params
---
<meta property="og:image" content={`/previews/${slug}.png`} />
Content Collections Integration
If you use Astro Content Collections for a blog or documentation, you can auto-generate preview screenshots for each entry.
// src/content/config.ts
import { defineCollection, z } from 'astro:content'
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
url: z.string().url().optional(), // external URL to screenshot
previewGenerated: z.boolean().default(false),
}),
})
export const collections = { blog }
Generate screenshots for all collection entries in a build script:
// scripts/generate-previews.ts
import { getCollection } from 'astro:content'
import { writeFileSync, mkdirSync } from 'fs'
const entries = await getCollection('blog')
const apiKey = process.env.SCREENSHOT_API_KEY!
mkdirSync('public/previews', { recursive: true })
for (const entry of entries) {
if (!entry.data.url || entry.data.previewGenerated) continue
const params = new URLSearchParams({
url: entry.data.url,
width: '1200',
height: '630',
format: 'png',
})
const res = await fetch(
`https://hermesforge.dev/api/screenshot?${params}`,
{ headers: { 'X-API-Key': apiKey } }
)
if (res.ok) {
const buf = Buffer.from(await res.arrayBuffer())
writeFileSync(`public/previews/${entry.slug}.png`, buf)
console.log(`Generated preview: ${entry.slug}`)
}
}
React Island with Screenshot Functionality
Astro's island architecture means interactive components are opt-in. A screenshot capture button is a perfect use case for a client island:
// src/components/ScreenshotButton.tsx
import { useState } from 'react'
interface Props {
targetUrl: string
}
export default function ScreenshotButton({ targetUrl }: Props) {
const [src, setSrc] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function capture() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ url: targetUrl, format: 'webp' })
const res = await fetch(`/api/screenshot?${params}`)
if (!res.ok) throw new Error(`Failed: ${res.status}`)
const blob = await res.blob()
setSrc(URL.createObjectURL(blob))
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
return (
<div>
<button onClick={capture} disabled={loading}>
{loading ? 'Capturing...' : 'Screenshot'}
</button>
{src && <img src={src} alt={`Screenshot of ${targetUrl}`} />}
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
)
}
Use in any .astro file with client:load to hydrate on page load:
---
import ScreenshotButton from '../components/ScreenshotButton'
---
<ScreenshotButton
targetUrl="https://example.com"
client:load
/>
Or client:visible to hydrate only when scrolled into view — better for performance if the button is below the fold.
On-Demand Rendering with export const prerender = false
In hybrid mode, you can opt specific pages out of static generation:
// src/pages/preview.ts
export const prerender = false // this endpoint is always server-rendered
import type { APIRoute } from 'astro'
export const GET: APIRoute = async ({ url }) => {
const targetUrl = url.searchParams.get('url')
// ... screenshot logic
}
This is useful for a preview tool where users enter arbitrary URLs at runtime — you can't prerender arbitrary URLs, so on-demand rendering is the right model here.
Middleware for Caching
Astro middleware can add a response cache to avoid redundant screenshot API calls:
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware'
const cache = new Map<string, { buffer: ArrayBuffer; contentType: string; expires: number }>()
export const onRequest = defineMiddleware(async (context, next) => {
if (context.url.pathname !== '/api/screenshot') return next()
const cacheKey = context.url.search
const cached = cache.get(cacheKey)
if (cached && cached.expires > Date.now()) {
return new Response(cached.buffer, {
headers: {
'Content-Type': cached.contentType,
'X-Cache': 'HIT',
},
})
}
const response = await next()
if (response.ok) {
const buffer = await response.clone().arrayBuffer()
cache.set(cacheKey, {
buffer,
contentType: response.headers.get('content-type') ?? 'image/webp',
expires: Date.now() + 3600_000, // 1 hour TTL
})
}
return response
})
Environment Variables
# .env
SCREENSHOT_API_KEY=your_key_here
In Astro, server-only env vars use import.meta.env.SCREENSHOT_API_KEY in .ts/.astro files. They are NOT available in client-side scripts or React/Vue/Svelte islands unless explicitly passed as props.
---
// src/pages/index.astro
// This runs at build time / server-side — safe to use API key
const apiKey = import.meta.env.SCREENSHOT_API_KEY
---
<!-- Pass only what the client needs — never the key itself -->
<ScreenshotButton targetUrl="https://example.com" client:load />
Full API reference: /api. Get a free key (50 req/day): /api/keys.