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.