Payload CMS Open Graph Tags: Dynamic SEO for Your Headless CMS

Add og:title, og:image, og:description and Twitter card meta tags to a Next.js frontend powered by Payload CMS — including dynamic image generation.

Payload CMS + Next.js OG architecture

Payload CMS is a headless CMS — it stores your content and exposes it via a REST or GraphQL API. Your Next.js (or other) frontend fetches that content and renders HTML. OG tags should be set in your Next.js generateMetadata() function using data fetched from the Payload API.

Step 1: Add SEO fields to your Payload collection

Define an seo group in your Payload collection config:

// payload/collections/Posts.ts
import { CollectionConfig } from 'payload/types';

const Posts: CollectionConfig = {
  slug: 'posts',
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'excerpt', type: 'textarea' },
    { name: 'heroImage', type: 'upload', relationTo: 'media' },
    {
      name: 'seo',
      type: 'group',
      fields: [
        { name: 'ogTitle', type: 'text' },
        { name: 'ogDescription', type: 'textarea' },
        { name: 'ogImage', type: 'upload', relationTo: 'media' },
      ],
    },
  ],
};

export default Posts;

Step 2: generateMetadata in your Next.js page

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

async function getPost(slug: string) {
  const res = await fetch(
    `${process.env.PAYLOAD_API_URL}/api/posts?where[slug][equals]=${slug}&depth=1`,
    { next: { revalidate: 60 } }
  );
  const data = await res.json();
  return data.docs[0];
}

export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const post = await getPost(params.slug);
  const seoTitle = post.seo?.ogTitle || post.title;
  const seoDesc  = post.seo?.ogDescription || post.excerpt;
  const seoImage = post.seo?.ogImage?.url || post.heroImage?.url;

  return {
    title: seoTitle,
    description: seoDesc,
    openGraph: {
      title: seoTitle,
      description: seoDesc,
      images: seoImage ? [{ url: seoImage, width: 1200, height: 630 }] : [],
      type: 'article',
      url: `https://yourdomain.com/posts/${params.slug}`,
    },
    twitter: {
      card: 'summary_large_image',
      title: seoTitle,
      description: seoDesc,
      images: seoImage ? [seoImage] : [],
    },
  };
}

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

Using the @payloadcms/seo plugin

Payload's official @payloadcms/plugin-seo adds a ready-made SEO field group with auto-generated previews directly in the CMS UI:

// payload.config.ts
import seoPlugin from '@payloadcms/plugin-seo';

export default buildConfig({
  plugins: [
    seoPlugin({
      collections: ['posts', 'pages'],
      uploadsCollection: 'media',
      generateTitle: ({ doc }) => `${doc.title} | My Site`,
      generateDescription: ({ doc }) => doc.excerpt,
      generateImage: ({ doc }) => doc.heroImage,
      generateURL: ({ doc, collectionSlug }) =>
        `https://yourdomain.com/${collectionSlug}/${doc.slug}`,
    }),
  ],
});

Verify your Payload CMS OG tags

After deploying, paste your URL into OGFixer to preview exactly how Twitter, Slack, Discord, and LinkedIn will render your links.

Related guides