Multi-Tenant SEO with Payload & Next.js — Complete Guide

Implement tenant-aware SEO with Payload CMS and Next.js: automated metadata, tenant branding, image URL…

·Matija Žiberna·
Multi-Tenant SEO with Payload & Next.js — Complete Guide

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

I was building a multi-tenant application with Payload CMS and Next.js when I hit a common challenge: implementing comprehensive SEO that works seamlessly across different tenants. The existing documentation showed separate approaches for Payload and Next.js, but nothing that tied them together in a multi-tenant context. After hours of experimentation, I developed a solution that automates SEO generation while maintaining tenant-specific branding. This guide shows you exactly how to implement intelligent SEO that scales across your multi-tenant architecture.

The Challenge

With multiple tenants (e.g., tenant-a and tenant-b), you need to:

  • Generate SEO metadata automatically from content to reduce editor workload
  • Maintain tenant-specific branding in all metadata (titles, descriptions, images)
  • Support both production domains and local development
  • Ensure proper fallbacks when manual SEO data isn't provided
  • Keep the implementation simple and maintainable

Understanding the SEO Architecture

Before diving into implementation, it's crucial to understand how SEO works in a Payload + Next.js stack. You have two distinct but complementary systems:

Payload CMS handles the data layer through its SEO plugin, storing metadata like titles, descriptions, and images in your database. The plugin automatically adds a meta field (containing title, description, and image) to your configured collections, providing an interface in the admin panel for editors to manage SEO.

Next.js handles the presentation layer through its Metadata API, converting your stored data into proper HTML <head> tags that search engines and social media platforms can understand. When generateMetadata functions run, they fetch data and transform it into Next.js Metadata objects, which are automatically converted to <meta> tags.

In a multi-tenant context, this architecture becomes even more powerful because you can have tenant-specific SEO data that automatically adapts to the current tenant context. Each tenant can have its own branding, domain, and defaults while sharing the same codebase.

Setting Up the Payload SEO Plugin

Let's start by configuring the Payload SEO plugin to work intelligently with our multi-tenant setup. The key is implementing auto-generation functions that reduce manual work while ensuring consistency across tenants.

Open your payload.config.ts file and enhance the SEO plugin configuration. In our implementation, we leverage two helper functions from src/payload/utilities/seo.ts:

// File: payload.config.ts
import { generateSeoTitle, generateSeoDescription } from '@/payload/utilities/seo';

// ... other config ...

seoPlugin({
  collections: [Pages.slug, Posts.slug, Products.slug, CaseStudy.slug, Project.slug, JobOpening.slug],
  uploadsCollection: Media.slug,
  tabbedUI: false, // SEO fields added directly to collection, not in separate tab

  // Auto-generate SEO titles with tenant context
  generateTitle: generateSeoTitle,

  // Auto-generate descriptions from content fields
  generateDescription: generateSeoDescription,

  // Note: generateImage and generateURL can be added for more automation
  // For now, editors manually set the meta.image field in the admin UI
})

The generateSeoTitle and generateSeoDescription functions are defined in src/payload/utilities/seo.ts and work like this:

// File: src/payload/utilities/seo.ts

/**
 * Generate SEO title with tenant context
 * Format: "{page title} | {tenant name}" (max 60 chars)
 * Respects manually set meta.title if already defined
 */
export async function generateSeoTitle({
  doc,
}: {
  doc: any;
}): Promise<string> {
  try {
    // If meta.title already set, don't override
    if (doc.meta?.title) {
      return doc.meta.title;
    }

    const title = doc.title || "";
    if (!title) return "";

    // Get tenant name for suffix
    let tenantName = "Default Tenant";
    if (doc.tenant) {
      const config = await getTenantConfig(doc.tenant);
      if (config?.name) {
        tenantName = config.name;
      }
    }

    // Combine and truncate to 60 chars (SEO best practice)
    const combined = `${title} | ${tenantName}`;
    return combined.length > 60 ? combined.substring(0, 57) + "..." : combined;
  } catch (error) {
    console.warn("[generateSeoTitle] Error:", error);
    return "";
  }
}

/**
 * Generate SEO description from content fields
 * Tries: excerpt → shortDescription → description (max 160 chars)
 * Respects manually set meta.description if already defined
 */
