This page covers the parts of a screenshot feature that are specific to Next.js: when to skip the API and use opengraph-image.tsx, a Server Action that captures a URL a user pasted, signed thumbnail URLs rendered by a Server Component, function duration on Vercel, and tests. The basic route handler and the generateMetadata example live on the Node.js screenshot API page, so this page does not repeat them.
Last tested: 2026-10-18 with Node 22.18, Next.js 16.4.2 (Turbopack), React 19.3.1, TypeScript 7.0.2, vitest 5.0.5 and
renderscreenshot1.1.2 from npm.next buildpassed type checking and prerendered both pages. The Server Action ran in vitest, once with mocks and once against a local stub of our API that returns the documented JSON and error responses. We did not call the live API or deploy to Vercel.
Screenshot API or opengraph-image.tsx?
Next.js can draw images on its own. ImageResponse turns JSX into a PNG with Satori, which supports a subset of CSS and needs no browser. A screenshot API runs a real Chrome. Pick by what the image has to show (our Next.js OG image comparison times both routes):
| You need | Use |
|---|---|
| A branded card with the post title, drawn from your own data | opengraph-image.tsx and ImageResponse |
| An image of a real page: a user's link, a competitor's site, a dashboard | The screenshot API |
| Your own page as it looks, with web fonts, grid and the full CSS | The screenshot API, with a signed URL |
| A PDF of a page | The screenshot API with format('pdf') |
The rest of this page is about the second and third rows.
Install
npm install renderscreenshot server-only
// lib/screenshots.ts import 'server-only'; import { Client } from 'renderscreenshot'; export const screenshots = new Client(process.env.RS_API_KEY ?? '', { timeout: 90_000, // longer than the API's 60-second maximum wait signingKey: process.env.RS_SIGNING_KEY, // rs_secret_... publicKeyId: process.env.RS_PUBLIC_KEY_ID, // rs_pub_... });
import 'server-only' makes next build fail if a Client Component ever imports this file, so the key cannot reach the browser bundle by accident. The SDK needs Node.js 20 or later. Route handlers, Server Actions and Server Components use the Node.js runtime by default, so you do not need a runtime export.
A Server Action that captures a pasted URL
A link-preview box is the common case: a user pastes a URL and sees a card. The action calls takeJson() instead of take(). The API keeps the image and returns its CDN URL, so no image bytes pass through your function or your storage:
// app/previews/actions.ts 'use server'; import { RenderScreenshotError, TakeOptions } from 'renderscreenshot'; import { screenshots } from '@/lib/screenshots'; export type PreviewState = | { status: 'idle' } | { status: 'ok'; imageUrl: string; title: string | null } | { status: 'error'; message: string }; export async function capturePreview(_prev: PreviewState, form: FormData): Promise<PreviewState> { let target: URL; try { target = new URL(String(form.get('url') ?? '')); } catch { return { status: 'error', message: 'Enter a full URL, including https://' }; } if (target.protocol !== 'https:' && target.protocol !== 'http:') { return { status: 'error', message: 'Only http and https URLs can be captured.' }; } try { const res = await screenshots.takeJson( TakeOptions.url(target.href).preset('og_card').format('webp').blockCookieBanners(), ); if (!res.image?.url) return { status: 'error', message: 'The render returned no image.' }; return { status: 'ok', imageUrl: res.image.url, title: res.metadata?.title ?? null }; } catch (err) { if (!(err instanceof RenderScreenshotError)) throw err; if (err.httpStatus === 429) { return { status: 'error', message: `Busy. Try again in ${err.retryAfter ?? 30} seconds.` }; } if (err.httpStatus === 422) { return { status: 'error', message: `That page could not be captured (${err.code}).` }; } if (err.httpStatus === 400) return { status: 'error', message: 'That URL cannot be captured.' }; console.error('capture failed', err.httpStatus, err.code); return { status: 'error', message: 'Capture failed. Please try again later.' }; } }
// app/previews/preview-form.tsx 'use client'; import { useActionState } from 'react'; import { capturePreview, type PreviewState } from './actions'; const initial: PreviewState = { status: 'idle' }; export function PreviewForm() { const [state, action, pending] = useActionState(capturePreview, initial); return ( <form action={action}> <input name="url" type="url" required placeholder="https://example.com" /> <button disabled={pending}>{pending ? 'Capturing…' : 'Capture'}</button> {state.status === 'error' && <p role="alert">{state.message}</p>} {state.status === 'ok' && ( <figure> <img src={state.imageUrl} width={600} height={315} alt={state.title ?? 'Page preview'} /> {state.title && <figcaption>{state.title}</figcaption>} </figure> )} </form> ); }
// app/previews/page.tsx import { PreviewForm } from './preview-form'; // Server Actions run in this page's function. export const maxDuration = 90; export default function PreviewsPage() { return ( <main> <h1>Link preview</h1> <PreviewForm /> </main> ); }
How the parts fit together:
- Errors become state, not exceptions. A thrown error in a Server Action reaches the client as a generic error in production. Returning
{ status: 'error' }keeps the message, anduseActionStatere-renders the form with it. Only unknown errors are re-thrown. - The scheme check runs before any credit is spent. The API refuses private and internal targets with
blocked, so you do not need an SSRF filter of your own. The check gives the user a clear message for afile:orjavascript:value. metadata.titlecomes from the real page. The API also sends the title, description and favicon asX-Screenshot-*headers on binary responses (response headers).- A 429 is shared. The rate limit is per account and covers every key, so a busy preview box slows down your other jobs too. Show the wait time and do not retry in a loop. The limits per plan are in the rate limits reference.
Function duration on Vercel
A Server Action runs inside the function of the page that renders it, so maxDuration goes in page.tsx, not in actions.ts. Set it above the API's longest render wait of 60 seconds (wait options) plus network time. Vercel caps maxDuration by plan, so check its function duration docs before you deploy. If your plan allows less than the render can take, set waitForTimeout() lower, or move the capture to a queue and use a webhook.
Signed thumbnails in a Server Component
For a gallery of sites, do not call the API from your server at all. Sign a GET URL per image and let the browser load it from the API:
// lib/thumbnails.ts import 'server-only'; import { TakeOptions } from 'renderscreenshot'; import { screenshots } from './screenshots'; const WEEK = 7 * 24 * 60 * 60 * 1000; export function thumbnailUrl(pageUrl: string, now = Date.now()): string { const expires = new Date((Math.floor(now / WEEK) + 2) * WEEK); // stable for a week return screenshots.generateUrl( TakeOptions.url(pageUrl).width(1280).height(800).format('webp').quality(70).cacheTtl(604_800), expires, ); }
// app/gallery/page.tsx import { thumbnailUrl } from '@/lib/thumbnails'; const SITES = ['https://example.com/', 'https://example.org/', 'https://example.net/']; export const revalidate = 86_400; // re-sign at most once a day export default function GalleryPage() { return ( <ul> {SITES.map((site) => ( <li key={site}> <img src={thumbnailUrl(site)} width={640} height={400} alt={`Home page of ${site}`} loading="lazy" /> </li> ))} </ul> ); }
Four things we found while we built this:
- The page is prerendered at build time.
next buildmarked/galleryas static with a one-day revalidate, and the signed URLs were already in the built HTML. SoRS_API_KEYand the signing keys must exist in the build environment as well as at runtime. WithoutRS_API_KEY, theClientconstructor throws "API key is required" as soon aslib/screenshots.tsloads. - Weeks start on Thursday. The Unix epoch began on a Thursday, so
Math.floor(now / WEEK)rolls over at Thursday 00:00 UTC. Our first test used a Tuesday and the following Thursday and failed for that reason. The expiry still always leaves 7 to 14 days, inside the 30-day maximum. - Use a plain
<img>.next/imageneeds the API host inimages.remotePatterns, and its optimizer then fetches the signed URL itself to resize it. Every fetch of a signed URL is a render. The image is already WebP at the size you asked for, so the optimizer adds cost and nothing else. cacheAvailable()is not in the npm release yet. The Node SDK reference lists it, butrenderscreenshot1.1.2 from npm has no such method, andtscrejected it. Until a release adds it, each crawler or browser fetch of these URLs renders and uses a credit. To signcache_available=truetoday, use the hand-written signing function in the signed URL docs.
Test with vitest
server-only throws when it is imported outside a React Server environment, and vitest is such a place. Alias it to an empty module:
// vitest.config.ts import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('.', import.meta.url)), 'server-only': fileURLToPath(new URL('./test/empty.ts', import.meta.url)), }, }, test: { environment: 'node' }, });
A Server Action is an async function, so the test calls it directly:
// test/actions.test.ts import { afterEach, expect, it, vi } from 'vitest'; import { RenderScreenshotError } from 'renderscreenshot'; import { screenshots } from '@/lib/screenshots'; import { capturePreview } from '@/app/previews/actions'; const form = (url: string) => { const f = new FormData(); f.set('url', url); return f; }; afterEach(() => vi.restoreAllMocks()); it('returns the CDN URL and the page title', async () => { vi.spyOn(screenshots, 'takeJson').mockResolvedValue({ id: 'req_1', status: 'completed', image: { url: 'https://cdn.renderscreenshot.com/screenshots/a.webp' }, metadata: { title: 'Example Domain' }, }); const state = await capturePreview({ status: 'idle' }, form('https://example.com')); expect(state).toMatchObject({ status: 'ok', title: 'Example Domain' }); }); it('turns a 429 into a message with the wait time', async () => { vi.spyOn(screenshots, 'takeJson').mockRejectedValue( new RenderScreenshotError(429, 'too_many_requests', 'Rate limit exceeded', 12), ); const state = await capturePreview({ status: 'idle' }, form('https://example.com')); expect(state).toEqual({ status: 'error', message: 'Busy. Try again in 12 seconds.' }); }); it('rejects non-http schemes before calling the API', async () => { const take = vi.spyOn(screenshots, 'takeJson'); await capturePreview({ status: 'idle' }, form('file:///etc/passwd')); expect(take).not.toHaveBeenCalled(); });
The suite also checks that thumbnailUrl() gives the same URL on a Thursday and the following Sunday and that the URL holds no secret. Run it with test values in RS_API_KEY, RS_SIGNING_KEY and RS_PUBLIC_KEY_ID. The Client constructor rejects an empty API key, and generateUrl() makes no request, so fake values are enough. For contract tests against a fake server, see the SDK testing guide.
Next steps
- If you only need a social card for your own posts, generating social cards with screenshots walks through the whole crawler path.
- SDK source and issues: github.com/Render-Screenshot/rs-node.
Get a free API key, add it to .env.local as RS_API_KEY, and paste a URL into the preview form. The free plan includes 150 credits every month.