Astro Screenshot API: Build-Time OG Cards and SSR

Astro OG images with a screenshot API: render cards from HTML at build time with a hash cache, plus an on-demand endpoint and signed URLs for SSR pages.

By Simon Chiu · Updated

Astro sites are mostly static, and that changes how screenshots fit in. The capture can happen once in astro build, or on demand in a server route, and each choice brings a problem that the Node.js screenshot API page does not cover. This page solves both: OG cards rendered from HTML at build time with a cache that survives rebuilds, and an on-demand endpoint plus signed URLs for pages that only exist at request time.

Last tested: 2026-10-19 with Node 22.18, Astro 7.3.8, @astrojs/node 11.1.8, renderscreenshot 1.1.2, TypeScript 6.0.3 (tsc --noEmit with astro/tsconfigs/strict passed) and vitest 5.0.5. astro build ran twice against a local stub of our API that returns the documented image and 429 responses, and the built Node server answered the on-demand route. We did not call the live API, so this page reports request counts, not render times.

The build-time trap

The obvious build-time approach is to capture https://example.com/blog/<slug> inside getStaticPaths. It fails for the post you just wrote. While astro build runs, that post is not live yet: the capture returns a 404 page, or the previous deploy for a changed post. The fix is to send the card as HTML. The API renders an HTML string the same way it renders a URL (content input), and the HTML needs nothing from your live site.

The same reason rules out POST /v1/batch for this job, because batch items are URLs. Batch is the right tool when the pages already exist, for example cards for a directory of other people's sites.

Page kind When to render Input Route type
Blog post, docs page, changelog entry astro build HTML card from frontmatter Prerendered endpoint
User profile, project, search result First request Live page URL prerender = false endpoint
Same, but no traffic through your server When a crawler fetches it Live page URL Signed URL in <head>

Install

npm install astro @astrojs/node renderscreenshot
npm install -D vitest typescript @types/node
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  site: 'https://example.com',
  adapter: node({ mode: 'standalone' }), // only for the on-demand route
});
// src/lib/screenshots.ts
import { Client } from 'renderscreenshot';

export const screenshots = new Client(process.env.RS_API_KEY ?? '', {
  baseUrl: process.env.RS_API_BASE, // unset in production; set only for a local stub
  timeout: 75_000, // above the API's 60-second maximum wait
});

The Client constructor throws when the key is empty. Because this module loads during astro build, a CI job without RS_API_KEY fails at once, before any page is written.

Cards at build time, cached by hash

// src/lib/og-cards.ts
import { createHash } from 'node:crypto';
import { access, mkdir, writeFile } from 'node:fs/promises';
import { RenderScreenshotError, TakeOptions } from 'renderscreenshot';
import { screenshots } from './screenshots';
import type { Post } from './posts'; // { slug, title, date }

const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

export function cardHtml(post: Post): string {
  return `<!doctype html><html><head><meta charset="utf-8"><style>
  body{margin:0;width:1200px;height:630px;display:flex;flex-direction:column;justify-content:flex-end;
       padding:72px;box-sizing:border-box;font:64px/1.1 system-ui,sans-serif;background:#0f172a;color:#f8fafc}
  small{font-size:28px;color:#94a3b8;margin-top:24px}
  </style></head><body>${escapeHtml(post.title)}<small>example.com ยท ${escapeHtml(post.date)}</small></body></html>`;
}

async function renderOne(html: string, file: string): Promise<void> {
  for (let attempt = 1; ; attempt++) {
    try {
      const png = await screenshots.take(TakeOptions.html(html).width(1200).height(630).format('png'));
      await writeFile(file, png);
      return;
    } catch (err) {
      const limited = err instanceof RenderScreenshotError && err.httpStatus === 429;
      if (!limited || attempt === 5) throw err; // fail the build, never ship a missing card
      await new Promise((r) => setTimeout(r, (err.retryAfter ?? 10) * 1000));
    }
  }
}