export async function generateSeoDescription({
  doc,
}: {
  doc: any;
}): Promise<string> {
  try {
    // If meta.description already set, don't override
    if (doc.meta?.description) {
      return doc.meta.description;
    }

    // Try common description fields in order of preference
    const candidates = [
      doc.excerpt,           // For blog posts
      doc.shortDescription,  // For products
      doc.description,       // Generic description field
    ];

    for (const candidate of candidates) {
      if (candidate && typeof candidate === "string") {
        // Truncate to 160 chars (SEO best practice)
        return candidate.length > 160
          ? candidate.substring(0, 157) + "..."
          : candidate;
      }
    }

    return "";
  } catch (error) {
    console.warn("[generateSeoDescription] Error:", error);
    return "";
  }
}

Key Benefits of This Approach:

  • Editors don't have to manually enter SEO data for every field
  • Intelligent fallbacks ensure something is always set
  • Tenant-specific branding is automatically applied
  • Editors can still override auto-generated values in the admin UI
  • The functions are simple and easy to debug when needed

Tenant Configuration and Caching

The foundation of our multi-tenant SEO system is intelligent tenant configuration management. Instead of hardcoding tenant names and descriptions throughout the codebase, we fetch them from the database and cache the results to minimize queries.

In your src/payload/utilities/seo.ts, we define the TenantConfig interface and getTenantConfig function:

// File: src/payload/utilities/seo.ts

// Cache for tenant configs to avoid repeated database queries
const tenantConfigCache = new Map<string | number, TenantConfig | null>();

export interface TenantConfig {
  name: string;
  slug: string;
  domain?: string;
  description?: string;
}

/**
 * Get tenant configuration with caching
 * Fetches tenant branding information for use in SEO metadata
 */
export async function getTenantConfig(
  tenant: Tenant | string | number | undefined | null,
): Promise<TenantConfig | null> {
  if (!tenant) return null;

  // Extract ID from tenant object or use directly
  let tenantId: string | number;
  if (typeof tenant === "string" || typeof tenant === "number") {
    tenantId = tenant;
  } else if (typeof tenant === "object" && tenant && "id" in tenant) {
    tenantId = (tenant as any).id;
  } else {
    return null;
  }

  // Check cache first
  const cacheKey = String(tenantId);
  if (tenantConfigCache.has(cacheKey)) {
    return tenantConfigCache.get(cacheKey) || null;
  }

  // Fetch from database
  try {
    const tenantData = await getTenantById(tenantId);
    if (tenantData) {
      const config: TenantConfig = {
        name: (tenantData as any).name || "Default Tenant",
        slug: (tenantData as any).slug || "",
        domain: (tenantData as any).domain,
        description: (tenantData as any).description,
      };
      tenantConfigCache.set(cacheKey, config);
      return config;
    }
  } catch (error) {
    console.warn(`[getTenantConfig] Error fetching tenant ${tenantId}:`, error);
  }

  // Cache null result to avoid repeated failed queries
  tenantConfigCache.set(cacheKey, null);
  return null;
}

This approach has several benefits:

  1. Database Query Optimization: Results are cached in a Map, so repeated requests for the same tenant don't hit the database
  2. Async/Await Support: The function is async, allowing us to call it from anywhere (Payload hooks, Next.js routes, etc.)
  3. Type Safety: The TenantConfig interface ensures consistent tenant data across the application
  4. Graceful Fallbacks: If tenant lookup fails, cached null prevents infinite retry attempts
  5. Multi-Tenant Flexibility: Works with any tenant format (ID, slug, or full object)

Core SEO Metadata Generation

With tenant configuration in place, we can build a robust SEO metadata generator that works across all content types. The core function handles the heavy lifting of converting Payload SEO data to Next.js Metadata, including intelligent image URL transformation for multi-tenant support.

Image URL Transformation

One critical aspect of multi-tenant SEO is ensuring that media URLs work correctly across both development and production environments. The transformImageUrl() helper transforms image URLs to use the tenant-specific domain:

// File: src/payload/utilities/seo.ts

/**
 * Transform image URL hostname to match tenant's base URL
 * Handles development domain mapping and relative paths
 */
function transformImageUrl(imageUrl: string, baseUrl: string): string {
  if (!imageUrl) return imageUrl;

  // If URL is relative, prepend baseUrl
  if (imageUrl.startsWith('/')) {
    return `${baseUrl}${imageUrl}`;
  }

  try {
    // Extract hostname from baseUrl
    const baseUrlObj = new URL(baseUrl);
    const tenantHostname = baseUrlObj.hostname;
    const tenantProtocol = baseUrlObj.protocol;

    // Parse the image URL
    const imageUrlObj = new URL(imageUrl);

    // Replace hostname and protocol with tenant's
    imageUrlObj.hostname = tenantHostname;
    imageUrlObj.protocol = tenantProtocol;

    return imageUrlObj.toString();
  } catch {
    // If URL parsing fails, return original
    return imageUrl;
  }
}

