Generating Social Cards in Next.js Without Vercel Lock-in

Rendex Team··6 min read
nextjstutorialog-images
Next.js route handler code generating Next.js OG images with the Rendex API: html parameter with 1200x630 viewport and Cache-Control headers

If you deploy Next.js on Netlify, Railway, Fly.io, or a VPS, @vercel/ogcauses friction. It was built for Vercel's Edge Runtime and its Satori dependency pulls in WebAssembly blobs that break on other runtimes or add 300KB to your function bundle. For a feature that runs once per page load, that tradeoff rarely makes sense.

The alternative: call a rendering API from your route handler. Pass HTML, get back a PNG. No WASM, no runtime lock-in, works anywhere Next.js runs.

Prerequisites

  • Next.js 14 or 15 (App Router)
  • A Rendex API key (free tier, 100 calls/month; get one at rendex.dev/login)
  • Your API key stored in .env.local as RENDEX_API_KEY=rdx_your_key

Step 1: Create the OG Image Route

Create a route handler that accepts page metadata as query parameters and returns a PNG image. This route becomes the og:image URL for every page.

app/og/route.ts
import { NextRequest } from "next/server"

export const runtime = "nodejs"  // works on any host, not Edge-only

export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl
  const title = searchParams.get("title") ?? "My Site"
  const description = searchParams.get("description") ?? ""

  const html = buildOgHtml(title, description)

  const res = await fetch("https://api.rendex.dev/v1/screenshot", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RENDEX_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      format: "png",
      width: 1200,
      height: 630,
    }),
  })

  if (!res.ok) {
    return new Response("Failed to generate image", { status: 500 })
  }

  const buffer = await res.arrayBuffer()

  return new Response(buffer, {
    headers: {
      "Content-Type": "image/png",
      "Cache-Control": "public, max-age=86400, s-maxage=86400",
    },
  })
}

function buildOgHtml(title: string, description: string): string {
  return `
    <html>
    <head>
      <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
          width: 1200px;
          height: 630px;
          display: flex;
          flex-direction: column;
          justify-content: center;
          padding: 80px;
          background: #0f0f0f;
          font-family: system-ui, -apple-system, sans-serif;
          color: #fff;
        }
        .tag {
          font-size: 18px;
          color: #888;
          text-transform: uppercase;
          letter-spacing: 2px;
          margin-bottom: 24px;
        }
        h1 {
          font-size: 64px;
          font-weight: 700;
          line-height: 1.1;
          margin-bottom: 24px;
          max-width: 900px;
        }
        p {
          font-size: 28px;
          color: #aaa;
          max-width: 800px;
          line-height: 1.4;
        }
        .brand {
          position: absolute;
          bottom: 48px;
          right: 80px;
          font-size: 20px;
          color: #555;
        }
      </style>
    </head>
    <body>
      <div class="tag">yoursite.com</div>
      <h1>${title}</h1>
      <p>${description}</p>
      <div class="brand">yoursite.com</div>
    </body>
    </html>
  `
}

Step 2: Wire Up Metadata

Point each page's openGraph.imagesat the route handler, passing the page's title and description as query params.

app/blog/[slug]/page.tsx
import type { Metadata } from "next"

export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}): Promise<Metadata> {
  const post = await getPost(params.slug)
  if (!post) return {}

  const ogUrl = new URL("https://yoursite.com/og")
  ogUrl.searchParams.set("title", post.title)
  ogUrl.searchParams.set("description", post.description)

  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      images: [
        {
          url: ogUrl.toString(),
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.description,
      images: [ogUrl.toString()],
    },
  }
}

Step 3: Add a Site-Wide Default

Set a static fallback in your root layout.tsx for pages that do not generate custom metadata:

app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL("https://yoursite.com"),
  openGraph: {
    images: [
      {
        url: "/og?title=My+Site&description=The+tagline+goes+here",
        width: 1200,
        height: 630,
      },
    ],
  },
}

Step 4: Cache the Generated Images

The route handler adds Cache-Control: public, max-age=86400 headers. CDN and browser caches hold the image for 24 hours. For content that changes rarely (blog posts, docs pages), increase the max-age:

app/og/route.ts (cache header)
// For evergreen content — cache for 7 days
"Cache-Control": "public, max-age=604800, s-maxage=604800, stale-while-revalidate=86400"

If a page's metadata changes (title edit, description update), the query string changes and the CDN treats it as a new URL. No manual cache invalidation needed.

Step 5: Test Before Deploying

Verify the Rendex API can render your HTML before wiring it into Next.js. Paste your HTML template into the HTML-to-image tool for a quick preview, or use curl:

test-og.sh
curl -X POST https://api.rendex.dev/v1/screenshot \
  -H "Authorization: Bearer rdx_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<html><body style=\"background:#000;color:#fff;width:1200px;height:630px;display:flex;align-items:center;justify-content:center;font-size:64px;font-family:system-ui\">Hello OG</body></html>",
    "format": "png",
    "width": 1200,
    "height": 630
  }' --output test-og.png && open test-og.png

Get your API key at rendex.dev/login. The free tier gives you 100 renders/month, more than enough for testing.

Troubleshooting

Image shows as broken link in social previews: Social crawlers require the URL to return an image directly (not a redirect). Make sure your route handler returns the PNG bytes with Content-Type: image/png, not a JSON response wrapping a URL.

Text is cut off or overflows: The rendered viewport is exactly 1200x630 pixels. If your title is long, reduce the font size or add overflow: hidden to your container. Test long titles explicitly using the OG preview tool before deploying.

Route returns 500 in production: Check that RENDEX_API_KEYis set in your host's environment variable config. On Netlify, this goes in Site Configuration > Env Vars. On Railway, it goes in the service Variables tab.

Custom fonts are not loading: Use system fonts (system-ui, sans-serif) or inline a base64 font face in your HTML. The Rendex API renders the HTML in isolation. Fonts must be inlined or referenced by absolute public URL.

How This Compares to @vercel/og

Dimension@vercel/ogRendex API
Hosting requirementVercel Edge (Satori + WASM)Any host, any runtime
Template languageJSX (limited CSS subset)Full HTML + CSS + JS
CSS supportSubset (no Grid, no clip-path)Full Chromium CSS
Bundle size impact~300KB WASMNone (external API call)
CachingCDN via VercelYou set Cache-Control headers

The main trade-off: @vercel/og runs locally in your function and has no per-call cost. The API approach adds a network hop per unique image but removes all runtime constraints. For teams already off Vercel, the API is the simpler path.

For more on generating dynamic images and the full parameter reference, see the Rendex API reference and the dynamic OG images guide, which covers Astro and Nuxt integrations alongside Next.js.

Next Steps

Try the HTML-to-image tool to preview your OG template without writing any code. When your design is ready, get a free API key and drop the route handler into your Next.js project.

Try Rendex Free

100 screenshots/month. No credit card required.

Get API Key