A Nuxt app has two places where a screenshot can happen: a Nitro server route that captures a page on request, or the nuxt-og-image module that draws a card from a Vue component. This page builds the first one for Nuxt 4, wires it into useSeoMeta, adds a signed-URL variant, and ends with a table that shows when the module is the better tool. For SDK basics, start with the Node.js screenshot API page.
Last tested: 2026-10-19 with Node 24.18 (Nuxt 4.6.2 warns that Node 22.18 is not supported), Nitro 2.13.5, h3 1.15.11, unstorage 1.17.6,
renderscreenshot1.1.2,vue-tsc(nuxt typecheckpassed) and vitest 5.0.5. The tests start the built server from.output/and point it at a local HTTP stub of our API. We did not call the live API, and we did not deploy to a serverless host.
Configure the client
npm install renderscreenshot
// nuxt.config.ts export default defineNuxtConfig({ compatibilityDate: '2026-10-01', runtimeConfig: { // Server only. Set with NUXT_RS_API_KEY, NUXT_RS_SIGNING_KEY, NUXT_RS_PUBLIC_KEY_ID. rsApiKey: '', rsApiBase: '', // empty in production; a local stub URL in tests rsSigningKey: '', rsPublicKeyId: '', public: { siteUrl: 'https://example.com' }, }, nitro: { // Rendered cards, keyed by slug. Use a shared driver (Redis, KV) on serverless hosts. storage: { og: { driver: 'fs', base: './.data/og' } }, }, });
// server/utils/screenshots.ts (auto-imported in server/) import { Client } from 'renderscreenshot'; let client: Client | undefined; export function useScreenshots(): Client { const config = useRuntimeConfig(); client ??= new Client(config.rsApiKey, { baseUrl: config.rsApiBase || undefined, timeout: 75_000, // above the API's 60-second maximum wait signingKey: config.rsSigningKey, publicKeyId: config.rsPublicKeyId, }); return client; }
Keys at the top level of runtimeConfig stay on the server. Only public reaches the browser, so siteUrl is the one value the page components can read. Nuxt fills rsApiKey from NUXT_RS_API_KEY at runtime, which is why the client is created on first use and not at import time.
The Nitro route
// server/routes/og/[file].ts -> GET /og/<slug>.png import { RenderScreenshotError, TakeOptions } from 'renderscreenshot'; const FILE = /^([a-z0-9-]{1,80})\.png$/; export default defineEventHandler(async (event) => { const slug = FILE.exec(getRouterParam(event, 'file') ?? '')?.[1]; if (!slug) throw createError({ statusCode: 404 }); const store = useStorage('og'); const headers = { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' }; const cached = await store.getItemRaw<Uint8Array>(`${slug}.png`); if (cached) return new Response(new Uint8Array(cached), { headers }); const { public: { siteUrl } } = useRuntimeConfig(event); try { const png = new Uint8Array(await useScreenshots().take( TakeOptions.url(`${siteUrl}/blog/${slug}`).preset('og_card').blockCookieBanners(), )); await store.setItemRaw(`${slug}.png`, png); return new Response(png, { headers }); } 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) } : {}, }); } });
This route looks plain, but three of its details came from failures in our first version:
- The file is
[file].ts, not[slug].png.ts. Nitro registered[slug].png.tsas/og/:slug.png, and a request for/og/hello-world.pngreturned 404. A whole-segment parameter with the extension checked in the handler works, and the regular expression doubles as input validation: only[a-z0-9-]reaches the URL you capture. - No
swrroute rule. Our first version cached the route withrouteRules: { '/og/**': { swr: 86400 } }. The first response was a correct PNG. The next ones came back withcontent-type: application/jsonand a body of{"type":"Buffer",...}when the handler returned a Buffer, or{}when it returned aResponse. Every crawler after the first would have received a broken image. So the route stores the bytes itself withsetItemRaw, which keeps them binary, and leaves HTTP caching to the CDN throughCache-Control. - Errors return a
Response, notcreateError. WithsetResponseHeader(event, 'Retry-After', ...)followed bythrow createError({ statusCode: 503 }), the 503 reached the client without theRetry-Afterheader. A returnedResponsekeeps both. The 503 matters because the rate limit is per account and shared by all your keys (rate limits): the CDN or crawler should come back later, not retry at once.
The fs driver writes to .data/og on the server's disk. That works on a VPS or in a container with a volume. On serverless hosts the disk does not persist between invocations, so mount a shared driver for og, such as Redis or Cloudflare KV, and keep the handler unchanged. A stored card stays until you remove it, so call useStorage('og').removeItem('<slug>.png') when a post changes.
Wire it into useSeoMeta
<!-- app/pages/blog/[slug].vue --> <script setup lang="ts"> const route = useRoute(); const slug = String(route.params.slug); const { public: { siteUrl } } = useRuntimeConfig(); useSeoMeta({ title: () => `Post ${slug}`, ogImage: `${siteUrl}/og/${slug}.png`, ogImageWidth: 1200, ogImageHeight: 630, twitterCard: 'summary_large_image', }); </script> <template> <h1>Post {{ slug }}</h1> </template>
The og:image value must be absolute, because crawlers do not resolve relative URLs against your page. The width and height tags let some platforms lay out the card before they download the image. If a card still does not appear, the og:image troubleshooting guide walks through the usual causes.
Signed URLs: no image traffic through Nitro
A signed URL points the crawler at the API directly. The signing key must stay on the server, so the page asks a Nitro handler for the URL:
// server/api/og-url.get.ts -> GET /api/og-url?path=/docs/intro import { TakeOptions } from 'renderscreenshot'; const PATH = /^\/docs\/[a-z0-9-/]{1,120}$/; // only pages you want cards for const DAY = 86_400_000; export default defineEventHandler((event) => { const path = String(getQuery(event).path ?? ''); if (!PATH.test(path)) throw createError({ statusCode: 400 }); const { public: { siteUrl } } = useRuntimeConfig(event); const expires = new Date(Math.floor(Date.now() / DAY) * DAY + 8 * DAY); // same URL all day return { url: useScreenshots().generateUrl(TakeOptions.url(siteUrl + path).preset('og_card'), expires) }; });
// app/composables/useSignedOgImage.ts export function useSignedOgImage(path: string) { // On the server, useFetch calls the Nitro handler in-process; only the URL reaches the payload. const request = useFetch('/api/og-url', { query: { path } }); useSeoMeta({ ogImage: () => request.data.value?.url, ogImageWidth: 1200, ogImageHeight: 630 }); return request; // await it in the page, so SSR waits for the URL }
In app/pages/docs/[...path].vue, call await useSignedOgImage(route.path) in <script setup>.
The composable calls useSeoMeta before anything is awaited. Our first version awaited useFetch first and then called useSeoMeta; the page failed with [NUXT_E1001], because the Nuxt instance is not available after an await inside a composable. The path pattern matters as well. /api/og-url is public, and without the pattern anyone could ask it to sign URLs for any page and spend your credits. In our test, the rendered HTML held the signed URL in og:image and in the payload, and no rs_live_ or rs_secret_ string. The expiry is aligned to the UTC day, inside the 30-day maximum, so the URL stays the same for every view that day.
Test against the built server
Nitro routes depend on auto-imports and runtime config, so the most honest test runs .output/server/index.mjs with a stub API in front of it:
// test/og.test.ts: run after `nuxt build` import { spawn, type ChildProcess } from 'node:child_process'; import { mkdtemp } from 'node:fs/promises'; import http from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, expect, it } from 'vitest'; const calls: string[] = []; let stub: http.Server; let app: ChildProcess; const BASE = 'http://127.0.0.1:4099'; beforeAll(async () => { stub = http.createServer((req, res) => { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { calls.push(body); if (body.includes('/blog/busy')) { res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '7' }); return res.end(JSON.stringify({ error: { code: 'too_many_requests', message: 'slow down', retryable: true } })); } res.writeHead(200, { 'Content-Type': 'image/png' }).end(Buffer.from([0x89, 0x50, 0x4e, 0x47])); }); }); await new Promise<void>((r) => stub.listen(4098, '127.0.0.1', r)); app = spawn('node', [join(process.cwd(), '.output/server/index.mjs')], { cwd: await mkdtemp(join(tmpdir(), 'nuxt-')), // fresh .data/og for each run env: { ...process.env, PORT: '4099', HOST: '127.0.0.1', NUXT_RS_API_KEY: 'rs_live_test', NUXT_RS_API_BASE: 'http://127.0.0.1:4098', NUXT_RS_SIGNING_KEY: 'rs_secret_test', NUXT_RS_PUBLIC_KEY_ID: 'rs_pub_test' }, }); for (let i = 0; i < 50; i++) { if (await fetch(BASE).then(() => true, () => false)) return; await new Promise((r) => setTimeout(r, 100)); } }); afterAll(() => { app.kill(); stub.close(); }); it('renders once, then serves the stored PNG', async () => { for (let i = 0; i < 3; i++) { const res = await fetch(`${BASE}/og/hello-world.png`); expect(res.headers.get('content-type')).toBe('image/png'); expect([...new Uint8Array(await res.arrayBuffer())]).toEqual([0x89, 0x50, 0x4e, 0x47]); } expect(calls.filter((c) => c.includes('/blog/hello-world'))).toHaveLength(1); }); it('maps a 429 to 503 with Retry-After', async () => { const res = await fetch(`${BASE}/og/busy.png`); expect(res.status).toBe(503); expect(res.headers.get('retry-after')).toBe('7'); }); it('signs og:image on the server and leaks no key', async () => { const html = await (await fetch(`${BASE}/docs/intro`)).text(); expect(html).toMatch(/og:image" content="[^"]+&signature=[0-9a-f]{64}/); expect(html).not.toMatch(/rs_secret|rs_live/); });
The first test is the one that caught the swr problem: it compares the bytes of three responses, not only the status code. The server runs in a fresh temporary directory, so the fs storage starts empty each time. For unit tests of plain functions, see the SDK testing guide.
nuxt-og-image or a screenshot API?
nuxt-og-image is a well-maintained module, and for many sites it is the simpler answer. These facts come from the documentation that ships with version 7.0.7, checked on 2026-10-19:
| Question | nuxt-og-image 7 | Nitro route + screenshot API |
|---|---|---|
| How the image is drawn | A Vue component in components/OgImage/, rendered by Takumi or Satori without a browser |
Real Chrome on our side, full CSS and JavaScript |
| Image of a real page | defineOgImageScreenshot(), which needs ogImage.browser with playwright-core or a Cloudflare Browser binding, and is meant for prerendered pages |
Any URL, on request, from any host that can make an HTTP call |
og:image meta |
Added for you by defineOgImage() |
You set it with useSeoMeta |
| Default size | 1200 by 600 | 1200 by 630 with the og_card preset |
| URL protection | Runtime URLs signed with NUXT_APP_SECRET |
Your slug pattern, or our signed URLs |
| Cost | Your server's CPU | One credit per render; stored copies are free |
Choose the module when your cards are designed from data you own, such as a title, an author and a colour, and its CSS subset is enough. Choose the API when the image must show what a page really looks like, such as a user's project, a dashboard or a third-party site, or when your host cannot run Chrome. The OG image API page covers the product side.
Get a free API key, set NUXT_RS_API_KEY, run nuxt build, and open /og/<slug>.png. The free plan includes 150 credits every month.