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/node11.1.8,renderscreenshot1.1.2, TypeScript 6.0.3 (tsc --noEmitwithastro/tsconfigs/strictpassed) and vitest 5.0.5.astro buildran 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 inGET. Astro builds pages one at a time by default (build.concurrencyis 1).getStaticPathsruns once for the route, so all missing cards render there with two requests in flight, and eachGETonly 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 buildsent two render requests and loggedog cards: 2 rendered, 0 from cache. The second build sent none and logged0 rendered, 2 from cache. Every render uses a credit, so without this cache each deploy would pay again for every post. Keepnode_modules/.cachein 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<and>. - 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-Afterseconds 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('<script>'); 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.