For a content-heavy site, you want your pages and images discoverable on as many channels as possible: Google (including image search), RSS readers, and Pinterest.
On Belle AI, I generate all three from the same typed content data using plain Next.js route handlers. No plugins needed.
Disclosure: I'm the developer of Belle AI.
The idea: one source of truth
My content lives in typed TypeScript modules: season palettes, lookbooks, face shape guides, blog posts. Each has slugs, titles, and image paths.
import { SEASON_PALETTES, SEASON_SLUGS } from "@/lib/color-palette-data";
import { LOOKBOOK_DATA } from "@/lib/lookbook-data";
import { getAllPosts } from "@/lib/blog";
The sitemap and the feeds all read from these modules. If I add a new lookbook entry, it appears in every output automatically. No second list to forget to update.
Route handlers can return any XML
A file at app/sitemap-images.xml/route.ts is served at /sitemap-images.xml. Return a Response with the right content type:
export async function GET() {
const entries: ImageEntry[] = buildEntries();
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
${entries.map(renderUrl).join("\n")}
</urlset>`;
return new Response(xml, {
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
Always escape your XML
Titles and captions contain &, quotes and apostrophes. One unescaped character makes the whole file invalid, and crawlers will silently drop it.
function esc(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
Escape & first, or you'll double-escape the others.
Absolute URLs and locale paths
Sitemaps need absolute URLs. With a default locale that has no prefix and other locales that do, I use two small helpers:
function localePath(locale: string, path: string): string {
const isDefault = locale === routing.defaultLocale;
return isDefault
? `${SITE_URL}${path || "/"}`
: `${SITE_URL}/${locale}${path}`;
}
function absUrl(imagePath: string): string {
if (imagePath.startsWith("http")) return imagePath;
return `${SITE_URL}${imagePath.startsWith("/") ? imagePath : `/${imagePath}`}`;
}
Use one SITE_URL constant everywhere. Mixing www and non-www hosts in a sitemap is a quiet way to create duplicate-content signals.
Image sitemaps: attach images to page URLs
Each <url> can contain <image:image> entries, which helps Google discover images that are rendered by components or loaded lazily:
interface ImageEntry {
loc: string;
images: { url: string; title?: string; caption?: string }[];
}
For pages without a hand-picked image, I point to the dynamic opengraph-image route, so every page still contributes an image.
The Pinterest feed
Pinterest can ingest an RSS feed, and each item needs an image. I reuse the same data and shape each item like this:
interface FeedItem {
title: string;
link: string;
description: string;
imageUrl: string;
pubDate: string;
category: string;
}
const items: FeedItem[] = [];
for (const post of getAllPosts("en")) {
if (!post.image) continue; // Pinterest needs an image
items.push({
title: post.title,
link: `${SITE_URL}/blog/${post.slug}`,
description: post.description,
imageUrl: absUrl(post.image),
pubDate: new Date(post.date).toUTCString(),
category: "Blog",
});
}
The same loop pattern then adds lookbook images, transformation galleries and guides, each with its own category.
Per-language feeds
Because Next.js page alternates replace layout-level ones, I expose a feed per locale and advertise it from the same helper that builds canonical and hreflang tags:
function rssTypes(locale: string) {
return {
"application/rss+xml":
locale === routing.defaultLocale
? `${SITE_URL}/feed.xml`
: `${SITE_URL}/feed/blog/${locale}.xml`,
};
}
Every page's <head> now points readers to the right feed for their language.
Checklist
- One typed data source feeds the sitemap, RSS and Pinterest feed
- Escape all dynamic text in XML
- Use absolute URLs and one canonical host
- Skip items with no image in the Pinterest feed
- Add cache headers so crawlers don't hammer your server
- Advertise feeds in
<head>viaalternates.types
You can see the site these routes belong to here.
Do you generate feeds and sitemaps from content data like this, or do you use a plugin? Curious what trade-offs others have hit.
Top comments (0)