Back to Blog

Next.js Dynamic OpenGraph Images with @vercel/og: Edge Runtime, Templates, and Caching

Generate dynamic OpenGraph images in Next.js with @vercel/og. Covers ImageResponse API, edge runtime execution, custom fonts, blog post and social card templates, cache headers, and file-based fallback images.

Viprasol Tech Team
April 12, 2027
11 min read

Sharing a link on Twitter or Slack with a blank gray preview is a missed opportunity. Dynamic OpenGraph images — generated per-page with the right title, author, category, and branding — dramatically increase click-through rates on social content. With @vercel/og, they generate at the edge in milliseconds using React JSX.

This guide covers templates for blog posts, landing pages, and user profiles, with caching strategies that keep your CDN bill reasonable.

How It Works

@vercel/og uses Satori (a JSX → SVG renderer) to convert React components to images. It runs at the edge (no cold start), supports a constrained subset of CSS (Flexbox only, no Grid), and returns a PNG response.

npm install @vercel/og

Blog Post OG Image

// app/og/route.tsx
import { ImageResponse } from "@vercel/og";
import { NextRequest } from "next/server";

export const runtime = "edge";

// Load fonts at module level (cached per edge instance)
const INTER_BOLD = fetch(
  new URL("../../public/fonts/Inter-Bold.ttf", import.meta.url)
).then((res) => res.arrayBuffer());

const INTER_REGULAR = fetch(
  new URL("../../public/fonts/Inter-Regular.ttf", import.meta.url)
).then((res) => res.arrayBuffer());

