KeystoneJS Open Graph Tags: Add OG Meta to Your Headless CMS

How to add og:title, og:image, and og:description to KeystoneJS 6 sites — with document fields, custom virtual fields, and Next.js frontend rendering.

KeystoneJS architecture and OG tags

KeystoneJS 6 is a headless CMS with a GraphQL API — so your OG tags live entirely in the frontend layer (typically Next.js or Remix). KeystoneJS provides the data; your frontend is responsible for rendering the <meta> tags using that data.

This is actually good news: you get full control over OG metadata without fighting a CMS-level abstraction. The challenge is making sure your frontend queries all the fields needed for OG generation — including og:image — from your Keystone schema.

Step 1: Add OG fields to your Keystone schema

Add dedicated OG fields to your content list. This allows editors to override the defaults with custom social preview content:

// keystone.ts
import { config, list } from "@keystone-6/core";
import { text, image } from "@keystone-6/core/fields";
import { cloudinaryImage } from "@keystone-6/cloudinary";

export default config({
  lists: {
    Post: list({
      fields: {
        title: text({ validation: { isRequired: true } }),
        slug: text({ isIndexed: "unique" }),
        content: text({ ui: { displayMode: "textarea" } }),
        excerpt: text({ ui: { displayMode: "textarea" } }),
        featuredImage: image({ storage: "local_images" }),
        
        // OG-specific override fields
        ogTitle: text({
          label: "OG Title (optional)",
          ui: { description: "Leave blank to use the post title" },
        }),
        ogDescription: text({
          label: "OG Description (optional)",
          ui: {
            displayMode: "textarea",
            description: "Leave blank to use the excerpt",
          },
        }),
        ogImageUrl: text({
          label: "OG Image URL (optional)",
          ui: { description: "Leave blank to use the featured image" },
        }),
      },
    }),
  },
});

Step 2: Query OG fields in your Next.js frontend

In your Next.js frontend, query the OG fields from Keystone's GraphQL API usinggenerateMetadata:

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

const KEYSTONE_API = process.env.KEYSTONE_API_URL || "http://localhost:3000/api/graphql";

async function getPost(slug: string) {
  const res = await fetch(KEYSTONE_API, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query: `
        query GetPost($slug: String!) {
          post(where: { slug: $slug }) {
            title
            excerpt
            ogTitle
            ogDescription
            ogImageUrl
            featuredImage {
              url
              width
              height
            }
          }
        }
      `,
      variables: { slug },
    }),
    next: { revalidate: 60 },
  });
  
  const { data } = await res.json();
  return data.post;
}

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await getPost(params.slug);
  
  if (!post) return { title: "Post not found" };
  
  const ogTitle = post.ogTitle || post.title;
  const ogDescription = post.ogDescription || post.excerpt;
  const ogImageUrl = post.ogImageUrl || post.featuredImage?.url;
  
  return {
    title: ogTitle,
    description: ogDescription,
    openGraph: {
      title: ogTitle,
      description: ogDescription,
      type: "article",
      images: ogImageUrl
        ? [
            {
              url: ogImageUrl,
              width: post.featuredImage?.width || 1200,
              height: post.featuredImage?.height || 630,
            },
          ]
        : [],
    },
    twitter: {
      card: "summary_large_image",
      title: ogTitle,
      description: ogDescription,
      images: ogImageUrl ? [ogImageUrl] : [],
    },
  };
}

export default async function PostPage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

Step 3: Add virtual fields for computed OG values

For computed OG fields (e.g., auto-generating the OG image URL from a template), use KeystoneJS virtual fields:

import { virtual } from "@keystone-6/core/fields";
import { graphql } from "@keystone-6/core";

// In your Post list fields:
computedOgImageUrl: virtual({
  field: graphql.field({
    type: graphql.String,
    async resolve(item) {
      // Return custom ogImageUrl if set, otherwise generate one
      if (item.ogImageUrl) return item.ogImageUrl;
      
      // Generate OG image URL from a service like Vercel OG
      const title = encodeURIComponent(item.title || "");
      return `https://yoursite.com/api/og?title=${title}`;
    },
  }),
}),

Dynamic OG images with Vercel OG

If you're deploying your Next.js frontend on Vercel, add a dynamic OG image route:

// app/api/og/route.tsx
import { ImageResponse } from "next/og";

export const runtime = "edge";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get("title") || "My Blog";

  return new ImageResponse(
    (
      <div
        style={{
          display: "flex",
          flexDirection: "column",
          background: "linear-gradient(135deg, #0f172a, #1e293b)",
          width: "100%",
          height: "100%",
          padding: "60px",
          justifyContent: "flex-end",
        }}
      >
        <div
          style={{
            fontSize: 64,
            fontWeight: 900,
            color: "#f8fafc",
            lineHeight: 1.1,
          }}
        >
          {title}
        </div>
        <div
          style={{
            fontSize: 24,
            color: "#94a3b8",
            marginTop: 20,
          }}
        >
          yoursite.com
        </div>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

Common KeystoneJS OG pitfalls

  • Missing SSR: KeystoneJS frontend must be server-rendered for OG tags to work — pure CSR won't be crawled
  • Image CORS issues: Ensure your Keystone image storage domain allows cross-origin access for social crawlers
  • Absolute image URLs: Always use absolute URLs in og:image — relative paths won't work
  • Cache invalidation: When you update OG fields in Keystone, invalidate the Next.js cache for that page
  • Missing dimensions: Always include og:image:width and og:image:height to prevent layout issues

Preview your KeystoneJS OG tags

After deploying, paste your page URL into OGFixer to verify how your social previews look on Twitter, LinkedIn, Discord, and Slack.

Check your OG tags free →