/** Renders every card whose HTML changed since the last build. Returns slug -> PNG path. */
export async function renderCards(
  list: Post[],
  dir = 'node_modules/.cache/og-cards', // keep this folder between CI builds
  inFlight = 2,
): Promise<Map<string, string>> {
  await mkdir(dir, { recursive: true });
  const files = new Map<string, string>();
  const todo: [string, string][] = [];
  for (const post of list) {
    const html = cardHtml(post);
    const file = `${dir}/${createHash('sha256').update(html).digest('hex').slice(0, 20)}.png`;
    files.set(post.slug, file);
    if (!(await access(file).then(() => true, () => false))) todo.push([html, file]);
  }
  const total = todo.length;
  await Promise.all(
    Array.from({ length: inFlight }, async () => {
      for (let job = todo.shift(); job; job = todo.shift()) await renderOne(...job);
    }),
  );
  console.log(`og cards: ${total} rendered, ${list.length - total} from cache`);
  return files;
}
// src/pages/og/[slug].png.ts (prerendered: runs once per build)
import type { APIRoute, GetStaticPaths } from 'astro';
import { readFile } from 'node:fs/promises';
import { posts } from '../../lib/posts';
import { renderCards } from '../../lib/og-cards';

export const getStaticPaths = (async () => {
  const files = await renderCards(posts);
  return posts.map((post) => ({ params: { slug: post.slug }, props: { file: files.get(post.slug)! } }));
}) satisfies GetStaticPaths;

export const GET: APIRoute = async ({ props }) =>
  new Response(new Uint8Array(await readFile(props.file)), { headers: { 'Content-Type': 'image/png' } });

In src/pages/blog/[slug].astro, point og:image at new URL(/og/${post.slug}.png, Astro.site).href and add og:image:width 1200 and og:image:height 630. Replace the posts array with getCollection('blog') in a real site.

What the design buys you:

  • Renders happen in getStaticPaths, not in GET. Astro builds pages one at a time by default (build.concurrency is 1). getStaticPaths runs once for the route, so all missing cards render there with two requests in flight, and each GET only reads a file.
  • The file name is a hash of the HTML. A rebuild renders only cards whose title, date or template changed. In our test, the first astro build sent two render requests and logged og cards: 2 rendered, 0 from cache. The second build sent none and logged 0 rendered, 2 from cache. Every render uses a credit, so without this cache each deploy would pay again for every post. Keep node_modules/.cache in your CI cache.
  • The title is escaped. A title such as Tags <and> "types" would otherwise become markup inside the card. Our second test post has exactly that title, and the stub received &#60;and&#62;.
  • A 429 waits, any other error stops the build. The rate limit is per account and all your keys share it (rate limits), so a deploy that runs while your app is busy can hit it. At build time, waiting Retry-After seconds is fine. A broken template must fail loudly, not ship a site with missing images.

Fonts: the card renders in our Chrome, not on your machine, so system-ui resolves to the fonts on our side. For a brand font, load it in the card's HTML with a <link> or an @font-face rule and font-display: block, as the HTML to image guide explains.

On-demand pages: an SSR endpoint

Pages that exist only at request time, such as /projects/<id>, can be captured by URL, because they are live when the crawler asks:

// src/pages/og/projects/[id].png.ts (on demand: runs per request on the server)
import type { APIRoute } from 'astro';
import { RenderScreenshotError, TakeOptions } from 'renderscreenshot';
import { screenshots } from '../../../lib/screenshots';

export const prerender = false;

const ID = /^[a-z0-9-]{1,64}$/;

export const GET: APIRoute = async ({ params, site }) => {
  if (!params.id || !ID.test(params.id)) return new Response(null, { status: 404 });
  try {
    const png = await screenshots.take(
      TakeOptions.url(new URL(`/projects/${params.id}`, site).href).preset('og_card').blockCookieBanners(),
    );
    return new Response(new Uint8Array(png), {
      headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600, s-maxage=86400' },
    });
  } catch (err) {
    if (!(err instanceof RenderScreenshotError)) throw err;
    console.error('og render failed', err.httpStatus, err.code);
    const busy = err.httpStatus === 429;
    return new Response(null, {
      status: busy ? 503 : 502,
      headers: busy ? { 'Retry-After': String(err.retryAfter ?? 30) } : {},
    });
  }
};

The base URL comes from site in astro.config.mjs, never from the request. Astro.url and context.url are built from the Host header, so a crafted request could send your API key to another host. blockCookieBanners() matters here because a live page often shows a consent banner that the static card never had. s-maxage lets a CDN keep the image for a day, so most crawler fetches never reach Node. Against the stub, the built server returned the PNG with these headers, a 404 for ..%2Fadmin, and a 503 with the stub's Retry-After when the stub answered 429.

Or skip the endpoint: sign the URL in the page

If you would rather not run image traffic through your server, sign a GET URL in the page's frontmatter and let the crawler fetch it from the API:

