Dynamic OG Images for Every Page Using Cloudflare Screenshot API

So I have 20+ products. MagicSlides, AskVideo, SheetAI, BlurScreen, CertifySimple, CueEdit, and more. Each one has dozens of pages. Blog posts, landing pages, tool pages. Every single one needs an OG image for when someone shares it on Twitter or LinkedIn or wherever.

Designing OG images manually? Not happening. Using @vercel/og with custom React components? Works but you're basically building a second UI for every page layout. I wanted something dumber and simpler.

So I just screenshot the actual page. That's it. The page itself becomes the OG image.

How It Works

Cloudflare has a service called Browser Rendering. It's basically a headless browser you can hit via API. You send it a URL, it loads the page, takes a screenshot at whatever viewport size you want, and sends back the image.

I created a single API route — /api/screenshot — that wraps this. Then in my SEO metadata, the OG image URL points to that route with the page URL as a parameter.

When Twitter or LinkedIn or Slack tries to unfurl your link, it hits /api/screenshot?url=https://yoursite.com/some-page, which tells Cloudflare to screenshot that page at 1200x630 (standard OG dimensions), and returns the PNG.

No Figma. No templates. No custom React OG components. Just your actual page, screenshotted.

The Code

Here's the full API route. This is everything. One file.

// src/app/api/screenshot/route.ts
import { NextRequest, NextResponse } from "next/server";

const OG_WIDTH = 1200;
const OG_HEIGHT = 630;

export async function GET(req: NextRequest) {
  const url = req.nextUrl.searchParams.get("url");
  if (!url) {
    return NextResponse.json(
      { error: "Missing ?url= parameter" },
      { status: 400 }
    );
  }

  const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
  const apiToken = process.env.CLOUDFLARE_BR_API_TOKEN;
  if (!accountId || !apiToken) {
    return NextResponse.json(
      { error: "Cloudflare Browser Rendering not configured" },
      { status: 500 }
    );
  }

  try {
    const res = await fetch(
      `https://api.cloudflare.com/client/v4/accounts/${accountId}/browser-rendering/screenshot`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiToken}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          url,
          screenshotOptions: { fullPage: false, type: "png" },
          viewport: { width: OG_WIDTH, height: OG_HEIGHT },
          gotoOptions: {
            timeout: 15000,
            waitUntil: "networkidle0",
          },
        }),
      }
    );

    if (!res.ok) {
      const text = await res.text().catch(() => "Unknown error");
      console.error("Screenshot failed:", res.status, text);
      return NextResponse.json(
        { error: "Screenshot failed" },
        { status: 502 }
      );
    }

    const imageBuffer = await res.arrayBuffer();

    return new NextResponse(imageBuffer, {
      status: 200,
      headers: {
        "Content-Type": "image/png",
        "Cache-Control":
          "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800",
      },
    });
  } catch (err) {
    console.error("Screenshot route error:", err);
    return NextResponse.json(
      { error: "Internal error" },
      { status: 500 }
    );
  }
}

That's literally it. The Cache-Control header is important — it caches the screenshot for 24 hours at the CDN level so Cloudflare isn't re-screenshotting the same page every time someone shares your link.

Wiring It Into Your SEO Metadata

I have a shared generateMetaData function that all my pages use. The OG image URL is constructed automatically:

// src/lib/seo.ts
const BASE_URL = "https://www.yoursite.com";

export function generateMetaData({ title, description, slug, ogimage }) {
  const normalizedSlug = slug.startsWith("/") ? slug : `/${slug}`;
  const pageUrl = `${BASE_URL}${normalizedSlug}`;
  const ogImageUrl =
    ogimage ??
    `${BASE_URL}/api/screenshot?url=${encodeURIComponent(pageUrl)}`;

  return {
    title,
    description,
    alternates: {
      canonical: `${BASE_URL}${normalizedSlug}`,
    },
    openGraph: {
      title,
      description,
      images: [
        {
          url: ogImageUrl,
          width: 1200,
          height: 630,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title,
      description,
      images: [ogImageUrl],
    },
  };
}

So if I don't pass a custom ogimage, every page automatically gets a screenshot of itself as the OG image. If I want a custom one for a specific page, I just pass the ogimage param and it overrides.

Then in any page:

export async function generateMetadata({ params }) {
  return generateMetaData({
    title: "Some Page Title",
    description: "Some description",
    slug: `/articles/${params.slug}`,
    type: "article",
  });
}

Done. Every article, every landing page, every tool page — they all get OG images automatically.

Setting Up Cloudflare Browser Rendering

You need two things from Cloudflare:

Get your credentials

  1. Log into your Cloudflare dashboard
  2. Your Account ID is in the right sidebar of the overview page
  3. Go to My Profile → API Tokens → Create Token
  4. Create a token with the Browser Rendering permission (or use a broader token if you already have one)

Add env vars

Add these to your .env.local:

CLOUDFLARE_ACCOUNT_ID="your-account-id"
CLOUDFLARE_BR_API_TOKEN="your-api-token"

And add them to your hosting platform too. If you're on Vercel:

vercel env add CLOUDFLARE_ACCOUNT_ID production
vercel env add CLOUDFLARE_BR_API_TOKEN production

That's the entire setup. No npm packages to install. No headless browser to run yourself. Cloudflare handles all of that.

Why I Use This Across All My Products

I use the exact same /api/screenshot route in CueEdit, this site, and I'm rolling it out to more. The setup is identical everywhere — copy the route file, add the two env vars, point your OG metadata at it.

The reasons I like this over alternatives:

No design work. My pages already look good. Why design a separate OG card when the page itself is the best preview?

Scales to any number of pages. I don't need to create a template for each page type. Blog posts, tool pages, landing pages — they all just get screenshotted.

Always up to date. If I change the page design, the OG image updates automatically after the cache expires. No stale images floating around.

One setup for all products. Same Cloudflare account, same API route, same env vars. I just copy it into each Next.js project and it works.

Cheap. Cloudflare Browser Rendering pricing is very reasonable. With the 24-hour cache, you're barely hitting it at all. Most unfurls are served from the CDN cache.

When You Might Want a Custom OG Instead

This approach isn't perfect for everything. If you need:

  • Text overlays on top of a branded background
  • A specific template with your logo and author photo
  • Different OG images for different social platforms

Then you probably want @vercel/og or a proper OG image generation service. But for most pages — especially blog posts and product pages — a screenshot of the actual page works great and takes zero effort to maintain.

The Full Setup Checklist

  1. Create /api/screenshot/route.ts with the code above
  2. Create /lib/seo.ts with the generateMetaData function
  3. Add CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_BR_API_TOKEN to your env
  4. Use generateMetaData in your page metadata
  5. Deploy

That's it. Every page on your site now has a dynamic OG image. I did this for my personal site and multiple products in under 10 minutes each. If you're building products fast and don't want to waste time on OG images, this is the move.

Follow me for more — @sanskarr.tiwari