How to Add Website Screenshots to Your React & Next.js App

February 21, 2026 · 10 min read

URL preview cards, link thumbnails, portfolio showcases, competitor dashboards -- they all need the same thing: a way to turn a URL into an image. If you're building with React or Next.js, integrating a website screenshot API is surprisingly straightforward.

This guide walks through practical patterns for adding live website screenshots to your app, from a simple <UrlPreview> component to server-side generation in Next.js API routes.

Why Use an API Instead of Puppeteer?

You could spin up a headless browser on your server. But here's why most teams don't:

A hosted screenshot API like GrabShot gives you a simple HTTP endpoint: send a URL, get back an image. No browser management, no infrastructure overhead.

Quick Start: Your First Screenshot

Before building components, let's confirm the API works. Grab a free API key from grabshot.dev/try.html and test with curl:

curl "https://grabshot.dev/api/screenshot?url=https://github.com&width=1280&height=800&format=png" \
  -H "X-API-Key: YOUR_API_KEY" \
  --output github.png

You should get a clean 1280x800 PNG of GitHub's homepage. Now let's put that into React.

Pattern 1: Simple URL Preview Component

The most common use case is displaying a thumbnail of a URL. Here's a reusable component:

// components/UrlPreview.jsx
import { useState } from 'react';

export default function UrlPreview({ url, width = 1280, height = 800 }) {
  const [loaded, setLoaded] = useState(false);
  const [error, setError] = useState(false);

  const screenshotUrl = `/api/screenshot?url=${encodeURIComponent(url)}`
    + `&width=${width}&height=${height}&format=webp`;

  if (error) {
    return (
      <div className="url-preview url-preview--error">
        <span>Preview unavailable</span>
      </div>
    );
  }

  return (
    <div className="url-preview">
      {!loaded && <div className="url-preview__skeleton" />}
      <img
        src={screenshotUrl}
        alt={`Screenshot of ${url}`}
        onLoad={() => setLoaded(true)}
        onError={() => setError(true)}
        style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
      />
    </div>
  );
}

Usage is dead simple:

<UrlPreview url="https://stripe.com" />
<UrlPreview url="https://linear.app" width={800} height={600} />

Notice we're proxying through /api/screenshot instead of calling the API directly from the browser. This keeps your API key on the server side. Let's build that proxy next.

Pattern 2: Next.js API Route (Server-Side Proxy)

Never expose your API key in client-side code. Create a Next.js API route that proxies requests:

// app/api/screenshot/route.js (Next.js App Router)
import { NextResponse } from 'next/server';

const API_KEY = process.env.GRABSHOT_API_KEY;
const BASE_URL = 'https://grabshot.dev/api/screenshot';

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const url = searchParams.get('url');

  if (!url) {
    return NextResponse.json({ error: 'url parameter required' }, { status: 400 });
  }

  // Build the upstream request
  const params = new URLSearchParams({
    url,
    width: searchParams.get('width') || '1280',
    height: searchParams.get('height') || '800',
    format: searchParams.get('format') || 'webp',
  });

  const response = await fetch(`${BASE_URL}?${params}`, {
    headers: { 'X-API-Key': API_KEY },
    // Cache for 1 hour on Vercel/CDN
    next: { revalidate: 3600 },
  });

  if (!response.ok) {
    return NextResponse.json(
      { error: 'Screenshot failed' },
      { status: response.status }
    );
  }

  const imageBuffer = await response.arrayBuffer();
  const contentType = response.headers.get('content-type') || 'image/webp';

  return new NextResponse(imageBuffer, {
    headers: {
      'Content-Type': contentType,
      'Cache-Control': 'public, max-age=3600, s-maxage=3600',
    },
  });
}

This gives you several wins: API key stays server-side, responses get cached at the edge, and you can add rate limiting or auth checks before proxying.

Pattern 3: On-Demand OG Image Generation

A powerful pattern for SaaS apps: generate OG images dynamically using screenshots. If your app has public pages (dashboards, profiles, reports), you can create shareable preview images automatically.

// app/api/og/route.js
export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const pageId = searchParams.get('id');

  // Build the URL for the page you want to screenshot
  const targetUrl = `https://yourapp.com/public/${pageId}`;

  const response = await fetch(
    `https://grabshot.dev/api/screenshot?` +
    `url=${encodeURIComponent(targetUrl)}` +
    `&width=1200&height=630&format=png`,
    { headers: { 'X-API-Key': process.env.GRABSHOT_API_KEY } }
  );

  const image = await response.arrayBuffer();

  return new Response(image, {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=86400',
    },
  });
}