// src/lib/signed.ts
import { Client, TakeOptions } from 'renderscreenshot';

const signer = new Client(process.env.RS_API_KEY ?? '', {
  signingKey: process.env.RS_SIGNING_KEY, // rs_secret_...
  publicKeyId: process.env.RS_PUBLIC_KEY_ID, // rs_pub_...
});
const DAY = 86_400_000;

/** One URL per page per UTC day, so crawlers and CDNs see a stable og:image. */
export function signedOgImage(pageUrl: string, now = Date.now()): string {
  const expires = new Date(Math.floor(now / DAY) * DAY + 8 * DAY); // 7 to 8 days, max is 30
  return signer.generateUrl(TakeOptions.url(pageUrl).preset('og_card'), expires);
}

In src/pages/projects/[id].astro, set export const prerender = false, call signedOgImage(new URL(Astro.url.pathname, Astro.site).href) in the frontmatter, and put the result in <meta property="og:image">. Astro escapes the & characters in the attribute. The URL holds your rs_pub_ key ID and a signature, never a secret. Do not use this on prerendered pages: the expiry is fixed at build time, and the URL stops working within 30 days.

Test with vitest

// test/og-cards.test.ts
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { RenderScreenshotError, type ErrorCode } from 'renderscreenshot';

process.env.RS_API_KEY = 'rs_live_test'; // the Client constructor rejects an empty key
const { screenshots } = await import('../src/lib/screenshots');
const { cardHtml, renderCards } = await import('../src/lib/og-cards');

const post = { slug: 'a', title: '<script>alert(1)</script>', date: '2026-10-18' };
let dir: string;

beforeEach(async () => {
  dir = await mkdtemp(join(tmpdir(), 'og-'));
  vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => vi.restoreAllMocks());

it('escapes the title in the card HTML', () => {
  expect(cardHtml(post)).toContain('&#60;script&#62;');
  expect(cardHtml(post)).not.toContain('<script>');
});

it('renders a card once, then again only when its HTML changes', async () => {
  const take = vi.spyOn(screenshots, 'take').mockResolvedValue(Buffer.from('PNG'));
  await renderCards([post], dir);
  await renderCards([post], dir);
  expect(take).toHaveBeenCalledTimes(1);
  await renderCards([{ ...post, title: 'New title' }], dir);
  expect(take).toHaveBeenCalledTimes(2);
});

it('waits out a 429 and retries', async () => {
  const take = vi.spyOn(screenshots, 'take')
    // The API sends too_many_requests; the SDK's ErrorCode type does not list it.
    .mockRejectedValueOnce(new RenderScreenshotError(429, 'too_many_requests' as ErrorCode, 'slow down', 0))
    .mockResolvedValue(Buffer.from('PNG'));
  await renderCards([post], dir);
  expect(take).toHaveBeenCalledTimes(2);
});

The environment variable is set before the dynamic import(), because screenshots.ts reads it at load time. A retryAfter of 0 keeps the retry test instant. Our suite has one more test: a 400 error must reject renderCards, so a bad template fails the build.

Astro's own image tools, or this?

An Astro endpoint can also draw OG images without a browser, with a library such as Satori in place of the API call. That works well for text on a coloured background, but Satori supports only a subset of CSS. Use the screenshot API when the card needs full CSS, web fonts, grid layouts or a real page. A wider comparison of approaches is in best OG image generators, and the OG image API page covers the product side.

Get a free API key, add it to your build environment as RS_API_KEY, and run astro build. The free plan includes 150 credits every month.

Frequently asked questions

Can I screenshot my Astro pages during astro build?
Not by URL if the pages are new. During the build, a new post does not exist on your live site yet, so a capture of its URL fails or shows the old deploy. Send the card as HTML instead, with the html input of the API, and capture the live URL only for pages that are already deployed.
How do I stop astro build from rendering every OG image again?
Name each image file after a hash of the HTML that produced it and keep the folder between builds. The build then sends a render request only for cards whose title, date or template changed.
How do I take screenshots in an Astro SSR route?
Add an endpoint with export const prerender = false, install a server adapter such as @astrojs/node, and call the API from the GET handler. Return the image with Cache-Control headers so that a CDN serves repeats.
Should an Astro OG endpoint use Astro.url or the site config for the page URL?
Use the site value from astro.config. The request URL comes from the Host header, so a crafted request could point your API key at another host.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.