Why this matters:

  • In development, transforms http://localhost/api/media/... to http://tenant-a.local/api/media/... or http://tenant-b.local/api/media/...
  • In production, ensures images use the correct tenant domain
  • Handles relative paths by prepending the tenant's base URL
  • Leverages getDevelopmentDomain() indirectly through getBaseUrl()

Core Metadata Function

// File: src/payload/utilities/seo.ts

// Update the function to be tenant-aware
async function getBaseUrl(tenant?: TenantConfig | null): Promise<string> {
  if (tenant?.domain) {
    // If tenant has a domain, use it
    const isDevelopment = process.env.NODE_ENV === 'development';
    const finalDomain = isDevelopment ? getDevelopmentDomain(tenant.domain) : tenant.domain;
    const protocol = isDevelopment ? 'http' : 'https';
    return `${protocol}://${finalDomain}`;
  }

  throw new Error("Tenant domain is required for URL generation");
}

export async function generateSEOMetadata(config: SEOConfig): Promise<Metadata> {
  const baseUrl = await getBaseUrl(config.tenant);
  const tenantDefaults = getDefaultTenantConfig(config.tenant || undefined);

  const defaultTitle = `${tenantDefaults.name}`;
  const defaultDescription = tenantDefaults.description || "Professional services";
  const siteName = config.tenant?.name || "Default Site";

  const seoTitle = config.title || defaultTitle;
  const seoDescription = config.description || defaultDescription;
  const seoUrl = config.url || baseUrl;
  const seoImage = config.image;

  // Transform image URL to use tenant's domain
  const seoImageUrl = seoImage?.url ? transformImageUrl(seoImage.url, baseUrl) : null;

  return {
    title: seoTitle,
    description: seoDescription,
    openGraph: {
      title: seoTitle,
      description: seoDescription,
      url: seoUrl,
      siteName: siteName,
      locale: "en_US",
      type: config.type || "website",
      images: seoImageUrl
        ? [
            {
              url: seoImageUrl,
              width: seoImage?.width || 1200,
              height: seoImage?.height || 630,
              alt: seoImage?.alt || seoTitle,
            },
          ]
        : config.ogImageUrl
          ? [
              {
                url: config.ogImageUrl,
                width: 1200,
                height: 630,
                alt: seoTitle,
              },
            ]
          : [
              {
                url: `${baseUrl}/og-default.jpg`,
                width: 1200,
                height: 630,
                alt: seoTitle,
              },
            ],
    },
    twitter: {
      card: "summary_large_image",
      title: seoTitle,
      description: seoDescription,
      images: seoImageUrl
        ? [seoImageUrl]
        : config.ogImageUrl
          ? [config.ogImageUrl]
          : [`${baseUrl}/og-default.jpg`],
    },
    alternates: {
      canonical: seoUrl,
    },
    robots: {
      index: !config.noIndex,
      follow: !config.noFollow,
      googleBot: {
        index: !config.noIndex,
        follow: !config.noFollow,
        "max-video-preview": -1,
        "max-image-preview": "large",
        "max-snippet": -1,
      },
    },
  };
}

/**
 * Generate SEO metadata for pages with type safety
 * Uses SEO plugin meta fields with intelligent fallbacks
 * Prerequisites: Route structure configured per [Production-Ready Multi-Tenant Setup](/blog/production-ready-multi-tenant-nextjs-payload#frontend-route-structure)
 */
export async function generatePageSEOMetadata(
  page: PageType,
  slug: string[],
  options?: {
    noIndex?: boolean;
    noFollow?: boolean;
    ogImageUrl?: string;
    tenant?: Tenant | string | number | null;
  },
): Promise<Metadata> {
  // Fetch tenant config if provided
  const tenant = options?.tenant || (page as any).tenant;
  const tenantConfig = await getTenantConfig(tenant);

  const baseUrl = await getBaseUrl(tenantConfig);
  const url = `${baseUrl}/${slug.join("/")}`;

  const seoTitle = page.meta?.title || page.title || undefined;
  const seoDescription = page.meta?.description || undefined;

  // Handle meta.image - it can be either a Media object (populated) or a number (ID only)
  let seoImage: Media | null = null;

  if (page.meta?.image) {
    if (typeof page.meta.image === "object" && page.meta.image !== null) {
      // Image is populated - use it directly
      seoImage = page.meta.image as Media;
    } else if (typeof page.meta.image === "number") {
      // Image is just an ID - not populated (should not happen with depth: 3)
      console.warn(`[SEO] meta.image is not populated (ID only: ${page.meta.image}) for page: ${page.title}. This indicates a depth or localization issue.`);
    }
  }

  return generateSEOMetadata({
    title: seoTitle,
    description: seoDescription,
    image: seoImage,
    url,
    type: "website",
    noIndex: options?.noIndex,
    noFollow: options?.noFollow,
    ogImageUrl: options?.ogImageUrl,
    tenant: tenantConfig,
  });
}