export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl;

  const title       = searchParams.get("title")?.slice(0, 100) ?? "Untitled";
  const description = searchParams.get("desc")?.slice(0, 160) ?? "";
  const category    = searchParams.get("cat") ?? "";
  const author      = searchParams.get("author") ?? "Viprasol Tech Team";
  const readTime    = searchParams.get("rt") ?? "";
  const type        = searchParams.get("type") ?? "post"; // post | page | profile

  const [interBold, interRegular] = await Promise.all([INTER_BOLD, INTER_REGULAR]);

  const CATEGORY_COLORS: Record<string, { bg: string; text: string }> = {
    "web-dev":   { bg: "#dbeafe", text: "#1d4ed8" },
    "ai-ml":     { bg: "#ede9fe", text: "#6d28d9" },
    "cloud":     { bg: "#d1fae5", text: "#065f46" },
    "saas":      { bg: "#fce7f3", text: "#9d174d" },
    "fintech":   { bg: "#fef3c7", text: "#92400e" },
    "trading":   { bg: "#fee2e2", text: "#991b1b" },
    "blockchain":{ bg: "#f3f4f6", text: "#374151" },
    "business":  { bg: "#e0f2fe", text: "#0c4a6e" },
  };

  const catStyle = CATEGORY_COLORS[category] ?? { bg: "#f3f4f6", text: "#374151" };
  const catLabel = category
    ? category.replace("-", " ").replace(/\b\w/g, (c) => c.toUpperCase())
    : "";

  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          backgroundColor: "#ffffff",
          fontFamily: "Inter",
          padding: "60px",
          position: "relative",
        }}
      >
        {/* Top accent bar */}
        <div style={{
          position: "absolute",
          top: 0, left: 0, right: 0,
          height: "6px",
          background: "linear-gradient(90deg, #2563eb 0%, #7c3aed 50%, #db2777 100%)",
        }} />

        {/* Logo */}
        <div style={{
          display: "flex",
          alignItems: "center",
          gap: "10px",
          marginBottom: "auto",
        }}>
          <div style={{
            width: "36px",
            height: "36px",
            borderRadius: "8px",
            backgroundColor: "#2563eb",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
          }}>
            <div style={{ color: "white", fontSize: "16px", fontWeight: "700" }}>V</div>
          </div>
          <span style={{ fontSize: "16px", fontWeight: "600", color: "#374151" }}>
            Viprasol
          </span>
        </div>

        {/* Category badge */}
        {catLabel && (
          <div style={{
            display: "flex",
            marginBottom: "20px",
          }}>
            <span style={{
              backgroundColor: catStyle.bg,
              color: catStyle.text,
              fontSize: "14px",
              fontWeight: "600",
              padding: "4px 14px",
              borderRadius: "100px",
              textTransform: "uppercase",
              letterSpacing: "0.05em",
            }}>
              {catLabel}
            </span>
          </div>
        )}

        {/* Title */}
        <div style={{
          fontSize: title.length > 60 ? "38px" : "48px",
          fontWeight: "700",
          color: "#111827",
          lineHeight: "1.2",
          marginBottom: "20px",
          maxWidth: "900px",
        }}>
          {title}
        </div>

        {/* Description */}
        {description && (
          <div style={{
            fontSize: "20px",
            color: "#6b7280",
            lineHeight: "1.5",
            maxWidth: "800px",
            marginBottom: "32px",
          }}>
            {description.length > 120 ? description.slice(0, 120) + "…" : description}
          </div>
        )}

        {/* Footer */}
        <div style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          borderTop: "1px solid #e5e7eb",
          paddingTop: "24px",
          marginTop: "auto",
        }}>
          <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
            <div style={{
              width: "32px", height: "32px", borderRadius: "50%",
              backgroundColor: "#dbeafe",
              display: "flex", alignItems: "center", justifyContent: "center",
            }}>
              <span style={{ fontSize: "14px", color: "#2563eb", fontWeight: "700" }}>
                {author[0]}
              </span>
            </div>
            <span style={{ fontSize: "16px", color: "#374151" }}>{author}</span>
          </div>

          {readTime && (
            <span style={{ fontSize: "15px", color: "#9ca3af" }}>
              {readTime}
            </span>
          )}
        </div>
      </div>
    ),
    {
      width: 1200,
      height: 630,
      fonts: [
        { name: "Inter", data: interBold,    weight: 700 },
        { name: "Inter", data: interRegular, weight: 400 },
      ],
    }
  );
}

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 1000+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Metadata Integration

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPost } from "@/lib/cms";

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

  const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;

  // Build OG image URL with query params
  const ogImageUrl = new URL("/og", APP_URL);
  ogImageUrl.searchParams.set("title",  post.title);
  ogImageUrl.searchParams.set("desc",   post.excerpt ?? "");
  ogImageUrl.searchParams.set("cat",    post.category);
  ogImageUrl.searchParams.set("author", post.author.name);
  ogImageUrl.searchParams.set("rt",     post.readTime);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt ?? undefined,
      type: "article",
      publishedTime: post.publishedAt,
      authors: [post.author.name],
      images: [{
        url: ogImageUrl.toString(),
        width: 1200,
        height: 630,
        alt: post.title,
      }],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt ?? undefined,
      images: [ogImageUrl.toString()],
      creator: "@viprasol",
    },
  };
}

Caching Strategy

OG images don't change often — cache them aggressively:

// app/og/route.tsx — add cache headers
export async function GET(req: NextRequest) {
  // ... image generation ...

  const response = new ImageResponse(/* ... */, { width: 1200, height: 630, fonts });

  // Cache at CDN for 7 days, stale-while-revalidate for 30 days
  response.headers.set(
    "Cache-Control",
    "public, immutable, no-transform, max-age=604800, stale-while-revalidate=2592000"
  );

  return response;
}

For Vercel: OG images are cached at the edge automatically. For self-hosted, add a CDN in front.

Next.js - Next.js Dynamic OpenGraph Images with @vercel/og: Edge Runtime, Templates, and Caching

🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Profile Card Template

// app/og/profile/route.tsx — user profile OG card
import { ImageResponse } from "@vercel/og";
import { NextRequest } from "next/server";

export const runtime = "edge";

