Prerequisites: This guide assumes you have a working multi-tenant setup. If you're starting fresh, begin with Production-Ready Multi-Tenant Setup.
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
Note: This guide focuses on page-level metadata (titles, descriptions, OG images). For generating sitemap.xml and robots.txt, see our Dynamic Sitemap Guide.
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:
typescript
// File: payload.config.tsimport { 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 contextgenerateTitle: generateSeoTitle,
// Auto-generate descriptions from content fieldsgenerateDescription: 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:
typescript
// 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
*/exportasyncfunctiongenerateSeoTitle({
doc,
}: {
doc: any;
}): Promise<string> {
try {
// If meta.title already set, don't overrideif (doc.meta?.title) {
return doc.meta.title;
}
const title = doc.title || "";
if (!title) return"";
// Get tenant name for suffixlet tenantName = "Tenant A";
if (doc.tenant) {
const config = awaitgetTenantConfig(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
*/exportasyncfunctiongenerateSeoDescription({
doc,
}: {
doc: any;
}): Promise<string> {
try {
// If meta.description already set, don't overrideif (doc.meta?.description) {
return doc.meta.description;
}
// Try common description fields in order of preferenceconst 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:
typescript
// File: src/payload/utilities/seo.ts// Cache for tenant configs to avoid repeated database queriesconst tenantConfigCache = newMap<string | number, TenantConfig | null>();
exportinterfaceTenantConfig {
name: string;
slug: string;
domain?: string;
description?: string;
}
/**
* Get tenant configuration with caching
* Fetches tenant branding information for use in SEO metadata
*/exportasyncfunctiongetTenantConfig(tenant: Tenant | string | number | undefined | null,
): Promise<TenantConfig | null> {
if (!tenant) returnnull;
// Extract ID from tenant object or use directlylettenantId: string | number;
if (typeof tenant === "string" || typeof tenant === "number") {
tenantId = tenant;
} elseif (typeof tenant === "object" && tenant && "id"in tenant) {
tenantId = (tenant asany).id;
} else {
returnnull;
}
// Check cache firstconst cacheKey = String(tenantId);
if (tenantConfigCache.has(cacheKey)) {
return tenantConfigCache.get(cacheKey) || null;
}
// Fetch from databasetry {
const tenantData = awaitgetTenantById(tenantId);
if (tenantData) {
constconfig: TenantConfig = {
name: (tenantData asany).name || "Tenant A",
slug: (tenantData asany).slug || "",
domain: (tenantData asany).domain,
description: (tenantData asany).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);
returnnull;
}
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 TenantConfig interface ensures consistent tenant data across the application
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:
typescript
// File: src/payload/utilities/seo.ts/**
* Transform image URL hostname to match tenant's base URL
* Handles development domain mapping and relative paths
*/functiontransformImageUrl(imageUrl: string, baseUrl: string): string {
if (!imageUrl) return imageUrl;
// If URL is relative, prepend baseUrlif (imageUrl.startsWith('/')) {
return`${baseUrl}${imageUrl}`;
}
try {
// Extract hostname from baseUrlconst baseUrlObj = newURL(baseUrl);
const tenantHostname = baseUrlObj.hostname;
const tenantProtocol = baseUrlObj.protocol;
// Parse the image URLconst imageUrlObj = newURL(imageUrl);
// Replace hostname and protocol with tenant's
imageUrlObj.hostname = tenantHostname;
imageUrlObj.protocol = tenantProtocol;
return imageUrlObj.toString();
} catch {
// If URL parsing fails, return originalreturn 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
typescript
// File: src/payload/utilities/seo.ts// Update the function to be tenant-awareasyncfunctiongetBaseUrl(tenant?: TenantConfig | null): Promise<string> {
if (tenant?.domain) {
// If tenant has a domain, use itconst isDevelopment = process.env.NODE_ENV === 'development';
const finalDomain = isDevelopment ? getDevelopmentDomain(tenant.domain) : tenant.domain;
const protocol = isDevelopment ? 'http' : 'https';
return`${protocol}://${finalDomain}`;
}
thrownewError("Tenant domain is required for URL generation");
}
exportasyncfunctiongenerateSEOMetadata(config: SEOConfig): Promise<Metadata> {
const baseUrl = awaitgetBaseUrl(config.tenant);
const tenantDefaults = getDefaultTenantConfig(config.tenant || undefined);
const defaultTitle = `${tenantDefaults.name}`;
const defaultDescription = tenantDefaults.description || "Professional creative services";
const siteName = config.tenant?.name || "Tenant A";
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 domainconst 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
*/exportasyncfunctiongeneratePageSEOMetadata(page: PageType,
slug: string[],
options?: {
noIndex?: boolean;
noFollow?: boolean;
ogImageUrl?: string;
tenant?: Tenant | string | number | null;
},
): Promise<Metadata> {
// Fetch tenant config if providedconst tenant = options?.tenant || (page asany).tenant;
const tenantConfig = awaitgetTenantConfig(tenant);
const baseUrl = awaitgetBaseUrl(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)letseoImage: 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.imageasMedia;
} elseif (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.`);
}
}
returngenerateSEOMetadata({
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.image is 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., tenant-a.com → tenant-a.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:
typescript
// File: src/app/(frontend)/tenant-slugs/[tenant]/[...slug]/page.tsxexportasyncfunctiongenerateMetadata({
params,
}: Props): Promise<Metadata> {
const resolvedParams = await params;
const { slug, tenant } = resolvedParams;
const safeSlug = normalizeSlug(slug);
console.log("[SlugPage] Resolving tenant page:", { tenant, slug });
letpage: Page | null = null;
try {
// Fetch draft content for live preview with full depth for SEO data population
page = awaitqueryPageBySlug({ slug: safeSlug, tenant, draft: true });
} catch (error) {
console.error("Error fetching page for metadata:", error);
}
// Fallback metadata if page not foundif (!page) {
return {
title: "Preview - Tenant A",
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 automaticallyreturnawaitgeneratePageSEOMetadata(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:
typescript
// File: src/app/(frontend)/tenant-slugs/[tenant]/products/[slug]/page.tsxexportasyncfunctiongenerateMetadata({
params,
}: ProductPageProps): Promise<Metadata> {
const { slug, tenant } = await params;
try {
// Fetch product with full depth to ensure meta.image is populatedconst product = awaitgetProductBySlug(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 shortDescriptionreturnawaitgenerateProductSEOMetadata(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:
typescript
/**
* Generate SEO metadata for products
* Includes product-specific fields and e-commerce considerations
*/exportasyncfunctiongenerateProductSEOMetadata(product: Product,
slug: string[],
options?: {
noIndex?: boolean;
noFollow?: boolean;
ogImageUrl?: string;
tenant?: Tenant | string | number | null;
},
): Promise<Metadata> {
// Fetch tenant configconst tenant = options?.tenant || (product asany).tenant;
const tenantConfig = awaitgetTenantConfig(tenant);
const baseUrl = awaitgetBaseUrl(tenantConfig);
const url = `${baseUrl}/${slug.join("/")}`;
// Use SEO plugin meta fields if available, fallback to product fieldsconst seoTitle = product.meta?.title || `${product.title} | Products`;
const seoDescription =
product.meta?.description || product.shortDescription || undefined;
// Handle meta.imageletseoImage: Media | null = null;
if (product.meta?.image) {
if (typeof product.meta.image === "object" && product.meta.image !== null) {
seoImage = product.meta.imageasMedia;
} elseif (typeof product.meta.image === "number") {
console.warn(`[SEO] meta.image not populated for product: ${product.title}`);
}
}
returngenerateSEOMetadata({
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:
typescript
// File: src/app/(frontend)/tenant-slugs/[tenant]/post/[slug]/page.tsxexportasyncfunctiongenerateMetadata({
params,
}: PostPageProps): Promise<Metadata> {
const { slug, tenant } = await params;
try {
// Fetch post with full depth for populated SEO and relationship dataconst post = awaitgetPostBySlug(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 excerptreturnawaitgeneratePostSEOMetadata(post, [slug], { tenant });
} catch (error) {
console.error(`Error generating metadata for post ${slug}:`, error);
return {
title: "Post",
description: "Post",
};
}
}
And the SEO utility function:
typescript
/**
* Generate SEO metadata for blog posts
* Includes post-specific fields like publishedAt and author
*/exportasyncfunctiongeneratePostSEOMetadata(post: Post,
slug: string[],
options?: {
noIndex?: boolean;
noFollow?: boolean;
ogImageUrl?: string;
tenant?: Tenant | string | number | null;
},
): Promise<Metadata> {
// Fetch tenant configconst tenant = options?.tenant || (post asany).tenant;
const tenantConfig = awaitgetTenantConfig(tenant);
const baseUrl = awaitgetBaseUrl(tenantConfig);
const url = `${baseUrl}/${slug.join("/")}`;
// Use SEO plugin meta fields if available, fallback to post fieldsconst seoTitle = post.meta?.title || post.title;
const seoDescription = post.meta?.description || post.excerpt || undefined;
// Handle meta.imageletseoImage: Media | null = null;
if (post.meta?.image) {
if (typeof post.meta.image === "object" && post.meta.image !== null) {
seoImage = post.meta.imageasMedia;
} elseif (typeof post.meta.image === "number") {
console.warn(`[SEO] meta.image not populated for post: ${post.title}`);
}
}
returngenerateSEOMetadata({
title: seoTitle,
description: seoDescription,
image: seoImage,
url,
type: "article", // Posts use article typenoIndex: 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:
Resolve parameters: Await the params promise (Next.js 15+ requirement)
Fetch content: Query the database with depth: 3 to populate relationships
Call specific utility: Use the appropriate metadata function (generatePageSEOMetadata, generateProductSEOMetadata, or generatePostSEOMetadata)
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 Complete Data Flow
Here's how data flows through the entire SEO 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):
Next.js middleware identifies the tenant from the domain
Next.js routing calls the appropriate generateMetadata function
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:
code
┌─ Fetch Page/Post/Product ──┐
│ - Query by slug + tenant │
│ - Include draft content │
│ - Fetch 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:
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
Testing SEO in a multi-tenant environment requires verifying that metadata is correct for each tenant domain.
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:
javascript
// In browser console:// Find all meta tagsdocument.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:
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 "Tenant A" 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
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:
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:
code
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/og endpoint
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 --noEmit to catch type errors
Cache Invalidation: Tenant config cache persists per-request. Restart server to clear if needed
Depth Parameter: Always fetch with depth: 3 to ensure relationships are populated
Development Domains: Use getDevelopmentDomain() for local testing with real domains
Image URL Transformation: The transformImageUrl() function is called automatically in generateSEOMetadata() - 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.