Nuxt Screenshot API: Nitro Routes and useSeoMeta

Nuxt 4 OG images from a screenshot API: a Nitro server route with binary-safe storage, useSeoMeta wiring, signed URLs, and when nuxt-og-image fits better.

By Simon Chiu · Updated

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, renderscreenshot 1.1.2, vue-tsc (nuxt typecheck passed) 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:

  1. The file is [file].ts, not [slug].png.ts. Nitro registered [slug].png.ts as /og/:slug.png, and a request for /og/hello-world.png returned 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.
  2. No swr route rule. Our first version cached the route with routeRules: { '/og/**': { swr: 86400 } }. The first response was a correct PNG. The next ones came back with content-type: application/json and a body of {"type":"Buffer",...} when the handler returned a Buffer, or {} when it returned a Response. Every crawler after the first would have received a broken image. So the route stores the bytes itself with setItemRaw, which keeps them binary, and leaves HTTP caching to the CDN through Cache-Control.
  3. Errors return a Response, not createError. With setResponseHeader(event, 'Retry-After', ...) followed by throw createError({ statusCode: 503 }), the 503 reached the client without the Retry-After header. A returned Response keeps 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.

Frequently asked questions

Should I use nuxt-og-image or a screenshot API in Nuxt?
Use nuxt-og-image when you design the card as a Vue component and its CSS subset is enough; it renders without a browser. Use a screenshot API when the image must show a real page, needs full CSS, or must be captured at request time on a host that cannot run Chrome.
Why does my Nitro route return 404 for /og/my-post.png?
In our Nuxt 4.6.2 build, a file named server/routes/og/[slug].png.ts registered the route /og/:slug.png and did not match /og/my-post.png. Name the file [file].ts and strip the .png extension from the parameter in the handler.
Can I cache a screenshot route with Nitro route rules?
Not safely in our test. With swr on the route, Nitro 2.13.5 served the first response correctly but later responses came back as JSON instead of PNG bytes. Store the bytes with useStorage().setItemRaw and set Cache-Control for your CDN instead.
How do I set og:image in Nuxt?
Call useSeoMeta in the page with ogImage set to an absolute URL, plus ogImageWidth and ogImageHeight. The value can be a getter, so it can come from useFetch data.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.