Then in your page's metadata:

// app/public/[id]/page.jsx
export function generateMetadata({ params }) {
  return {
    openGraph: {
      images: [`/api/og?id=${params.id}`],
    },
  };
}

Every public page now has a perfectly accurate OG image that updates automatically. No design tools, no manual exports. For more on this pattern, see our guide on automated OG image generation.

Get Started with GrabShot

25 free screenshots per month. No credit card required. API key in 30 seconds.

Get Your Free API Key

Pattern 4: Caching Screenshots Effectively

Screenshots don't change every second. Smart caching saves API calls and speeds up your app:

// lib/screenshot-cache.js
const cache = new Map();
const CACHE_TTL = 60 * 60 * 1000; // 1 hour

export async function getCachedScreenshot(url, options = {}) {
  const cacheKey = `${url}-${JSON.stringify(options)}`;

  const cached = cache.get(cacheKey);
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.data;
  }

  const params = new URLSearchParams({
    url,
    width: options.width || '1280',
    height: options.height || '800',
    format: 'webp',
    ...options,
  });

  const response = await fetch(
    `https://grabshot.dev/api/screenshot?${params}`,
    { headers: { 'X-API-Key': process.env.GRABSHOT_API_KEY } }
  );

  const data = Buffer.from(await response.arrayBuffer());
  cache.set(cacheKey, { data, timestamp: Date.now() });

  return data;
}

For production, swap the in-memory Map for Redis or your CDN's cache headers. The pattern stays the same.

Pattern 5: Bulk Screenshots with React Query

Building a dashboard that shows many URLs at once? Use React Query (TanStack Query) to manage loading states and deduplication:

// hooks/useScreenshot.js
import { useQuery } from '@tanstack/react-query';

export function useScreenshot(url, options = {}) {
  return useQuery({
    queryKey: ['screenshot', url, options],
    queryFn: async () => {
      const params = new URLSearchParams({
        url,
        width: options.width || '1280',
        height: options.height || '800',
        format: 'webp',
      });
      const res = await fetch(`/api/screenshot?${params}`);
      if (!res.ok) throw new Error('Screenshot failed');
      const blob = await res.blob();
      return URL.createObjectURL(blob);
    },
    staleTime: 1000 * 60 * 60, // 1 hour
    retry: 2,
    enabled: !!url,
  });
}

// Usage in a component
function CompetitorCard({ competitor }) {
  const { data: imageUrl, isLoading } = useScreenshot(competitor.url);

  return (
    <div className="card">
      {isLoading ? <Skeleton /> : <img src={imageUrl} alt={competitor.name} />}
      <h3>{competitor.name}</h3>
    </div>
  );
}

React Query handles deduplication (same URL won't be fetched twice), background refetching, and error retry -- all automatically. For high-volume use cases, check out our batch screenshot API guide.

Python Alternative: Flask/FastAPI Backend

Not everyone uses Next.js for their backend. Here's a Python equivalent using FastAPI:

import httpx
from fastapi import FastAPI
from fastapi.responses import Response

app = FastAPI()
API_KEY = "your_grabshot_api_key"

@app.get("/api/screenshot")
async def screenshot(url: str, width: int = 1280, height: int = 800):
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            "https://grabshot.dev/api/screenshot",
            params={"url": url, "width": width, "height": height, "format": "webp"},
            headers={"X-API-Key": API_KEY},
            timeout=30.0,
        )
    return Response(
        content=resp.content,
        media_type="image/webp",
        headers={"Cache-Control": "public, max-age=3600"},
    )

Common Pitfalls and How to Avoid Them

PitfallSolution
API key in client bundleAlways proxy through a server route
No loading stateUse skeleton placeholders; screenshots take 1-3s
Fetching on every renderCache aggressively (CDN, React Query, in-memory)
CORS errorsProxy through your own API route (no direct browser calls)
Oversized imagesUse format=webp and request only the dimensions you need
Memory leaks with blob URLsCall URL.revokeObjectURL() on component unmount

When to Use Which Pattern

What's Next

You've got the building blocks. A few ideas to explore:

The GrabShot free tier gives you 25 screenshots per month to experiment with -- enough to build and test all the patterns above. Grab your API key and start building.