DEV Community

Cover image for Generating Image Sitemaps, RSS and a Pinterest Feed in Next.js Route Handlers
Alan
Alan

Posted on

Generating Image Sitemaps, RSS and a Pinterest Feed in Next.js Route Handlers

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";
Enter fullscreen mode Exit fullscreen mode

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",
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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;");
}
Enter fullscreen mode Exit fullscreen mode

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}`}`;
}
Enter fullscreen mode Exit fullscreen mode

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 }[];
}
Enter fullscreen mode Exit fullscreen mode

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",
  });
}
Enter fullscreen mode Exit fullscreen mode

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`,
  };
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)