From f26e5d753765c4f6288f9d78fbb4a61be89457a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 15 Feb 2026 20:14:42 -0300 Subject: [PATCH] feat(cms): add Payload-driven site metadata global and wire Next.js SEO output - add new metadata global with URL, title, description, keywords, Open Graph, Twitter, and image fields - register Metadata global in payload.config.ts - switch frontend layout to generateMetadata() and load metadata values from Payload globals - resolve metadata image relation (ID or populated media) for OG/Twitter images - update live preview globals config to use SiteSettings.slug and HomePage.slug - keep SiteSettings revalidation hook in consistent position within config - regenerate payload-types.ts for new global/schema types --- src/app/(frontend)/layout.tsx | 55 +++++++++++++++++-- src/globals/Metadata.ts | 99 +++++++++++++++++++++++++++++++++++ src/globals/SiteSettings.ts | 6 +-- src/payload-types.ts | 58 ++++++++++++++++++++ src/payload.config.ts | 5 +- 5 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 src/globals/Metadata.ts diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index ff24e8b1..170c0b7d 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -4,10 +4,59 @@ import React from 'react' import './styles.css' import { RefreshRouteOnSave } from '@/utilities/RefreshRouteOnSave' import { Metadata } from 'next' +import { getCachedGlobal } from '@/utilities/globals' +import { Media } from '@/payload-types' +import { getCachedDocumentById } from '@/utilities/documents' -export const metadata: Metadata = { - description: 'Um template base usando Payload em uma aplicação Next.js.', - title: 'Template Base do Payload', +export async function generateMetadata(): Promise { + const payload = await getCachedGlobal('metadata')() + + let image = payload.image + + if (typeof image === 'number') { + image = await getCachedDocumentById('media', String(payload.image))() + } + + return { + title: payload.title, + description: payload.description, + keywords: payload.keywords.filter((keyword) => typeof keyword === 'string'), + authors: [ + { name: 'Francisco Solla', url: 'https://solla.dev' }, + { name: 'Teqo', url: 'https://teqo.app' }, + ], + creator: 'Francisco Solla', + publisher: 'Teqo', + robots: { + index: true, + follow: true, + }, + openGraph: { + type: 'website', + locale: 'pt-BR', + url: payload.URL, + siteName: payload.openGraph.siteName, + title: payload.title, + description: payload.description, + images: image?.url + ? [ + { + url: image.url, + width: image.width!, + height: image.height!, + alt: image.alt, + }, + ] + : [], + }, + twitter: { + card: 'summary_large_image', + title: payload.title, + description: payload.twitter.description, + creator: payload.twitter.creator, + images: image?.url ? [image.url] : [], + }, + } } export default async function RootLayout(props: { children: React.ReactNode }) { diff --git a/src/globals/Metadata.ts b/src/globals/Metadata.ts new file mode 100644 index 00000000..8b3802e9 --- /dev/null +++ b/src/globals/Metadata.ts @@ -0,0 +1,99 @@ +import { revalidateGlobal } from '@/utilities/globals' +import { GlobalConfig } from 'payload' + +const slug = 'metadata' + +const revalidate = async () => revalidateGlobal(slug) + +export const Metadata: GlobalConfig = { + slug, + label: 'Metadados', + admin: { + group: 'Configurações', + }, + access: { + read: () => true, + update: ({ req: { user } }) => Boolean(user), + }, + hooks: { + afterChange: [revalidate], + }, + fields: [ + { + name: 'URL', + label: 'URL', + type: 'text', + required: true, + }, + { + name: 'title', + label: 'Título', + type: 'text', + required: true, + }, + { + name: 'description', + label: 'Descrição', + type: 'textarea', + required: true, + }, + { + name: 'keywords', + label: 'Palavras-chave', + type: 'array', + required: true, + fields: [ + { + name: 'keyword', + label: 'Palavra-chave', + type: 'text', + required: true, + }, + ], + }, + { + name: 'openGraph', + label: 'Open Graph', + type: 'group', + fields: [ + { + name: 'siteName', + label: 'Nome do Site', + type: 'text', + required: true, + }, + { + name: 'description', + label: 'Descrição', + type: 'textarea', + required: true, + }, + ], + }, + { + name: 'twitter', + label: 'Twitter', + type: 'group', + fields: [ + { + name: 'creator', + label: 'Criador', + type: 'text', + required: true, + }, + { + name: 'description', + label: 'Descrição', + type: 'textarea', + required: true, + }, + ], + }, + { + name: 'image', + label: 'Imagem', + type: 'upload', + relationTo: 'media', + }, + ], +} diff --git a/src/globals/SiteSettings.ts b/src/globals/SiteSettings.ts index bf512bab..4b2b64f2 100644 --- a/src/globals/SiteSettings.ts +++ b/src/globals/SiteSettings.ts @@ -16,6 +16,9 @@ export const SiteSettings: GlobalConfig = { read: () => true, update: ({ req: { user } }) => Boolean(user), }, + hooks: { + afterChange: [revalidate], + }, fields: [ { name: 'headerTitle', @@ -77,9 +80,6 @@ export const SiteSettings: GlobalConfig = { ], }, ], - hooks: { - afterChange: [revalidate], - }, versions: { drafts: { autosave: { diff --git a/src/payload-types.ts b/src/payload-types.ts index c75b6115..6f6a34f1 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -92,10 +92,12 @@ export interface Config { globals: { 'site-settings': SiteSetting; home: Home; + metadata: Metadatum; }; globalsSelect: { 'site-settings': SiteSettingsSelect | SiteSettingsSelect; home: HomeSelect | HomeSelect; + metadata: MetadataSelect | MetadataSelect; }; locale: null; user: User; @@ -485,6 +487,31 @@ export interface Home { updatedAt?: string | null; createdAt?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "metadata". + */ +export interface Metadatum { + id: number; + URL: string; + title: string; + description: string; + keywords: { + keyword: string; + id?: string | null; + }[]; + openGraph: { + siteName: string; + description: string; + }; + twitter: { + creator: string; + description: string; + }; + image?: (number | null) | Media; + updatedAt?: string | null; + createdAt?: string | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "site-settings_select". @@ -515,6 +542,37 @@ export interface HomeSelect { createdAt?: T; globalType?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "metadata_select". + */ +export interface MetadataSelect { + URL?: T; + title?: T; + description?: T; + keywords?: + | T + | { + keyword?: T; + id?: T; + }; + openGraph?: + | T + | { + siteName?: T; + description?: T; + }; + twitter?: + | T + | { + creator?: T; + description?: T; + }; + image?: T; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "TaskSchedulePublish". diff --git a/src/payload.config.ts b/src/payload.config.ts index 6833b578..82f212a2 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -10,6 +10,7 @@ import { Media } from './collections/Media' import { SiteSettings } from './globals/SiteSettings' import { pt } from 'payload/i18n/pt' import { HomePage } from './globals/HomePage' +import { Metadata } from './globals/Metadata' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -22,11 +23,11 @@ export default buildConfig({ }, livePreview: { url: 'http://localhost:3000', - globals: ['site-settings', 'home'], + globals: [SiteSettings.slug, HomePage.slug], }, }, collections: [Users, Media], - globals: [SiteSettings, HomePage], + globals: [SiteSettings, HomePage, Metadata], editor: lexicalEditor(), secret: process.env.PAYLOAD_SECRET || '', typescript: {