Skip to main content

← Blog

One Payload Content Model for Sitemap, RSS, and JSON-LD

How shared Payload queries generate sitemap, RSS, and JSON-LD without duplicating publication rules, public media URLs, or cache invalidation.

Published
July 10, 2026
Updated
Updated July 14, 2026
Author
Daniel Mark
Reading time
10 min read

When I rebuilt thedanielmark.com on Payload and Next.js, I needed sitemap, RSS, and structured data to agree on what "public" meant.

The same slugs. The same publication dates. The same canonical host. The same media records wherever a cover image appeared.

This is not programmatic SEO in the template-generated landing-page sense. It is typed SEO infrastructure: three output contracts built from one shared set of published-content queries.

One content model, three public contracts

Sitemap, RSS, and JSON-LD serve different consumers.

Crawlers want a canonical URL inventory with modification dates and optional image metadata. Feed readers want chronological items with stable links and optional media enclosures. Structured-data parsers want page-level Schema.org objects that agree with the visible HTML.

Trying to force all three through one formatter would create the wrong abstraction. The implementation keeps separate builders under features/seo/, while sharing Payload query helpers from lib/cms/:

  • getPublishedPosts()
  • getPublishedProjects()
  • getTagsWithCounts()
  • getSiteSettings()

Sharing those helpers does not mean every new field automatically appears everywhere. Sitemap entries, RSS items, and JSON-LD objects still have different schemas. The benefit is narrower: publication state, canonical URLs, dates, and media relationships are less likely to drift independently.

Why I did not add next-sitemap

There is nothing inherently wrong with next-sitemap. It could generate a sitemap for this site.

I did not need another configuration layer between Payload and Next.js. Payload already owns publication state and media relationships. Next.js already provides a native sitemap metadata route with support for modification dates and image URLs.

The final split is straightforward:

  1. Payload helpers fetch published content.
  2. buildSitemapEntries() converts that content into typed sitemap entries.
  3. app/(frontend)/sitemap.ts returns MetadataRoute.Sitemap.
  4. Next.js serializes the result to /sitemap.xml.

That keeps sitemap generation inside the same application that already understands the content model. It also avoids maintaining a separate XML serializer for behavior the framework supports natively.

RSS and JSON-LD remain application-owned because they are different contracts. The decision not to install next-sitemap does not somehow solve feed formatting or structured-data safety. It simply avoids duplicating sitemap configuration.

Defining published content once

Posts and Projects use a custom status field with draft and published values. They do not use Payload’s _status draft workflow.

Public query helpers apply an explicit published filter and opt back into collection access rules for Local API reads:

1
const publishedWhere = {
2
status: { equals: "published" as const },
3
};
4
5
const publicReadOptions = {
6
overrideAccess: false,
7
} as const;
8
9
export async function queryPublishedPosts(limit = 100): Promise<Post[]> {
10
const payload = await getPayloadClient();
11
12
const result = await payload.find({
13
collection: "posts",
14
where: publishedWhere,
15
sort: "-publishedAt",
16
limit,
17
depth: 2,
18
...publicReadOptions,
19
});
20
21
return withResolvedPostCoverImages(result.docs);
22
}

The explicit filter states the public-content rule close to the query. overrideAccess: false provides a second boundary by applying the collection’s authenticatedOrPublished access control.

Preview and administrative reads remain separate. They can load drafts through authenticated paths without weakening the helpers used by public pages, sitemap, or RSS.

The formatters do not inspect status again. They trust the query layer. That makes the query helper the boundary worth testing, rather than pretending a string builder can enforce access control.

Tag discovery follows the same rule. getTagsWithCounts() calculates usage from published posts and projects. Only tags with at least one public relationship enter the sitemap. Empty tag pages return a real 404 rather than becoming thin archive pages.

Building the sitemap with native Next.js conventions

/sitemap.xml is generated through app/(frontend)/sitemap.ts.

On regeneration, it loads published posts, projects, and qualifying tag slugs before passing them to buildSitemapEntries().

The static route inventory includes:

  1. /
  2. /about
  3. /blog
  4. /projects
  5. /privacy

Dynamic entries add:

  1. /blog/{slug}
  2. /projects/{slug}
  3. /tags/{slug}