export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl;
  const name       = searchParams.get("name") ?? "User";
  const title      = searchParams.get("title") ?? "";
  const projects   = searchParams.get("projects") ?? "0";
  const avatarUrl  = searchParams.get("avatar") ?? "";

  return new ImageResponse(
    (
      <div style={{
        width: "100%", height: "100%",
        display: "flex", flexDirection: "column", alignItems: "center",
        justifyContent: "center",
        backgroundColor: "#0f172a",
        fontFamily: "system-ui",
        gap: "16px",
      }}>
        {/* Gradient blob */}
        <div style={{
          position: "absolute",
          top: "50%", left: "50%",
          transform: "translate(-50%, -50%)",
          width: "400px", height: "400px",
          borderRadius: "50%",
          background: "radial-gradient(circle, rgba(37,99,235,0.3) 0%, transparent 70%)",
        }} />

        {avatarUrl ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={avatarUrl}
            width={96} height={96}
            style={{ borderRadius: "50%", border: "3px solid rgba(255,255,255,0.2)" }}
            alt={name}
          />
        ) : (
          <div style={{
            width: "96px", height: "96px", borderRadius: "50%",
            backgroundColor: "#2563eb",
            display: "flex", alignItems: "center", justifyContent: "center",
            fontSize: "40px", color: "white", fontWeight: "700",
          }}>
            {name[0]}
          </div>
        )}

        <div style={{ color: "white", fontSize: "36px", fontWeight: "700" }}>
          {name}
        </div>
        {title && (
          <div style={{ color: "#94a3b8", fontSize: "20px" }}>{title}</div>
        )}
        <div style={{
          display: "flex", gap: "24px", marginTop: "8px",
        }}>
          <div style={{ textAlign: "center" }}>
            <div style={{ color: "white", fontSize: "24px", fontWeight: "700" }}>
              {projects}
            </div>
            <div style={{ color: "#64748b", fontSize: "14px" }}>Projects</div>
          </div>
        </div>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

File-Based Fallback

For pages without dynamic OG images, use a static fallback:

// app/layout.tsx — default OG image
export const metadata: Metadata = {
  openGraph: {
    images: ["/og-default.png"], // Static file in /public
  },
};

Satori CSS Constraints

Things that work in @vercel/og:

  • Flexbox (display: "flex", flexDirection, alignItems, justifyContent)
  • position: "absolute" with top, left, right, bottom
  • border, borderRadius, borderColor
  • background, backgroundColor, backgroundImage (gradients via CSS string)
  • Custom fonts (TTF, OTF via fetch)
  • <img> elements with absolute URLs

Things that don't work:

  • CSS Grid (display: "grid")
  • CSS variables (var(--color))
  • overflow: "hidden" (partially supported)
  • Web fonts via Google Fonts URL (must fetch TTF directly)
  • SVG <image> tags

Investment and Timeline

ScopeTeamTimelineCost Range
Single OG template (blog post)1 dev0.5–1 day$150–300
Full set (post + page + profile + social)1 dev2–3 days$400–800
Design-matched templates + custom fonts1 dev3–5 days$800–1,500

Cost to run: Essentially free on Vercel (edge invocations) and cached at CDN. On self-hosted, each image is ~10–50ms CPU and cached after first render.

More on This Topic


Viprasol in Action

Dynamic OG images consistently improve social share click-through rates — posts with branded, content-specific preview images perform 2–3× better than those with generic images or blank previews. Our team designs and implements @vercel/og templates that match your brand, with custom fonts, category colors, and metadata pulled directly from your CMS.

What we deliver:

  • Blog post OG image with title, description, category badge, and author
  • Profile card template with avatar and stats
  • Metadata helpers for generateMetadata() in App Router
  • Proper Cache-Control headers for CDN caching
  • Satori-compatible Flexbox layout (tested across card sizes)

Talk to our team about your social sharing setup →

Or explore our web development services.

Next.jsOpenGraphSocialEdge RuntimeTypeScriptSEOPerformance
Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.