Key improvements in this approach:

  1. No Hardcoded Defaults: Tenant names and domains are fetched from the database, not hardcoded
  2. Smart Image Handling: Checks if meta.image is a populated Media object or just an ID reference
  3. SEO Best Practices: Includes Open Graph, Twitter Cards, robots directives, and canonical URLs
  4. Development Support: Maps production domains to local development domains (e.g., example-tenant.comexample-tenant.local)
  5. Async Support: Properly handles async tenant lookups and URL generation

Implementing Metadata Across Routes

With the core utilities in place, implementing metadata generation across all routes becomes straightforward. Let's look at three real-world examples from the codebase.

Main Catch-All Route

The catch-all route handles all general page requests (/, /about, /services, etc.). Here's the actual implementation:

// File: src/app/(frontend)/tenant-slugs/[tenant]/[...slug]/page.tsx

export async function generateMetadata({
  params,
}: Props): Promise<Metadata> {
  const resolvedParams = await params;
  const { slug, tenant } = resolvedParams;

  const safeSlug = normalizeSlug(slug);

  console.log("[SlugPage] Resolving tenant page:", { tenant, slug });

  let page: Page | null = null;

  try {
    // Fetch draft content for live preview with full depth for SEO data population
    page = await queryPageBySlug({ slug: safeSlug, tenant, draft: true });
  } catch (error) {
    console.error("Error fetching page for metadata:", error);
  }

  // Fallback metadata if page not found
  if (!page) {
    return {
      title: "Preview - Default",
      description: "Draft preview",
    };
  }

  // Use the SEO utility function to generate metadata with tenant context
  // The meta.image field from the SEO plugin will be used automatically
  return await generatePageSEOMetadata(page, safeSlug, { tenant });
}

Key points:

  • Fetches page with draft: true for preview mode
  • Passes slug as an array (automatically normalized)
  • Uses generatePageSEOMetadata() which handles all tenant configuration lookup
  • The tenant parameter is passed explicitly to ensure proper branding
  • No manual OG image generation—uses page.meta.image from Payload SEO plugin

Product Page Implementation

Products have specific SEO needs (pricing, availability). Here's how we handle them with a dedicated metadata function:

// File: src/app/(frontend)/tenant-slugs/[tenant]/products/[slug]/page.tsx

export async function generateMetadata({
  params,
}: ProductPageProps): Promise<Metadata> {
  const { slug, tenant } = await params;

  try {
    // Fetch product with full depth to ensure meta.image is populated
    const product = await getProductBySlug(slug, tenant, { depth: 3 });

    if (!product) {
      return {
        title: "Product Not Found",
        description: "The product you are looking for does not exist.",
      };
    }

    // Use dedicated product SEO metadata function
    // This handles product-specific fields like shortDescription
    return await generateProductSEOMetadata(product, [slug], { tenant });
  } catch (error) {
    console.error(`Error generating metadata for product ${slug}:`, error);
    return {
      title: "Product",
      description: "Product",
    };
  }
}

The corresponding SEO function in src/payload/utilities/seo.ts:

/**
 * Generate SEO metadata for products
 * Includes product-specific fields and e-commerce considerations
 */
export async function generateProductSEOMetadata(
  product: Product,
  slug: string[],
  options?: {
    noIndex?: boolean;
    noFollow?: boolean;
    ogImageUrl?: string;
    tenant?: Tenant | string | number | null;
  },
): Promise<Metadata> {
  // Fetch tenant config
  const tenant = options?.tenant || (product as any).tenant;
  const tenantConfig = await getTenantConfig(tenant);

  const baseUrl = await getBaseUrl(tenantConfig);
  const url = `${baseUrl}/${slug.join("/")}`;

  // Use SEO plugin meta fields if available, fallback to product fields
  const seoTitle = product.meta?.title || `${product.title} | Products`;
  const seoDescription =
    product.meta?.description || product.shortDescription || undefined;

  // Handle meta.image
  let seoImage: Media | null = null;
  if (product.meta?.image) {
    if (typeof product.meta.image === "object" && product.meta.image !== null) {
      seoImage = product.meta.image as Media;
    } else if (typeof product.meta.image === "number") {
      console.warn(`[SEO] meta.image not populated for product: ${product.title}`);
    }
  }

  return generateSEOMetadata({
    title: seoTitle,
    description: seoDescription,
    image: seoImage,
    url,
    type: "website",
    noIndex: options?.noIndex,
    noFollow: options?.noFollow,
    ogImageUrl: options?.ogImageUrl,
    tenant: tenantConfig,
  });
}