Each dynamic entry receives lastModified from updatedAt or publishedAt. Post entries also receive an images array when getPublicMediaUrl() resolves a cover image. Next.js converts that property into the supported Google image sitemap namespace.

The sitemap does not emit deprecated image:title or image:caption fields. It provides the page URL and public image location, which are the fields current image sitemap consumers require.

Redirect sources, preview routes, /admin, and internal API routes remain outside the canonical URL inventory. /terms returns a real 404, while /cookies permanently redirects to /privacy, so neither appears as an independent sitemap entry.

Canonical URLs come from canonicalUrl(), using the configured site origin with https://www.thedanielmark.com as the production-safe canonical fallback.

Treating RSS as a separate contract

RSS is not sitemap XML with a different root element.

Feed readers expect channel metadata, publication dates, stable item GUIDs, links, descriptions, and optional enclosures. Site Settings provides the channel title and description, while layout metadata advertises /rss.xml through alternates.types.

The feed contains published posts only. Projects remain outside it because the feed’s contract is “new writing,” not “every public record.”

Item enclosures are emitted only when the post’s Media record contains everything RSS 2.0 requires:

1
export function resolvePostEnclosure(post: Post): RssEnclosure | null {
2
const media = resolveMedia(post.coverImage);
3
if (!media) return null;
4
5
const url = getPublicMediaUrl(media);
6
const length = resolveEnclosureLength(media);
7
const type = resolveEnclosureMimeType(media);
8
9
if (!url || length === null || !type) {
10
return null;
11
}
12
13
return { url, length, type };
14
}

The enclosure length comes from Media.filesize, measured in bytes. Its type comes from the real Media.mimeType, so PNG and WebP files are not mislabeled as JPEG.

If any required value is unavailable, the enclosure is omitted. RSS enclosures are optional, so the item remains valid without one.

Coverless posts also do not inherit the site-wide default Open Graph image as an item enclosure. That image belongs at channel level. Reusing it for every coverless post would imply that the same media object was attached to every article.

Legacy feed routes remain stable. /feed and /feed.xml permanently redirect to /rss.xml.

Rendering safe page-specific JSON-LD

Structured data is generated at the page boundary.

The frontend layout emits Person and WebSite JSON-LD from Site Settings. Blog pages add a BlogPosting object built from the same Post record used to render the article. Project pages emit CreativeWork data containing the project title, excerpt, dates, cover image, and technology keywords.

CMS-authored text eventually enters an inline script element, so valid JSON alone is not enough. A literal </script> inside a title or excerpt could terminate the script element before a JSON parser ever sees it.

JsonLdScript therefore serializes through a dedicated helper that escapes HTML-significant characters as Unicode sequences:

1
export function serializeJsonLd(data: Record<string, unknown>): string {
2
return JSON.stringify(data)
3
.replace(/</g, "\\u003c")
4
.replace(/>/g, "\\u003e")
5
.replace(/&/g, "\\u0026");
6
}

The browser receives one inert application/ld+json script. JSON.parse() can still reconstruct the original values, while CMS text cannot create a sibling executable script.

The implementation does not currently connect PersonWebSite, and individual content objects through shared @id references. That would create a tighter entity graph, but it is not required for the page-level objects to describe their rendered content accurately.

Structured data also does not guarantee a rich result. Schema.org validity, Google-supported eligibility, and Search Console reporting are separate concerns.

Separating public media from Next Image delivery

React rendering and machine-readable SEO output need different URL guarantees.

getMediaUrl() returns the source supplied to the site’s CmsImage wrapper. In production, that is typically an absolute R2 CDN URL. During local development, it may be a Payload media API path.

Next.js receives that source and generates its own /_next/image delivery URLs in rendered HTML. The application helper does not construct optimizer URLs itself.

getPublicMediaUrl() produces absolute, crawlable URLs for:

  • Sitemap image entries
  • RSS enclosures
  • JSON-LD image fields
  • Open Graph metadata

Local paths are expanded against the canonical site origin. External media must pass isAllowedMediaUrl(), which constrains accepted hostnames using MEDIA_BASE_URL.

