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…

📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.
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:
- Database Query Optimization: Results are cached in a Map, so repeated requests for the same tenant don't hit the database
- Async/Await Support: The function is async, allowing us to call it from anywhere (Payload hooks, Next.js routes, etc.)
- Type Safety: The
TenantConfiginterface ensures consistent tenant data across the application - Graceful Fallbacks: If tenant lookup fails, cached null prevents infinite retry attempts
- 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/...tohttp://tenant-a.local/api/media/...orhttp://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 throughgetBaseUrl()
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:
- No Hardcoded Defaults: Tenant names and domains are fetched from the database, not hardcoded
- Smart Image Handling: Checks if
meta.imageis a populated Media object or just an ID reference - SEO Best Practices: Includes Open Graph, Twitter Cards, robots directives, and canonical URLs
- Development Support: Maps production domains to local development domains (e.g.,
example-tenant.com→example-tenant.local) - 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: truefor preview mode - Passes slug as an array (automatically normalized)
- Uses
generatePageSEOMetadata()which handles all tenant configuration lookup - The
tenantparameter is passed explicitly to ensure proper branding - No manual OG image generation—uses
page.meta.imagefrom 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
shortDescriptionfield if no SEO description - Appends " | Products" to title if using product title directly
- Still respects SEO plugin's
meta.titleandmeta.descriptionif 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
excerptfield if no SEO description - Metadata function is specific to post fields
Pattern Summary
All route implementations follow this pattern:
- Resolve parameters: Await the params promise (Next.js 15+ requirement)
- Fetch content: Query the database with
depth: 3to populate relationships - Handle missing content: Return basic fallback metadata
- Call specific utility: Use the appropriate metadata function (
generatePageSEOMetadata,generateProductSEOMetadata, orgeneratePostSEOMetadata) - 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
generateTitlefunction automatically creates a title in the format:{page title} | {tenant name} - The
generateDescriptionfunction intelligently extracts description from available fields (excerpt, shortDescription, description) - The editor can manually upload an image for
meta.imagein 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):
- Next.js middleware identifies the tenant from the domain
- Next.js routing calls the appropriate
generateMetadatafunction - The route fetches content from Payload using
depth: 3to 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:
-
Title Auto-Generation:
- Enter title: "Our Services"
- Leave
meta.titleblank - Expected: Auto-generated to "Our Services | Tenant Name" (or tenant name)
- Max 60 chars with "..." truncation if needed
-
Description Auto-Generation:
- For posts: Add
excerptfield - For products: Add
shortDescriptionfield - Leave
meta.descriptionblank - Expected: Description auto-populated from available fields
- Max 160 chars with "..." truncation
- For posts: Add
-
Image Upload:
- In SEO section, manually upload an image for
meta.image - This is the single source for OG images (no dynamic generation)
- In SEO section, manually upload an image for
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
getPageBySlugcall 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 ingenerateSEOMetadata(). Check thatbaseUrlincludes 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.imageURL is publicly accessible from social media servers - Test with Facebook Sharing Debugger to diagnose URL issues
- Verify
Issue: Metadata shows for draft but not published pages
- Cause: Route fetching from wrong environment
- Solution: Verify
draftparameter in query (production routes should usedraft: 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
baseUrlis 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
-
Automated Metadata Generation: The Payload SEO plugin automatically generates titles and descriptions, reducing editor workload from manual to exception-based (only override when needed)
-
Database-Driven Branding: Tenant names, domains, and descriptions are fetched from the database, not hardcoded, making the system flexible and maintainable
-
Intelligent Fallback Chains: Metadata automatically falls back through sensible defaults:
- Explicit SEO fields → content fields → tenant defaults → application defaults
-
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
-
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
- Development: Transforms to local dev domains (
-
Performance Optimization: Tenant configuration is cached in a Map to minimize database queries across requests
-
Type Safety: Complete TypeScript support with
TenantConfiginterface and@payload-typesimports -
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.)
-
Content-Type Specific: Different metadata functions for pages, posts, and products, allowing type-specific logic:
- Products: fallback to
shortDescription - Posts: use
articleschema type, fallback toexcerpt - Pages: generic website type
- Products: fallback to
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:
- Create Tenant record in Payload with name, slug, domain
- Content automatically uses correct branding
- No code changes needed
Updating tenant branding:
- Edit Tenant record (change name or domain)
- All existing content automatically reflects changes
- Cached values are used until next deployment
Setting up a new content type:
- Configure SEO plugin in payload.config.ts
- Create
generateMyTypeSEOMetadata()function in seo.ts - Add
generateMetadata()to route - Metadata works immediately with fallbacks
Next Steps (Optional Enhancements)
While not implemented in this guide, you could extend the system with:
- Structured Data: Add JSON-LD schemas for Articles, Products, BreadcrumbLists
- Dynamic OG Images: If you need more visual richness, create a
/api/ogendpoint - Sitemap Generation: Create dynamic sitemap.xml with canonical URLs
- Hreflang Tags: Support multiple languages with proper alternates
- Rich Snippets: Add schema.org markup for better SERP display
Maintenance Notes
- TypeScript Verification: After changes, run
npx tsc --noEmitto catch type errors - Cache Invalidation: Tenant config cache persists per-request. Restart server to clear if needed
- Depth Parameter: Always fetch with
depth: 3to ensure relationships are populated - Development Domains: Use
getDevelopmentDomain()for local testing with real domains - Image URL Transformation: The
transformImageUrl()function is called automatically ingenerateSEOMetadata()- no manual setup needed - URL Format: Ensure base URLs are properly formatted (include protocol) for URL parsing in
transformImageUrl()to work correctly - 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.