Key differences for products:

  • Falls back to shortDescription field if no SEO description
  • Appends " | Products" to title if using product title directly
  • Still respects SEO plugin's meta.title and meta.description if set by editor

Blog Post Implementation

Blog posts use an article schema and include post-specific metadata. Here's the pattern:

// File: src/app/(frontend)/tenant-slugs/[tenant]/post/[slug]/page.tsx

export async function generateMetadata({
  params,
}: PostPageProps): Promise<Metadata> {
  const { slug, tenant } = await params;

  try {
    // Fetch post with full depth for populated SEO and relationship data
    const post = await getPostBySlug(slug, tenant, { depth: 3 });

    if (!post) {
      return {
        title: "Post Not Found",
        description: "The post you are looking for does not exist.",
      };
    }

    // Use dedicated post SEO metadata function
    // Handles article-specific fields like publishedAt and excerpt
    return await generatePostSEOMetadata(post, [slug], { tenant });
  } catch (error) {
    console.error(`Error generating metadata for post ${slug}:`, error);
    return {
      title: "Post",
      description: "Post",
    };
  }
}

And the SEO utility function:

/**
 * Generate SEO metadata for blog posts
 * Includes post-specific fields like publishedAt and author
 */
export async function generatePostSEOMetadata(
  post: Post,
  slug: string[],
  options?: {
    noIndex?: boolean;
    noFollow?: boolean;
    ogImageUrl?: string;
    tenant?: Tenant | string | number | null;
  },
): Promise<Metadata> {
  // Fetch tenant config
  const tenant = options?.tenant || (post as any).tenant;
  const tenantConfig = await getTenantConfig(tenant);

  const baseUrl = await getBaseUrl(tenantConfig);
  const url = `${baseUrl}/${slug.join("/")}`;

  // Use SEO plugin meta fields if available, fallback to post fields
  const seoTitle = post.meta?.title || post.title;
  const seoDescription = post.meta?.description || post.excerpt || undefined;

  // Handle meta.image
  let seoImage: Media | null = null;
  if (post.meta?.image) {
    if (typeof post.meta.image === "object" && post.meta.image !== null) {
      seoImage = post.meta.image as Media;
    } else if (typeof post.meta.image === "number") {
      console.warn(`[SEO] meta.image not populated for post: ${post.title}`);
    }
  }

  return generateSEOMetadata({
    title: seoTitle,
    description: seoDescription,
    image: seoImage,
    url,
    type: "article", // Posts use article type
    noIndex: options?.noIndex,
    noFollow: options?.noFollow,
    ogImageUrl: options?.ogImageUrl,
    tenant: tenantConfig,
  });
}

Key differences for posts:

  • Uses article schema type instead of website
  • Falls back to excerpt field if no SEO description
  • Metadata function is specific to post fields

Pattern Summary

All route implementations follow this pattern:

  1. Resolve parameters: Await the params promise (Next.js 15+ requirement)
  2. Fetch content: Query the database with depth: 3 to populate relationships
  3. Handle missing content: Return basic fallback metadata
  4. Call specific utility: Use the appropriate metadata function (generatePageSEOMetadata, generateProductSEOMetadata, or generatePostSEOMetadata)
  5. Pass tenant context: Always provide the tenant so proper branding is applied

This separation of concerns makes the system maintainable—each content type has its own metadata function handling type-specific logic.

The SEO Data Flow

Here's how SEO data flows through the system, from content creation to social media sharing:

Step 1: Content Creation in Payload CMS

When editors create a new page, product, or post in Payload:

  • The SEO plugin's generateTitle function automatically creates a title in the format: {page title} | {tenant name}
  • The generateDescription function intelligently extracts description from available fields (excerpt, shortDescription, description)
  • The editor can manually upload an image for meta.image in the SEO section of the admin UI
  • If the editor doesn't manually set SEO fields, intelligent defaults ensure something is always present