When PAYLOAD_STORAGE=s3, the R2 adapter’s generateFileURL writes the configured CDN URL onto the Media record during upload. SEO outputs therefore reference the durable media object instead of coupling external consumers to the Next.js image optimizer.

Invalidating old and new content relationships

Publishing a post affects more than its article page.

It can change:

  • The current article URL
  • The previous URL after a slug change
  • The blog index
  • The homepage
  • Old and new tag pages
  • RSS
  • The sitemap

Payload hooks call triggerRevalidation(), which invokes revalidatePath() for affected routes and revalidateTag() for cached query data.

Post and project hooks compare the current document with previousDoc. That makes it possible to calculate the union of previous and current tag relationships. A slug change also passes previousSlug, allowing both the retired path and the new path to be invalidated.

Unpublishing removes content from listings and machine-readable output on regeneration. Deleting a published document triggers the same public cleanup. Deleting an unpublished draft skips public revalidation because no public cache should reference it.

Project changes use the same slug and tag logic but do not invalidate RSS. Media changes trace which posts, projects, tag pages, or Site Settings records depend on the changed object.

An authenticated /api/revalidate endpoint supports external triggers. It requires REVALIDATION_SECRET through the x-revalidation-secret header. Normal Payload admin saves call the cache APIs inside the application instead of making that network round trip.

One coherent caching strategy

The final caching model divides responsibility across three mechanisms:

  1. CMS data cache: withCmsCache wraps Payload queries in unstable_cache with a one-hour fallback window and entity-specific tags.
  2. Route fallback: Sitemap and RSS export revalidate = 3600.
  3. On-demand invalidation: Payload hooks invalidate affected query tags and route paths when content changes.

The route and data fallback windows now agree. Sitemap and RSS no longer add their own manual 24-hour CDN headers, and orphan rss or sitemap tags are not emitted without a real cache entry behind them.

A legitimate empty collection still produces valid output. A CMS or database exception is different: it is logged and propagated rather than being converted into a successful but empty sitemap or feed.

That distinction prevents a temporary database failure from being cached as if the site suddenly contained no published content.

What the tests prove

Unit tests cover:

  • Sitemap entry shape
  • Supported image sitemap fields
  • RSS enclosure length and MIME handling
  • Feeds without enclosures
  • JSON-LD script-context serialization
  • Canonical URL behavior
  • Media URL allowlisting
  • Revalidation paths and tags
  • Previous-slug handling
  • Cache error semantics

Browser smoke tests cover sitemap and RSS responses, legacy feed redirects, robots rules, and rejection of unauthenticated revalidation requests.

The repository also includes a Payload/Postgres integration check designed to seed draft and published records, call the real public helpers, and verify that draft slugs stay out of sitemap and RSS output.

These tests prove local structure and wiring. They do not prove that a deployed CDN hostname resolves correctly, that Vercel invalidates every edge location within a particular number of seconds, or that Google chooses to display a rich result.

Remaining tradeoffs

The current sitemap query uses helpers that default to 100 posts and 100 projects. The present catalog remains below that boundary, but a sitemap should not silently become partial as the archive grows. A dedicated paginated sitemap query is the appropriate next step before either collection approaches that limit.

The static route inventory is also manual. Adding a new public route requires an explicit sitemap decision, which is clear but easy to forget without a route-inventory test.

Project entries do not currently include image sitemap metadata. That can be added independently when project cover handling warrants it.

Closing

Sitemap, RSS, and JSON-LD are different output formats. They should not become different definitions of what is public.

On thedanielmark-site, shared Payload queries establish that boundary. Native Next.js sitemap entries expose canonical URLs and public images. The RSS builder emits media only when it has complete metadata. JSON-LD is serialized safely for an inline script context. Payload hooks invalidate old slugs and retired relationships rather than refreshing everything indiscriminately.

The implementation now has one coherent shape in code. Production proof still belongs in deployed URLs, cache behavior, and search tooling, not in the implementation story alone.


Portrait of Daniel Mark

Written by

Daniel Mark

Senior Frontend Engineer

Daniel Mark is a senior frontend engineer and product consultant with 7+ years of experience building resilient product interfaces for Web3, SaaS, and enterprise teams. He writes about frontend architecture, design systems, performance, SEO, and the engineering decisions that turn complex systems into dependable user experiences.