Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 12 additions & 18 deletions src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,16 @@ import { getPayload } from 'payload'
import config from '@payload-config'
import { unstable_cache } from 'next/cache'

import { buildLocalizedPostSitemapEntries } from '@/seo/postsSitemap'

// Hooks normally invalidate this tag after an editorial change. A bounded TTL also
// recovers if a bulk import bypasses Payload hooks or an invalidation is lost.
const getPostsSitemap = unstable_cache(
async () => {
const payload = await getPayload({ config })
const SITE_URL =
process.env.NEXT_PUBLIC_SERVER_URL ||
process.env.VERCEL_PROJECT_PRODUCTION_URL ||
'https://example.com'

const dateFallback = new Date().toISOString()
const locales = ['fr', 'en'] as const
const sitemap = []

for (const locale of locales) {
for (const locale of ['fr', 'en'] as const) {
const results = await payload.find({
collection: 'posts',
overrideAccess: false,
Expand All @@ -33,30 +30,27 @@ const getPostsSitemap = unstable_cache(
select: {
title: true,
slug: true,
excerpt: true,
content: true,
updatedAt: true,
},
})

for (const post of results.docs) {
if (!post?.slug || !post?.title) continue

sitemap.push({
loc: `${SITE_URL}/${locale}/posts/${post.slug}`,
lastmod: post.updatedAt || dateFallback,
})
}
// The public article route returns 404 if any of these localized fields
// are missing. Do not advertise such URLs to search engines.
sitemap.push(...buildLocalizedPostSitemapEntries(locale, results.docs))
}

return sitemap
},
['posts-sitemap-v2'],
['posts-sitemap-v3'],
{
tags: ['posts-sitemap'],
revalidate: 3600,
},
)

export async function GET() {
const sitemap = await getPostsSitemap()

return getServerSideSitemap(sitemap)
}
39 changes: 23 additions & 16 deletions src/collections/Posts/hooks/revalidatePost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ function revalidateEditorialSitemaps() {
revalidateTag('news-sitemap', 'max')
}

// These are the public URLs, not just the legacy /posts route. Keep landing
// pages and article archives fresh when published content changes.
function revalidatePublicPostPaths(slug: string) {
revalidatePath(`/posts/${slug}`)
for (const locale of ['fr', 'en'] as const) {
revalidatePath(`/${locale}/posts/${slug}`)
revalidatePath(`/${locale}/posts`)
revalidatePath(`/${locale}`)
}
}

async function getLocalizedPostUrls({
payload,
id,
Expand Down Expand Up @@ -70,22 +81,20 @@ export const revalidatePost: CollectionAfterChangeHook<Post> = async ({
req: { payload, context },
}) => {
if (!context.disableRevalidate) {
if (doc._status === 'published') {
const path = `/posts/${doc.slug}`

payload.logger.info(`Revalidating post at path: ${path}`)

revalidatePath(path)
revalidateEditorialSitemaps()
}
const wasPublished = previousDoc._status === 'published'
const isPublished = doc._status === 'published'

// If the post was previously published, we need to revalidate the old path
if (previousDoc._status === 'published' && doc._status !== 'published') {
const oldPath = `/posts/${previousDoc.slug}`
if (isPublished || wasPublished) {
if (isPublished && doc.slug) {
payload.logger.info(`Revalidating published post: ${doc.slug}`)
revalidatePublicPostPaths(doc.slug)
}

payload.logger.info(`Revalidating old post at path: ${oldPath}`)
// Also invalidate the old canonical URLs after a rename or unpublish.
if (wasPublished && previousDoc.slug && (!isPublished || previousDoc.slug !== doc.slug)) {
revalidatePublicPostPaths(previousDoc.slug)
}

revalidatePath(oldPath)
revalidateEditorialSitemaps()
}
}
Expand Down Expand Up @@ -134,9 +143,7 @@ export const revalidateDelete: CollectionAfterDeleteHook<Post> = async ({
req: { payload, context },
}) => {
if (!context.disableRevalidate) {
const path = `/posts/${doc?.slug}`

revalidatePath(path)
if (doc?.slug) revalidatePublicPostPaths(doc.slug)
revalidateEditorialSitemaps()
}

Expand Down
34 changes: 34 additions & 0 deletions src/seo/postsSitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { absoluteCanonicalURL } from '@/seo/structuredData'
import type { SiteLocale } from '@/i18n/config'

export type PublicSitemapPost = {
title?: string | null
slug?: string | null
excerpt?: string | null
content?: unknown
updatedAt?: string | null
}

/**
* Build canonical entries only for articles that the localized public route can
* actually render (title, excerpt and rich-text content in that locale).
*/
export function buildLocalizedPostSitemapEntries(
locale: SiteLocale,
posts: PublicSitemapPost[],
fallbackDate = new Date().toISOString(),
) {
return posts
.filter(
(post) =>
Boolean(post.title?.trim()) &&
Boolean(post.slug?.trim()) &&
Boolean(post.excerpt?.trim()) &&
post.content !== null &&
post.content !== undefined,
)
.map((post) => ({
loc: absoluteCanonicalURL(`/${locale}/posts/${post.slug}`),
lastmod: post.updatedAt || fallbackDate,
}))
}
61 changes: 61 additions & 0 deletions tests/unit/postsSitemap.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'

import { buildLocalizedPostSitemapEntries } from '@/seo/postsSitemap'

describe('localized public article sitemap', () => {
const now = '2026-10-11T18:00:00.000Z'

it('uses the canonical domain, even when deployment environment points to a preview', () => {
expect(
buildLocalizedPostSitemapEntries(
'en',
[
{
slug: 'example',
title: 'Example',
excerpt: 'Summary',
content: { root: { children: [] } },
updatedAt: now,
},
],
now,
),
).toEqual([
{
loc: 'https://insight.trigenys.com/en/posts/example',
lastmod: now,
},
])
})

it('never advertises untranslated or incomplete article routes that return 404', () => {
const result = buildLocalizedPostSitemapEntries(
'en',
[
{ slug: 'fr-only', title: null, excerpt: null, content: null },
{ slug: 'missing-excerpt', title: 'Title', excerpt: '', content: {} },
{ slug: 'missing-content', title: 'Title', excerpt: 'Summary', content: null },
{ slug: null, title: 'Title', excerpt: 'Summary', content: {} },
{ slug: 'complete', title: 'Title', excerpt: 'Summary', content: {}, updatedAt: now },
],
now,
)

expect(result).toEqual([
{
loc: 'https://insight.trigenys.com/en/posts/complete',
lastmod: now,
},
])
})

it('provides a stable ISO fallback when updatedAt is missing', () => {
expect(
buildLocalizedPostSitemapEntries(
'fr',
[{ slug: 'test', title: 'Titre', excerpt: 'Résumé', content: {} }],
now,
)[0].lastmod,
).toBe(now)
})
})
Loading