Claude
Skills
Sign in
Back

rss

Included with Lifetime
$97 forever

Setup RSS, Atom, and JSON Feed generation for Next.js with Payload CMS integration. Use this skill when the user says "setup rss", "add rss feed", "setup atom feed", "add feed", "rss integration", or "syndication feeds".

Web Dev

What this skill does


# RSS Feed Integration

Sets up RSS 2.0, Atom, and JSON Feed generation for Next.js App Router with Payload CMS integration, SEO-optimized feed discovery, and content team-friendly debug tools.

## Quick Start

```bash
# 1. Install feed generation package
bun add feedsmith

# 2. Start dev server
bun dev

# 3. Test feeds
open http://localhost:3000/feed.xml
open http://localhost:3000/feed.atom
open http://localhost:3000/feed.json

# 4. Debug page
open http://localhost:3000/debug/rss
```

## What Gets Created

### Route Handlers

1. **`src/app/feed.xml/route.ts`** - RSS 2.0 feed (most compatible)
2. **`src/app/feed.atom/route.ts`** - Atom feed (better semantics)
3. **`src/app/feed.json/route.ts`** - JSON Feed (modern, easy parsing)

### Library Files

1. **`src/lib/rss/generate.ts`** - Feed generation utilities
2. **`src/lib/rss/types.ts`** - TypeScript types for feed items
3. **`src/lib/rss/fetch-content.ts`** - CMS content fetching

### Debug & Testing

1. **`src/app/debug/rss/page.tsx`** - Feed preview and validation
2. **`e2e/specs/rss.spec.ts`** - Playwright validation tests

## Installation

```bash
bun add feedsmith
```

## Environment Variables

Add to `.env.local`:

```env
# Site Configuration (required for absolute URLs)
NEXT_PUBLIC_APP_URL=http://localhost:3000

# Feed Metadata
NEXT_PUBLIC_SITE_NAME="Your Site Name"
NEXT_PUBLIC_SITE_DESCRIPTION="Your site description"
```

Add to `src/env.ts`:

```typescript
client: {
  NEXT_PUBLIC_APP_URL: z.string().url(),
  NEXT_PUBLIC_SITE_NAME: z.string().optional(),
  NEXT_PUBLIC_SITE_DESCRIPTION: z.string().optional(),
}
```

## Prerequisites

### TypeScript Path Alias

Add `@payload-config` to `tsconfig.json` paths (required for Payload CMS imports):

```json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"],
      "@payload-config": ["./src/payload.config.ts"]
    }
  },
  "exclude": ["node_modules", "e2e"]
}
```

## Setup Steps

### 1. Create Feed Types (`src/lib/rss/types.ts`)

```typescript
export type FeedItem = {
  id: string;
  title: string;
  slug: string;
  description: string;
  content?: string;
  publishedAt: Date;
  updatedAt?: Date;
  author?: {
    name: string;
    email?: string;
  };
  categories?: string[];
  featuredImage?: {
    url: string;
    alt?: string;
  };
};

export type FeedConfig = {
  title: string;
  description: string;
  siteUrl: string;
  feedUrl: string;
  language?: string;
  copyright?: string;
  managingEditor?: string;
  webMaster?: string;
  ttl?: number;
  image?: {
    url: string;
    title: string;
    link: string;
  };
};
```

### 2. Create Content Fetcher (`src/lib/rss/fetch-content.ts`)

```typescript
import { getPayload } from "payload";
import config from "@payload-config";
import type { FeedItem } from "./types";

/**
 * Fetch published posts from Payload CMS
 * Falls back to empty array if Payload is not configured
 */
export async function fetchFeedItems(limit = 50): Promise<FeedItem[]> {
  try {
    const payload = await getPayload({ config });

    const posts = await payload.find({
      collection: "posts",
      where: {
        status: { equals: "published" },
      },
      sort: "-publishedAt",
      limit,
      depth: 2,
    });

    return posts.docs.map((post) => ({
      id: String(post.id),
      title: post.title,
      slug: post.slug,
      description: post.excerpt ?? "",
      content: extractTextFromLexical(post.content),
      publishedAt: new Date(post.publishedAt ?? post.createdAt),
      updatedAt: new Date(post.updatedAt),
      author:
        typeof post.author !== "string" && typeof post.author !== "number" && post.author
          ? {
              name: post.author.name ?? post.author.email,
              email: post.author.email,
            }
          : undefined,
      categories:
        post.categories?.map((cat: string | number | { name: string }) =>
          typeof cat === "string" || typeof cat === "number" ? String(cat) : cat.name
        ) ?? [],
      featuredImage:
        typeof post.featuredImage !== "string" && typeof post.featuredImage !== "number" && post.featuredImage
          ? {
              url: post.featuredImage.url ?? "",
              alt: post.featuredImage.alt,
            }
          : undefined,
    }));
  } catch {
    // Payload not configured or collection doesn't exist
    console.warn("RSS: Could not fetch from Payload CMS, returning empty feed");
    return [];
  }
}

/**
 * Extract plain text from Lexical rich text content
 */
function extractTextFromLexical(content: unknown): string {
  if (!content || typeof content !== "object") return "";

  const root = content as { root?: { children?: unknown[] } };
  if (!root.root?.children) return "";

  function extractText(nodes: unknown[]): string {
    return nodes
      .map((node) => {
        const n = node as { text?: string; children?: unknown[] };
        if (n.text) return n.text;
        if (n.children) return extractText(n.children);
        return "";
      })
      .join(" ");
  }

  return extractText(root.root.children).trim();
}
```

