Screenshot API with Astro: Endpoints, SSG, and Content Collections

2026-05-09 | Tags: [screenshot-api, astro, javascript, tutorial, ssg]

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.