Step 2: Publishing and Storage

Payload stores the complete document including:

  • Explicit SEO data: meta.title, meta.description, meta.image (if set by editor)
  • Content data: title, excerpt, shortDescription, etc.
  • Tenant relationship: Link to the Tenant record with domain and branding info

Step 3: Page Request

When a user visits a page (e.g., https://tenant-a.com/about):

  1. Next.js middleware identifies the tenant from the domain
  2. Next.js routing calls the appropriate generateMetadata function
  3. The route fetches content from Payload using depth: 3 to populate all relationships

Step 4: Metadata Generation in Next.js

The metadata generation function executes:

┌─ Fetch Page/Post/Product ──┐
│    with depth: 3           │
└──────────────┬─────────────┘
               ↓
┌─ Tenant Config Lookup ─────┐
│  - Fetch from Tenant table │
│  - Cache result in Map     │
│  - Use for branding info   │
└──────────────┬─────────────┘
               ↓
┌─ Extract SEO Fields ───────┐
│  - meta.title or fallback  │
│  - meta.description or fb  │
│  - meta.image (Media obj)  │
└──────────────┬─────────────┘
               ↓
┌─ Build URLs ───────────────┐
│  - Get base URL from tenant│
│  - Construct page URL      │
│  - Generate canonical URL  │
│  - Apply getDevelopmentDom │
└──────────────┬─────────────┘
               ↓
┌─ Transform Image URL ──────┐
│  - Use transformImageUrl() │
│  - Replace hostname with   │
│    tenant's domain         │
│  - Handle dev mapping      │
└──────────────┬─────────────┘
               ↓
┌─ Call generateSEOMetadata()┐
│  - Create OG metadata      │
│  - Add Twitter cards       │
│  - Set robots directives   │
│  - Return Next.js Metadata │
└──────────────┬─────────────┘
               ↓
        Return Metadata

Key insights:

  • All tenant branding is fetched from the database, not hardcoded. If a tenant updates their name or domain, it automatically propagates to all SEO metadata without code changes.
  • Image URLs are tenant-aware: The transformImageUrl() function ensures media URLs use the correct tenant domain in both development and production environments.

Step 5: HTML Generation

Next.js automatically converts the Metadata object into HTML tags:

<head>
  <title>About | Tenant Name</title>
  <meta name="description" content="Professional services...">
  <meta property="og:title" content="About | Tenant Name">
  <meta property="og:description" content="Professional services...">
  <meta property="og:image" content="https://cdn.example.com/og-image.jpg">
  <meta property="og:url" content="https://tenant-a.com/about">
  <meta property="og:type" content="website">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:title" content="About | Tenant Name">
  <link rel="canonical" href="https://tenant-a.com/about">
  <meta name="robots" content="index, follow">
</head>

Step 6: Social Media Sharing

When someone shares the page on social media:

  • Facebook Sharing Debugger reads the Open Graph tags
  • Twitter reads the Twitter Card metadata
  • The OG image (from meta.image) displays as a rich preview
  • The title and description appear below the image
  • The canonical URL ensures proper indexing

Key Simplification: Using Payload SEO Images

Unlike complex dynamic OG image generation, our implementation:

  • Relies on Payload's built-in image field (meta.image)
  • Gives editors full control via the admin UI
  • Eliminates complex fallback chains (no hero block extraction needed)
  • Supports multiple image formats (Payload Media object handles everything)
  • Works with Payload's CDN (images served from configured upload destination)
  • Transforms URLs automatically using transformImageUrl() to ensure images use the correct tenant domain

Image URL transformation ensures:

  • Development: Images use tenant-specific local domains (http://tenant-a.local/api/media/...)
  • Production: Images use the actual tenant domain from configuration
  • Relative paths: Automatically prepended with the tenant's base URL
  • No manual intervention: Developers don't need to worry about domain mismatches

This simplicity is a feature—editors have one clear place to set the image, and the system is straightforward to understand and debug. All domain complexity is handled automatically.

Testing and Validation

1. Testing Auto-Generated SEO Fields in Payload Admin

Create a new page, product, or post and observe the Payload admin:

  1. Title Auto-Generation:

    • Enter title: "Our Services"
    • Leave meta.title blank
    • Expected: Auto-generated to "Our Services | Tenant Name" (or tenant name)
    • Max 60 chars with "..." truncation if needed
  2. Description Auto-Generation:

    • For posts: Add excerpt field
    • For products: Add shortDescription field
    • Leave meta.description blank
    • Expected: Description auto-populated from available fields
    • Max 160 chars with "..." truncation
  3. Image Upload:

    • In SEO section, manually upload an image for meta.image
    • This is the single source for OG images (no dynamic generation)

2. Testing Page Request and Metadata Generation

For each content type, verify the browser receives correct metadata:

Check Title and Description:

# Visit: https://tenant-a.local/about
# View page source or use browser dev tools
# Look for:
<title>About | Tenant Name</title>
<meta name="description" content="...">

Check Open Graph Tags:

<meta property="og:title" content="About | Tenant Name">
<meta property="og:description" content="...">
<meta property="og:image" content="https://...">
<meta property="og:url" content="https://tenant-a.local/about">
<meta property="og:type" content="website">

Check Twitter Cards:

<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="About | Tenant Name">
<meta name="twitter:description" content="...">
<meta name="twitter:image" content="https://...">

3. Testing Tenant-Specific Branding

Verify each tenant gets correct branding across both domains:

Test Tenant A (https://tenant-a.com or https://tenant-a.local):

  • Title should include " | Tenant A"
  • URL should be https://tenant-a.com/...
  • OG site name should be "Tenant A"

Test Tenant B (https://tenant-b.com or https://tenant-b.local):

  • Title should include " | Tenant B"
  • URL should be https://tenant-b.com/...
  • OG site name should be "Tenant B"

4. Browser DevTools Inspection

Use Chrome/Firefox DevTools to inspect meta tags:

// In browser console:
// Find all meta tags
document.querySelectorAll('meta').forEach(m => {
  console.log(m.getAttribute('name') || m.getAttribute('property'), m.getAttribute('content'));
});

5. Checking Server Logs

Monitor console output during metadata generation:

[SlugPage] Resolving tenant page: { tenant: 'tenant-a', slug: ['about'] }
[SlugPage] Page found: { tenant: 'tenant-a', slug: 'about', pageId: '12345' }

6. Testing with Social Media Debuggers

After deploying to production with real domains:

Facebook Sharing Debugger (https://developers.facebook.com/tools/debug/sharing):

  • Enter your page URL
  • Verify OG image displays correctly
  • Check title and description render properly
  • Click "Fetch new shares" to see live preview

Twitter Card Validator (https://cards-dev.twitter.com/validator):

  • Paste your page URL
  • Verify card type is "summary_large_image"
  • Confirm image and text appear correctly

LinkedIn URL Inspector (https://www.linkedin.com/inspector):

  • Test how your page preview looks when shared on LinkedIn
  • Verify image displays and text is accurate

7. Troubleshooting Common Issues

Issue: meta.image shows as ID instead of Media object

  • Cause: Not fetching with depth: 3
  • Solution: Check your route's getPageBySlug call includes { depth: 3 }

Issue: Tenant name shows as "Default Tenant" instead of actual tenant name

  • Cause: Tenant lookup failed or caching issue
  • Solution: Check console for errors in getTenantConfig, verify Tenant record in database

Issue: OG image URL shows incorrect domain in development

  • Cause: Image URL not being transformed to use tenant-specific domain
  • Solution: Verify that transformImageUrl() is called in generateSEOMetadata(). Check that baseUrl includes the correct development domain (e.g., http://tenant-a.local)

Issue: OG image not showing on social media (production)

  • Cause: Image URL uses wrong domain or is unreachable
  • Solution:
    • Verify transformImageUrl() correctly transforms to tenant's production domain
    • Ensure meta.image URL is publicly accessible from social media servers
    • Test with Facebook Sharing Debugger to diagnose URL issues

Issue: Metadata shows for draft but not published pages

  • Cause: Route fetching from wrong environment
  • Solution: Verify draft parameter in query (production routes should use draft: false)

Issue: Image URL transformation fails silently

  • Cause: Invalid URL format or URL parsing error
  • Solution: The function gracefully falls back to the original URL. Check browser console for any URL parsing errors. Ensure baseUrl is a valid URL string.

What You've Accomplished

By implementing this guide, you now have a production-ready, multi-tenant SEO system that handles complex scenarios with elegance:

System Features

  1. Automated Metadata Generation: The Payload SEO plugin automatically generates titles and descriptions, reducing editor workload from manual to exception-based (only override when needed)

  2. Database-Driven Branding: Tenant names, domains, and descriptions are fetched from the database, not hardcoded, making the system flexible and maintainable

  3. Intelligent Fallback Chains: Metadata automatically falls back through sensible defaults:

    • Explicit SEO fields → content fields → tenant defaults → application defaults
  4. Multi-Tenant Support: Each tenant gets proper branding automatically:

    • Titles include tenant name
    • URLs use tenant domain
    • OG metadata shows correct site name
    • Proper canonical URLs for search engines
  5. Tenant-Aware Image URLs: The transformImageUrl() function ensures all media URLs use the correct tenant domain:

    • Development: Transforms to local dev domains (tenant-a.local, tenant-b.local)
    • Production: Uses actual tenant domains from configuration
    • Automatic hostname replacement based on tenant context
    • Graceful fallback for relative paths
  6. Performance Optimization: Tenant configuration is cached in a Map to minimize database queries across requests

  7. Type Safety: Complete TypeScript support with TenantConfig interface and @payload-types imports

  8. SEO Best Practices: Comprehensive metadata including:

    • Open Graph tags for social sharing
    • Twitter Card support
    • Canonical URL tags
    • robots.txt directives (index/follow, max-snippet, etc.)
  9. Content-Type Specific: Different metadata functions for pages, posts, and products, allowing type-specific logic:

    • Products: fallback to shortDescription
    • Posts: use article schema type, fallback to excerpt
    • Pages: generic website type

Architecture Highlights

Separation of Concerns:

  • Payload: Content management and SEO data storage
  • Next.js: Metadata generation and HTML delivery
  • Utilities: Reusable functions for both server and client contexts

Clean Data Flow:

Payload Content → Fetch with depth: 3 → getTenantConfig() → generateSEOMetadata() → Next.js Metadata → HTML <head>

Zero Hardcoding:

  • No tenant slugs in utility functions
  • No hardcoded domains
  • No hardcoded tenant names
  • All configuration comes from database

Common Use Cases

Adding a new tenant:

  1. Create Tenant record in Payload with name, slug, domain
  2. Content automatically uses correct branding
  3. No code changes needed

Updating tenant branding:

  1. Edit Tenant record (change name or domain)
  2. All existing content automatically reflects changes
  3. Cached values are used until next deployment

Setting up a new content type:

  1. Configure SEO plugin in payload.config.ts
  2. Create generateMyTypeSEOMetadata() function in seo.ts
  3. Add generateMetadata() to route
  4. Metadata works immediately with fallbacks

Next Steps (Optional Enhancements)

While not implemented in this guide, you could extend the system with:

  1. Structured Data: Add JSON-LD schemas for Articles, Products, BreadcrumbLists
  2. Dynamic OG Images: If you need more visual richness, create a /api/og endpoint
  3. Sitemap Generation: Create dynamic sitemap.xml with canonical URLs
  4. Hreflang Tags: Support multiple languages with proper alternates
  5. Rich Snippets: Add schema.org markup for better SERP display

Maintenance Notes

  1. TypeScript Verification: After changes, run npx tsc --noEmit to catch type errors
  2. Cache Invalidation: Tenant config cache persists per-request. Restart server to clear if needed
  3. Depth Parameter: Always fetch with depth: 3 to ensure relationships are populated
  4. Development Domains: Use getDevelopmentDomain() for local testing with real domains
  5. Image URL Transformation: The transformImageUrl() function is called automatically in generateSEOMetadata() - no manual setup needed
  6. URL Format: Ensure base URLs are properly formatted (include protocol) for URL parsing in transformImageUrl() to work correctly
  7. Fallback Values: If tenant lookup fails, graceful fallbacks ensure pages still render

Key Takeaways

The elegant solution here is letting Payload CMS handle data management and Next.js handle presentation. By using Payload's SEO plugin with auto-generation and Next.js's Metadata API, you get:

  • ✅ Reduced editor burden with intelligent defaults
  • ✅ Consistent branding across all content
  • ✅ Tenant-aware image URLs that work in dev and production
  • ✅ Easy to understand and debug
  • ✅ Type-safe with full TypeScript support
  • ✅ Scalable to multiple tenants and content types
  • ✅ Minimal code with maximum flexibility

The image URL transformation (transformImageUrl()) is a particularly elegant addition that ensures all media automatically uses the correct tenant domain without any manual configuration. In development, it maps localhost URLs to tenant-specific domains (via getDevelopmentDomain()), while in production it ensures images use the actual tenant domain from your database.

This approach demonstrates that sometimes the best architecture is the one that stays simple and leverages each tool's core strengths.


For questions, issues, or improvements to this guide, refer to the codebase examples in the actual implementation files: src/payload/utilities/seo.ts, src/app/(frontend)/tenant-slugs/, and payload.config.ts.

0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.