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, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&apos;"); } 

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> via alternates.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.