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 100+ 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.

๐Ÿš€ 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

Cost and Timeline Estimates

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.

See Also


Working With Viprasol

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.

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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

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.