### 3. Create Feed Generator (`src/lib/rss/generate.ts`)

```typescript
import {
  generateRssFeed,
  generateAtomFeed,
  generateJsonFeed,
} from "feedsmith";
import type { FeedItem, FeedConfig } from "./types";
import { env } from "@/env";

const DEFAULT_CONFIG: FeedConfig = {
  title: env.NEXT_PUBLIC_SITE_NAME ?? "My Site",
  description: env.NEXT_PUBLIC_SITE_DESCRIPTION ?? "Latest updates from our site",
  siteUrl: env.NEXT_PUBLIC_APP_URL,
  feedUrl: `${env.NEXT_PUBLIC_APP_URL}/feed.xml`,
  language: "en-US",
  copyright: `Copyright ${new Date().getFullYear()}`,
  ttl: 60,
};

/**
 * Generate RSS 2.0 feed
 */
export function generateRss(items: FeedItem[], config?: Partial<FeedConfig>): string {
  const feedConfig = { ...DEFAULT_CONFIG, ...config };
  const siteUrl = feedConfig.siteUrl;

  return generateRssFeed({
    title: feedConfig.title,
    link: siteUrl,
    description: feedConfig.description,
    language: feedConfig.language,
    copyright: feedConfig.copyright,
    managingEditor: feedConfig.managingEditor,
    webMaster: feedConfig.webMaster,
    pubDate: new Date(),
    lastBuildDate: new Date(),
    ttl: feedConfig.ttl,
    image: feedConfig.image,
    items: items.map((item) => ({
      title: item.title,
      link: `${siteUrl}/blog/${item.slug}`,
      description: item.description,
      author: item.author?.email
        ? `${item.author.email} (${item.author.name})`
        : item.author?.name,
      guid: `${siteUrl}/blog/${item.slug}`,
      pubDate: item.publishedAt,
      categories: item.categories?.map((name) => ({ name })),
      enclosure: item.featuredImage?.url
        ? {
            url: item.featuredImage.url.startsWith("http")
              ? item.featuredImage.url
              : `${siteUrl}${item.featuredImage.url}`,
            type: "image/jpeg",
            length: 0,
          }
        : undefined,
    })),
  });
}

/**
 * Generate Atom feed
 */
export function generateAtom(items: FeedItem[], config?: Partial<FeedConfig>): string {
  const feedConfig = { ...DEFAULT_CONFIG, ...config };
  const siteUrl = feedConfig.siteUrl;

  return generateAtomFeed({
    id: `${siteUrl}/feed.atom`,
    title: feedConfig.title,
    subtitle: feedConfig.description,
    updated: new Date(),
    links: [
      { href: siteUrl },
      { href: `${siteUrl}/feed.atom`, rel: "self", type: "application/atom+xml" },
    ],
    rights: feedConfig.copyright,
    entries: items.map((item) => ({
      id: `${siteUrl}/blog/${item.slug}`,
      title: item.title,
      updated: item.updatedAt ?? item.publishedAt,
      published: item.publishedAt,
      summary: item.description,
      content: item.content,
      links: [{ href: `${siteUrl}/blog/${item.slug}` }],
      authors: item.author
        ? [{ name: item.author.name, email: item.author.email }]
        : [],
      categories: item.categories?.map((term) => ({ term })),
    })),
  });
}

/**
 * Gen
Files: 2
Size: 36.9 KB
Complexity: 48/100
Category: Web Dev

Related in Web Dev