From a2cf723e11edf53ce08e953a8c837c171244bf14 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:45:13 -0400 Subject: [PATCH 1/3] fix(next): handle Next.js TemplateString title object in generateMetadata --- packages/next/src/utilities/meta.spec.ts | 88 ++++++++++++++++++++++++ packages/next/src/utilities/meta.ts | 27 ++++++-- 2 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 packages/next/src/utilities/meta.spec.ts diff --git a/packages/next/src/utilities/meta.spec.ts b/packages/next/src/utilities/meta.spec.ts new file mode 100644 index 00000000000..fb3d1b6d4e0 --- /dev/null +++ b/packages/next/src/utilities/meta.spec.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' + +import { generateMetadata } from './meta.js' + +describe('generateMetadata', () => { + it('should handle a string title with titleSuffix', async () => { + const result = await generateMetadata({ + serverURL: 'http://localhost:3000', + title: 'Dashboard', + titleSuffix: '- My CMS', + }) + + expect(result.title).toBe('Dashboard - My CMS') + }) + + it('should pass a TemplateString title object through unchanged, ignoring titleSuffix', async () => { + const result = await generateMetadata({ + serverURL: 'http://localhost:3000', + title: { default: 'My CMS', template: '%s | My CMS' }, + titleSuffix: '- Payload', + }) + + // TemplateString should be preserved as-is — the user has taken control of title formatting + expect(typeof result.title).toBe('object') + expect((result.title as { default: string; template: string }).default).toBe('My CMS') + expect((result.title as { default: string; template: string }).template).toBe('%s | My CMS') + }) + + it('should use the TemplateString default for ogTitle when title is a TemplateString object', async () => { + const result = await generateMetadata({ + serverURL: 'http://localhost:3000', + title: { default: 'My CMS', template: '%s | My CMS' }, + titleSuffix: '- Payload', + }) + + // OG title must be a plain string — extract from TemplateString.default and append titleSuffix + expect(result.openGraph?.title).toBe('My CMS - Payload') + }) + + it('should use the TemplateString absolute for ogTitle when title has absolute property', async () => { + const result = await generateMetadata({ + serverURL: 'http://localhost:3000', + title: { absolute: 'My CMS Absolute' }, + titleSuffix: '- Payload', + }) + + expect(result.openGraph?.title).toBe('My CMS Absolute - Payload') + }) + + it('should pass a TemplateString with absolute through unchanged for metaTitle', async () => { + const result = await generateMetadata({ + serverURL: 'http://localhost:3000', + title: { absolute: 'My CMS Absolute' }, + titleSuffix: '- Payload', + }) + + expect(typeof result.title).toBe('object') + expect((result.title as { absolute: string }).absolute).toBe('My CMS Absolute') + }) + + it('should use openGraph.title string over incomingMetadata.title for ogTitle', async () => { + const result = await generateMetadata({ + serverURL: 'http://localhost:3000', + title: 'My CMS', + titleSuffix: '- Payload', + openGraph: { title: 'Custom OG Title' }, + }) + + expect(result.openGraph?.title).toBe('Custom OG Title') + }) + + it('should return undefined for metaTitle when no title and no titleSuffix are set', async () => { + const result = await generateMetadata({ + serverURL: 'http://localhost:3000', + }) + + expect(result.title).toBeUndefined() + }) + + it('should return just the title when no titleSuffix is set', async () => { + const result = await generateMetadata({ + serverURL: 'http://localhost:3000', + title: 'My CMS', + }) + + expect(result.title).toBe('My CMS') + }) +}) diff --git a/packages/next/src/utilities/meta.ts b/packages/next/src/utilities/meta.ts index a6539b82ad3..346e6bf371a 100644 --- a/packages/next/src/utilities/meta.ts +++ b/packages/next/src/utilities/meta.ts @@ -5,6 +5,19 @@ import type { MetaConfig } from 'payload' import { payloadFaviconDark, payloadFaviconLight, staticOGImage } from '@payloadcms/ui/assets' import * as qs from 'qs-esm' +const getTitleString = (title: Metadata['title']): string | undefined => { + if (!title) { + return undefined + } + if (typeof title === 'string') { + return title + } + if ('absolute' in title) { + return title.absolute + } + return title.default +} + const defaultOpenGraph: Metadata['openGraph'] = { description: 'Payload is a headless CMS and application framework built with TypeScript, Node.js, and React.', @@ -43,11 +56,17 @@ export const generateMetadata = async ( }, ] satisfies Array) - const metaTitle: Metadata['title'] = [incomingMetadata.title, titleSuffix] - .filter(Boolean) - .join(' ') + const metaTitle: Metadata['title'] = + typeof incomingMetadata.title === 'object' && incomingMetadata.title !== null + ? incomingMetadata.title + : [getTitleString(incomingMetadata.title), titleSuffix].filter(Boolean).join(' ') || undefined + + const titleStringForOg: string | undefined = + typeof incomingMetadata.openGraph?.title === 'string' + ? incomingMetadata.openGraph.title + : getTitleString(incomingMetadata.title) - const ogTitle = `${typeof incomingMetadata.openGraph?.title === 'string' ? incomingMetadata.openGraph.title : incomingMetadata.title} ${titleSuffix}` + const ogTitle = [titleStringForOg, titleSuffix].filter(Boolean).join(' ') const mergedOpenGraph: Metadata['openGraph'] = { ...(defaultOpenGraph || {}), From 509898b67570f8f2a7847686815245f1308a74b8 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:08:35 -0400 Subject: [PATCH 2/3] fix(next): apply titleSuffix to all fields when title is a TemplateString object --- packages/next/src/utilities/meta.spec.ts | 19 ++++++++------- packages/next/src/utilities/meta.ts | 30 ++++++++++++++++++++---- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/next/src/utilities/meta.spec.ts b/packages/next/src/utilities/meta.spec.ts index fb3d1b6d4e0..ef216bc42be 100644 --- a/packages/next/src/utilities/meta.spec.ts +++ b/packages/next/src/utilities/meta.spec.ts @@ -13,17 +13,20 @@ describe('generateMetadata', () => { expect(result.title).toBe('Dashboard - My CMS') }) - it('should pass a TemplateString title object through unchanged, ignoring titleSuffix', async () => { + it('should apply titleSuffix to default and template fields of a TemplateString title object', async () => { const result = await generateMetadata({ serverURL: 'http://localhost:3000', - title: { default: 'My CMS', template: '%s | My CMS' }, - titleSuffix: '- Payload', + title: { default: 'Dashboard', template: '%s | Dashboard' }, + titleSuffix: '- My CMS', }) - // TemplateString should be preserved as-is — the user has taken control of title formatting expect(typeof result.title).toBe('object') - expect((result.title as { default: string; template: string }).default).toBe('My CMS') - expect((result.title as { default: string; template: string }).template).toBe('%s | My CMS') + expect((result.title as { default: string; template: string }).default).toBe( + 'Dashboard - My CMS', + ) + expect((result.title as { default: string; template: string }).template).toBe( + '%s | Dashboard - My CMS', + ) }) it('should use the TemplateString default for ogTitle when title is a TemplateString object', async () => { @@ -47,7 +50,7 @@ describe('generateMetadata', () => { expect(result.openGraph?.title).toBe('My CMS Absolute - Payload') }) - it('should pass a TemplateString with absolute through unchanged for metaTitle', async () => { + it('should apply titleSuffix to the absolute field of a TemplateString title object', async () => { const result = await generateMetadata({ serverURL: 'http://localhost:3000', title: { absolute: 'My CMS Absolute' }, @@ -55,7 +58,7 @@ describe('generateMetadata', () => { }) expect(typeof result.title).toBe('object') - expect((result.title as { absolute: string }).absolute).toBe('My CMS Absolute') + expect((result.title as { absolute: string }).absolute).toBe('My CMS Absolute - Payload') }) it('should use openGraph.title string over incomingMetadata.title for ogTitle', async () => { diff --git a/packages/next/src/utilities/meta.ts b/packages/next/src/utilities/meta.ts index 346e6bf371a..038c699858a 100644 --- a/packages/next/src/utilities/meta.ts +++ b/packages/next/src/utilities/meta.ts @@ -5,6 +5,31 @@ import type { MetaConfig } from 'payload' import { payloadFaviconDark, payloadFaviconLight, staticOGImage } from '@payloadcms/ui/assets' import * as qs from 'qs-esm' +const formatMetaTitle = ( + title: Metadata['title'], + suffix: string | undefined, +): Metadata['title'] => { + if (!suffix || !title) { + return title ?? undefined + } + if (typeof title === 'string') { + return `${title} ${suffix}` + } + + if ('default' in title) { + return { default: `${title.default} ${suffix}`, template: `${title.template} ${suffix}` } + } + + if ('template' in title) { + return { + absolute: `${title.absolute} ${suffix}`, + template: title.template !== null ? `${title.template} ${suffix}` : null, + } + } + + return { absolute: `${title.absolute} ${suffix}` } +} + const getTitleString = (title: Metadata['title']): string | undefined => { if (!title) { return undefined @@ -56,10 +81,7 @@ export const generateMetadata = async ( }, ] satisfies Array) - const metaTitle: Metadata['title'] = - typeof incomingMetadata.title === 'object' && incomingMetadata.title !== null - ? incomingMetadata.title - : [getTitleString(incomingMetadata.title), titleSuffix].filter(Boolean).join(' ') || undefined + const metaTitle: Metadata['title'] = formatMetaTitle(incomingMetadata.title, titleSuffix) const titleStringForOg: string | undefined = typeof incomingMetadata.openGraph?.title === 'string' From f8752f415312b8bbf4435fe1a25f4dfba630be5b Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:30:09 -0400 Subject: [PATCH 3/3] fix(next): rename title suffix function --- packages/next/src/utilities/meta.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/next/src/utilities/meta.ts b/packages/next/src/utilities/meta.ts index 038c699858a..1ae5af91036 100644 --- a/packages/next/src/utilities/meta.ts +++ b/packages/next/src/utilities/meta.ts @@ -5,7 +5,7 @@ import type { MetaConfig } from 'payload' import { payloadFaviconDark, payloadFaviconLight, staticOGImage } from '@payloadcms/ui/assets' import * as qs from 'qs-esm' -const formatMetaTitle = ( +const appendTitleSuffix = ( title: Metadata['title'], suffix: string | undefined, ): Metadata['title'] => { @@ -81,7 +81,7 @@ export const generateMetadata = async ( }, ] satisfies Array) - const metaTitle: Metadata['title'] = formatMetaTitle(incomingMetadata.title, titleSuffix) + const metaTitle: Metadata['title'] = appendTitleSuffix(incomingMetadata.title, titleSuffix) const titleStringForOg: string | undefined = typeof incomingMetadata.openGraph?.title === 'string'