Mastering WordPress RSS feeds: a developer's guide

Mastering WordPress RSS feeds: a developer's guide

Last verified: October 12, 2026
25 min read
Guide
Full-stack developer

WordPress was serving content as XML for years before anyone coined the term “headless CMS”. An RSS feed is still the simplest contract around: a list of items, a title, the full text or an excerpt, a date and a canonical link. Aggregators, newsletter importers, feed readers and podcast directories still consume it.

This guide is for developers who maintain a theme or a small plugin and want control over feeds without an “RSS everything” bundle. It covers the default endpoints, what the RSS2 template actually outputs compared with the spec, add_feed, caching, safe disabling, user enumeration through the author feed, podcasts, JSON Feed and WebSub. The parts about core behaviour have been checked against the WordPress source (wp-includes/feed-rss2.php, feed-atom.php, feed.php, class-wp.php).

#WordPress default RSS feeds list

Core registers several feed types. With pretty permalinks enabled, these are the ones you usually see:

  • /feed/ or /feed/rss2/: RSS 2.0 (the default)
  • /feed/atom/: Atom
  • /feed/rdf/: RDF/RSS 1.0
  • /comments/feed/: site-wide comments
  • /category/slug/feed/, /tag/slug/feed/, /author/login/feed/, /search/term/feed/: contextual feeds

Without pretty permalinks the same data goes out through the query string (?feed=rss2). The do_feed() function reads the feed variable and fires the do_feed_{name} action; the default handlers load the wp-includes/feed-*.php files. If no action exists for a given name, WordPress ends the request with a 404.

In production, three things matter:

  1. Discovery: core prints <link rel="alternate" type="application/rss+xml" ...> in wp_head through the feed_links and feed_links_extra functions, provided the theme declares add_theme_support( 'automatic-feed-links' ). Readers and crawlers take what they see in the HTML, not what “should be there”.
  2. Query scope: the main feed pulls posts (post). CPTs, pages and custom statuses stay out until you change pre_get_posts.
  3. Full text vs excerpt: the rss_use_excerpt option (Settings, Reading) decides the shape of each item. The RSS2 template always prints <description> with the excerpt (the_excerpt_rss()), and with full text it adds <content:encoded> holding the content from get_the_content_feed(). The the_content_feed and the_excerpt_rss filters act on these two fields.

If you are building a newsletter importer, check /feed/ without plugins first. Often the problem is not “no RSS” but a missing thumbnail, a missing CPT or a CDN that is too aggressive and caches XML like HTML.

XML-layer hooks worth knowing before you write your own template: rss2_ns (namespaces on the root element), rss2_head (children of <channel>), rss2_item (children of each <item>), and the Atom equivalents (atom_ns, atom_head, atom_entry). The content filters (the_content_feed, the_excerpt_rss) run earlier, on the HTML string inserted into the item. Order matters: first you build the item content, then you add XML elements around it.

The number of items in the main feed comes from the posts_per_rss option (Settings, Reading): WP_Query uses it as posts_per_page for feed queries, and the same limit trims the comments feed. Do not raise it to hundreds “just in case”: a heavy XML document with full content and shortcodes kills TTFB and breaks PHP memory limits on shared hosting.

#WordPress RSS2 template vs RSS 2.0 specification

The RSS 2.0 specification requires three elements in the channel: title, link and description. In an item every element is optional, but at least one of title or description has to be present. Dates use the RFC 822 format. The feed-rss2.php template meets that minimum and adds a few elements worth knowing about before you start duplicating them in hooks:

  • <atom:link rel="self"> with the address of the feed itself (the Atom namespace is declared on the RSS2 root),
  • <lastBuildDate> and <language> from the site settings,
  • <sy:updatePeriod> and <sy:updateFrequency> from the Syndication module, hourly and 1 by default, changed with the rss_update_period and rss_update_frequency filters,
  • in every item <dc:creator>, categories, <pubDate> in the +0000 time zone and <guid isPermaLink="false">.

The <ttl> element (the number of minutes a channel may be cached) does not appear in the core template. If your reader or aggregator honours it, add it on rss2_head.

guid deserves separate attention. The spec describes it as a string that uniquely identifies the item; isPermaLink defaults to true, and with false a reader may not assume it is a URL. WordPress prints the post’s guid field, set when the post was created (usually in the form https://domain/?p=123), and marks it isPermaLink="false". That is the right call: changing the slug or the domain does not change the identifier, so readers do not show old posts as new. Do not overwrite guid during a domain migration unless you want subscribers to receive a replay of the whole archive.

The spec also defines <enclosure> with three required attributes: url, length (size in bytes) and type (the MIME type). The RSS2 template calls rss_enclosure(), which reads the post’s enclosure custom field (URL, length and type on consecutive lines) and prints a finished element. Before you add your own enclosure in rss2_item, check whether the post already has that field, or you will get a duplicate.

#WordPress Atom feed vs RSS 2.0 differences

Atom is defined in RFC 4287 from 2005 and is stricter than RSS. The atom:feed element must contain exactly one atom:id, one atom:title and one atom:updated, plus at least one atom:author, unless every entry has its own author. The same three elements are mandatory in every atom:entry. A feed should have an atom:link with rel="self", dates must follow the date-time format from RFC 3339, and the MIME type is application/atom+xml.

The feed-atom.php template implements it like this: the channel <id> is the Atom feed address, the entry <id> is the guid, every entry has an <author>, and next to <published> it prints <updated> with the post’s last modification date. That is the practical difference: core RSS2 only has pubDate, so a correction to a published text leaves no trace in it, while Atom signals it through updated. If your consumers sync content changes (archives, internal search engines, importers), give them /feed/atom/.

#How to add thumbnails and media to a WordPress RSS feed

The default feed does not add the featured image to the content. Most newsletter tools look for an <img> in the item content or in Media RSS. A minimal, predictable fix:

declare(strict_types=1);

function wppoland_feed_prepend_thumbnail( string $content ): string {
	if ( ! is_feed() || ! has_post_thumbnail() ) {
		return $content;
	}

	$post_id = get_the_ID();
	$url     = get_the_post_thumbnail_url( $post_id, 'medium_large' );

	if ( ! $url ) {
		return $content;
	}

	$img = sprintf(
		'<p><img src="%s" alt="%s" /></p>',
		esc_url( $url ),
		esc_attr( get_the_title( $post_id ) )
	);

	return $img . $content;
}
add_filter( 'the_content_feed', 'wppoland_feed_prepend_thumbnail' );
add_filter( 'the_excerpt_rss', 'wppoland_feed_prepend_thumbnail' );

With full text in the feed, both filters land in the same item (the excerpt in <description>, the content in <content:encoded>), so the image shows up in both fields. Most readers display content:encoded, while newsletter importers often read description, which is why keeping both filters makes sense.

For readers that honour Media RSS you can also add the xmlns:media="http://search.yahoo.com/mrss/" namespace on rss2_ns and a media:content element on rss2_item:

declare(strict_types=1);

function wppoland_media_ns(): void {
	echo 'xmlns:media="http://search.yahoo.com/mrss/"' . "\n";
}
add_action( 'rss2_ns', 'wppoland_media_ns' );

function wppoland_media_content(): void {
	$thumb_id = get_post_thumbnail_id();
	$src      = $thumb_id ? wp_get_attachment_image_src( $thumb_id, 'large' ) : false;

	if ( ! $src ) {
		return;
	}

	printf(
		'<media:content url="%s" medium="image" type="%s" width="%d" height="%d" />' . "\n",
		esc_url( $src[0] ),
		esc_attr( (string) get_post_mime_type( $thumb_id ) ),
		(int) $src[1],
		(int) $src[2]
	);
}
add_action( 'rss2_item', 'wppoland_media_content' );

Do not mix both approaches without a reason: one feed, one convention, fewer surprises in importers.

#Delay posts in the WordPress RSS feed

Scrapers and aggregators pull /feed/ within seconds of publication. If the original gets indexed more slowly than the copy, you have a classic canonical dispute. Delaying items in the feed query by an hour gives the crawler on your own domain a head start:

declare(strict_types=1);

function wppoland_delay_feed_posts( string $where, WP_Query $query ): string {
	global $wpdb;

	if ( ! $query->is_main_query() || ! $query->is_feed() || $query->is_comment_feed() ) {
		return $where;
	}

	$where .= $wpdb->prepare(
		" AND {$wpdb->posts}.post_date_gmt <= %s ",
		gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS )
	);

	return $where;
}
add_filter( 'posts_where', 'wppoland_delay_feed_posts', 10, 2 );

The posts_where filter passes the query object as its second argument, so the condition applies only to the main feed query and not to widgets or blocks rendered in the same request. Comparing the column with a precomputed date (instead of running TIMESTAMPDIFF on the column) lets the database use the index on post_date_gmt.

This does not replace rel=canonical or a sitemap. It is only a time buffer on the XML layer. On sites with frequent post-publication edits, set a longer threshold or exclude draft statuses another way.

#Add custom post types to the main WordPress feed

The main /feed/ reads the post type. Portfolio items, events or release notes stay off the list until you extend the query:

declare(strict_types=1);

function wppoland_include_cpt_in_main_feed( WP_Query $query ): void {
	if ( is_admin() || ! $query->is_main_query() || ! $query->is_feed() ) {
		return;
	}

	// Main feed only, not /author/.../feed/ or comments.
	if ( $query->is_comment_feed() || $query->is_author() || $query->is_category() || $query->is_tag() || $query->is_search() ) {
		return;
	}

	$query->set(
		'post_type',
		array( 'post', 'portfolio', 'event' )
	);
}
add_action( 'pre_get_posts', 'wppoland_include_cpt_in_main_feed' );

The is_main_query() check is essential: without it you change every query run while the feed is generated, plugin queries included. A separate CPT feed (/feed/portfolio/) is often cleaner than mixing types in one XML document. A post type registered with has_archive also gets its own feed under the archive address, for example /portfolio/feed/.

#How to create a custom RSS feed with add_feed

add_feed( $feedname, $callback ) registers a /feed/{name}/ rewrite and calls the callback instead of the standard RSS2 template. This is the official API from the feeds documentation and the add_feed reference. The callback receives two arguments: whether this is a comments feed, and the feed name.

A production pattern:

declare(strict_types=1);

function wppoland_register_release_feed(): void {
	add_feed(
		'releases',
		static function (): void {
			header( 'Content-Type: application/rss+xml; charset=' . get_option( 'blog_charset' ), true );
			echo '<?xml version="1.0" encoding="' . esc_attr( get_option( 'blog_charset' ) ) . '"?' . '>';
			// Build the full RSS2 document here or load your own template.
			load_template( get_stylesheet_directory() . '/feed-releases.php' );
		}
	);
}
add_action( 'init', 'wppoland_register_release_feed' );

After the first registration (or a feed rename) run flush_rewrite_rules() once, on plugin activation, not on every init. Without the flush you get a 404 at /feed/releases/ even though the callback code is correct.

In the callback:

  • set Content-Type before you output a single byte of XML,
  • escape every user-supplied field (esc_xml / esc_url / esc_html),
  • limit the query (posts_per_page, publish status, a meta query if needed),
  • do not call heavy shortcodes without caching.

The header in the callback is needed for a specific reason. For every feed request WP::send_headers() sets Content-Type from feed_content_type(), which only knows rss, rss2, rss-http, atom and rdf. For an unknown name it returns application/octet-stream. Either you override the header in the callback (second argument true), or you add the type through the feed_content_type filter, which receives the MIME type and the feed name.

If all you need is a variant of the main RSS2 feed (a different item count, a different post type), add_feed plus pre_get_posts on is_feed( 'releases' ) and the standard template is sometimes enough. A dedicated template file makes sense when the XML has different namespaces or a different item structure.

The feed name goes into the feed rewrite tag. Be careful with core names (rss2, atom, rdf, rss): add_feed removes the default do_feed_{name} handler and hooks your callback instead, so add_feed( 'rss2', ... ) quietly replaces the main feed of the whole site. After a rename the old URL stops working until you add a redirect in the server configuration or in template_redirect. In local testing after add_feed, go to Settings, Permalinks and save without changes if you do not flush on plugin activation. It is the same mechanism.

#How WordPress feed caching works

There are three different mechanisms here that are easy to confuse: the cache for external feeds fetched by WordPress, conditional responses for your own feeds, and caching on the CDN.

External feeds. fetch_feed() builds a SimplePie object from an RSS or Atom address and returns it, or returns WP_Error on failure. It also accepts an array of addresses and merges them into one feed. The result goes into transients, and the wp_feed_cache_transient_lifetime filter changes its lifetime (12 hours by default). This is not the cache for your own /feed/.

declare(strict_types=1);

$rss = fetch_feed( 'https://example.com/feed/' );

if ( ! is_wp_error( $rss ) ) {
	$max   = $rss->get_item_quantity( 5 );
	$items = $rss->get_items( 0, $max );

	foreach ( $items as $item ) {
		printf(
			'<li><a href="%s">%s</a></li>',
			esc_url( $item->get_permalink() ),
			esc_html( $item->get_title() )
		);
	}
}

add_filter(
	'wp_feed_cache_transient_lifetime',
	static function (): int {
		return HOUR_IN_SECONDS;
	}
);

The is_wp_error() check is not a formality: an unreachable source server or broken XML happens all the time, and without that condition the widget throws a fatal error on the front end. SimplePie content passes through a KSES-based sanitizer, but you still escape titles and links on output.

Your feeds and 304 responses. Core handles conditional requests for feeds without any plugin. On every request with the feed variable set, WP::send_headers() computes Last-Modified from the last modification date of a post (and, for comment feeds, of a comment), derives an ETag from it, and when the client sends a matching If-Modified-Since or If-None-Match, it answers 304 and ends the request. This also covers endpoints registered with add_feed, because they go through the same variable. You only write your own 304 logic for endpoints outside this mechanism, for example in the REST API.

Your own /feed/ or /feed/releases/ endpoint at the edge:

  • CDN / reverse proxy: set Cache-Control deliberately. A public feed with max-age=300 is usually enough. After publishing, invalidate the /feed/ path or version the ETag.
  • Object cache: if you build heavy JSON or combine CPTs with meta, cache the response string with wp_cache_set and a short TTL, and clear it on transition_post_status / save_post.
  • Do not cache per-user HTML: the feed is public; personalisation inside XML ends with data leaking between subscribers.
  • Plugin page cache: some full HTML caches serve /feed/ as a page with the wrong Content-Type. Exclude feed paths from the cache rules or force a bypass for is_feed().

An example of a short cache header on a custom feed:

header( 'Cache-Control: public, max-age=300' );
header( 'X-Robots-Tag: noindex' ); // optional, if you do not want the XML indexed

noindex on a feed does not switch off syndication. It only blocks indexing of the XML itself as a “page”. With Cloudflare or another edge: a cache rule for *.xml / path /feed* with a short TTL, and Cache Everything only when the origin returns the correct MIME type. A bad edge cache can hold an empty document for hours after a rewrite flush.

#How to disable or restrict RSS feeds in WordPress safely

Switching feeds off “hard” without a plan leaves dangling discovery links and stale reader bookmarks. A safe order:

  1. Remove the discovery links: remove_action( 'wp_head', 'feed_links', 2 ) and remove_action( 'wp_head', 'feed_links_extra', 3 ), with the priorities core registers them at.
  2. Decide whether old URLs should get a 301 to HTML content, an empty 410, or keep serving XML.
  3. Only then disable the do_feed_* handlers.

Core registers four handlers: do_feed_rdf, do_feed_rss, do_feed_rss2 and do_feed_atom. There are no separate handlers for comments: /comments/feed/ goes through do_feed_rss2 (or do_feed_atom) with the first argument set to true. A minimal cut-off for the classic feeds (it still leaves room for your own add_feed):

declare(strict_types=1);

function wppoland_disable_default_feeds(): void {
	remove_action( 'do_feed_rdf', 'do_feed_rdf', 10 );
	remove_action( 'do_feed_rss', 'do_feed_rss', 10 );
	remove_action( 'do_feed_rss2', 'do_feed_rss2', 10 );
	remove_action( 'do_feed_atom', 'do_feed_atom', 10 );
}
add_action( 'init', 'wppoland_disable_default_feeds' );

function wppoland_feed_gone(): void {
	wp_die(
		esc_html__( 'This feed has been disabled.', 'wppoland' ),
		'',
		array( 'response' => 410 )
	);
}
add_action( 'do_feed_rss2', 'wppoland_feed_gone', 1 );
add_action( 'do_feed_atom', 'wppoland_feed_gone', 1 );

RDF and RSS 0.92 without a handler end in a 404 from do_feed(), while RSS2 and Atom (including their comment versions) get a 410.

A gentler alternative: keep RSS2 and cut Atom/RDF and the comments feed. Aggregators take RSS2 anyway. To disable only the comments feed, check the handler argument:

declare(strict_types=1);

function wppoland_comments_feed_gone( bool $is_comment_feed ): void {
	if ( $is_comment_feed ) {
		wp_die( esc_html__( 'The comments feed is disabled.', 'wppoland' ), '', array( 'response' => 410 ) );
	}
}
add_action( 'do_feed_rss2', 'wppoland_comments_feed_gone', 1 );
add_action( 'do_feed_atom', 'wppoland_comments_feed_gone', 1 );
add_filter( 'feed_links_show_comments_feed', '__return_false' );

The feed_links_show_comments_feed filter removes the discovery link for the comments feed from <head>. Do not remove feed rewrites without a flush, or you get a 404 instead of a controlled response.

#WordPress author feed user enumeration

/author/admin/feed/ and /author/mariusz/feed/ confirm that a login exists. Together with /?author=1 (a redirect to the archive) this is a classic account enumeration vector ahead of brute-force attacks or targeted phishing.

A hard shutdown of author archives (feeds included):

declare(strict_types=1);

function wppoland_disable_author_archives(): void {
	if ( is_author() ) {
		global $wp_query;
		$wp_query->set_404();
		status_header( 404 );
		nocache_headers();
	}
}
add_action( 'template_redirect', 'wppoland_disable_author_archives' );

If author archives have to stay (a personal blog), at least:

  • use a clean nicename rather than the raw login in the URL, if your setup allows it,
  • do not publish a list of authors with their logins in the HTML,
  • consider limiting the author feed to an empty list, or a 404 only for is_feed() && is_author().

Keep in mind that the RSS2 template prints <dc:creator> with the author’s display name in every item, and Atom prints an <author> element. If the display name equals the login, the main feed exposes it just as the author archive does.

The REST API (/wp-json/wp/v2/users) is a separate topic. Disabling the author feed does not close enumeration through REST without additional filters. Treat RSS as one of many surfaces that reveal identity.

Do not put email addresses, tokens, internal order IDs or private / draft content into a feed. Content filters also run in the feed context: a shortcode that shows a customer panel in HTML can leak to public subscribers in XML.

The comments feed (/comments/feed/ or the feed under a single post) often gets skipped in audits. If comments contain login details, order numbers or PII of B2B customers, disable the comments feed with the code from the previous section or keep moderation in place before a comment reaches the XML. A public comments feed is a separate scraping surface, not just a “bonus for readers”.

#WordPress podcast feed with iTunes tags and enclosure

WordPress is not an audio file host, but it can be an RSS generator that directories accept. The Apple Podcasts requirements are explicit: every episode needs a unique <enclosure> with three parts (URL, length, type), every episode needs a GUID that never changes, and the feed must contain at least one episode. Apple keeps the list of required channel tags in a separate document on RSS tags, and that is the document you compare your feed against before submission.

Technically you need:

  • the xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" namespace (a historical namespace URL; directories recognise it),
  • channel tags from the itunes namespace (for example itunes:author, itunes:category, itunes:image) plus language,
  • items with <enclosure url="..." length="..." type="audio/mpeg" /> and optionally itunes:duration.

Core hooks:

declare(strict_types=1);

function wppoland_podcast_ns(): void {
	echo ' xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"';
}
add_action( 'rss2_ns', 'wppoland_podcast_ns' );

function wppoland_podcast_head(): void {
	echo '<itunes:author>' . esc_xml( get_bloginfo( 'name' ) ) . '</itunes:author>';
}
add_action( 'rss2_head', 'wppoland_podcast_head' );

function wppoland_podcast_item_enclosure(): void {
	$url = get_post_meta( get_the_ID(), 'episode_audio_url', true );
	$len = (int) get_post_meta( get_the_ID(), 'episode_audio_length', true );

	if ( ! is_string( $url ) || $url === '' || $len < 1 ) {
		return;
	}

	printf(
		'<enclosure url="%s" length="%d" type="audio/mpeg" />',
		esc_url( $url ),
		$len
	);
}
add_action( 'rss2_item', 'wppoland_podcast_item_enclosure' );

This code assumes episodes do not have the enclosure field filled in, which rss_enclosure() prints automatically. esc_xml() has been in core since version 5.5 and is the correct escaping function for text inside XML elements.

In production a separate feed (add_feed( 'podcast', ... )) is usually better than mixing regular posts with episodes. Directories validate the whole channel; one bad item can get a submission rejected. Keep MP3 files on a stable URL (a CDN) with the correct length in bytes, because guessed lengths break clients. Apple also recommends enabling HEAD requests and byte-range requests on the file server, since streaming playback depends on them.

#How to add JSON Feed to WordPress

JSON Feed is an alternative to XML for JS clients. WordPress core does not register it natively. You add it with add_feed( 'json', $callback ) and the Content-Type: application/feed+json header, because that is the MIME type the spec recommends.

A callback skeleton:

declare(strict_types=1);

function wppoland_json_feed(): void {
	header( 'Content-Type: application/feed+json; charset=utf-8', true );

	$query = new WP_Query(
		array(
			'post_type'           => 'post',
			'post_status'         => 'publish',
			'posts_per_page'      => 20,
			'ignore_sticky_posts' => true,
			'no_found_rows'       => true,
		)
	);

	$items = array();
	foreach ( $query->posts as $post ) {
		$items[] = array(
			'id'             => (string) get_the_guid( $post ),
			'url'            => get_permalink( $post ),
			'title'          => get_the_title( $post ),
			'content_html'   => apply_filters( 'the_content', $post->post_content ),
			'date_published' => get_post_time( DATE_ATOM, true, $post ),
			'date_modified'  => get_post_modified_time( DATE_ATOM, true, $post ),
		);
	}

	echo wp_json_encode(
		array(
			'version'       => 'https://jsonfeed.org/version/1.1',
			'title'         => get_bloginfo( 'name' ),
			'home_page_url' => home_url( '/' ),
			'feed_url'      => home_url( '/feed/json/' ),
			'language'      => get_bloginfo( 'language' ),
			'items'         => $items,
		),
		JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
	);
}

add_action(
	'init',
	static function (): void {
		add_feed( 'json', 'wppoland_json_feed' );
	}
);

JSON Feed does not replace RSS for podcasts or older readers. Treat it as a second contract for your own clients and internal tools. Discovery in HTML: type="application/feed+json".

At the feed level the spec requires version, title and items, and in every item an id (a string) plus at least one of content_html or content_text. Version 1.1 added the authors field (an array, so several authors per feed or item) and the language field, and deprecated the single author. Keep item id values stable: the spec says the id should not change when an item is updated, and new items should not reuse a previously used id. That is why the example uses the guid as id rather than the permalink, which changes with the slug. content_text or content_html: pick one as the source of truth; sending both with diverging content ends in inconsistent previews across clients.

#How to enable WebSub for a WordPress feed

Without WebSub a reader polls the feed on its own schedule, so a new post reaches subscribers with a delay that depends on the reader. WebSub, a W3C Recommendation, reverses the direction: the publisher points to a hub, subscribers register with the hub, and the hub distributes the update once the publisher reports a change.

The publisher-side requirements are short. The spec recommends a Link header with at least one rel="hub" and exactly one rel="self", and for XML feeds embedded link elements. The publisher must notify the advertised hubs about an update to the topic, but the spec leaves the notification mechanism itself open. In a note it gives an example found in public hubs: a POST request with the keys hub.mode=publish and hub.url pointing to the changed resource.

WordPress core does not implement WebSub. The plugin directory has a plugin called “WebSub (FKA. PubSubHubbub)” that adds both elements. By hand it looks like this:

declare(strict_types=1);

const WPPOLAND_HUB = 'https://hub.example.com/';

function wppoland_websub_links(): void {
	printf( '<atom:link rel="hub" href="%s" />' . "\n", esc_url( WPPOLAND_HUB ) );
}
add_action( 'rss2_head', 'wppoland_websub_links' );

function wppoland_websub_ping( string $new_status, string $old_status, WP_Post $post ): void {
	if ( 'publish' !== $new_status || 'publish' === $old_status || 'post' !== $post->post_type ) {
		return;
	}

	wp_remote_post(
		WPPOLAND_HUB,
		array(
			'body'     => array(
				'hub.mode' => 'publish',
				'hub.url'  => get_feed_link( 'rss2' ),
			),
			'blocking' => false,
		)
	);
}
add_action( 'transition_post_status', 'wppoland_websub_ping', 10, 3 );

rel="self" is already in the RSS2 template (<atom:link rel="self">), so you only add rel="hub". Check the hub address and the notification format in the documentation of the hub you pick, because the spec does not prescribe them. Subscription verification (hub.challenge), expiry after hub.lease_seconds and the X-Hub-Signature signature when a secret is used are handled by the hub and the subscriber, not by WordPress.

#How to fix XML parsing errors in a WordPress feed

The most common error with custom templates and plugins is a document that the reader rejects with a message along the lines of XML or text declaration not at start of entity. The <?xml ... ?> declaration has to be the very first character of the response. A single blank line or a BOM printed before it is enough for the parser to refuse the whole feed.

The cause almost always sits in a PHP file loaded on every request: a space or a newline after the closing ?> in functions.php or in a plugin, or a file saved as UTF-8 with a BOM. The proper fix is to remove the closing ?> from files that contain only PHP and to save files without a BOM. To find the culprit, run curl -s https://domain/feed/ | head -c 100 | xxd, see what sits in front of <?xml, then deactivate plugins one by one.

If you cannot fix someone else’s plugin right away, an output buffer that cleans the start of the feed response helps as a stopgap:

declare(strict_types=1);

function wppoland_clean_feed_output(): void {
	if ( ! is_feed() ) {
		return;
	}

	ob_start(
		static function ( string $buffer ): string {
			// Strip the BOM and whitespace before the XML declaration.
			return ltrim( preg_replace( '/^\xEF\xBB\xBF/', '', $buffer ) );
		}
	);
}
add_action( 'template_redirect', 'wppoland_clean_feed_output', 1 );

This is a patch, not a fix: the buffer cannot remove characters printed before template_redirect runs. Teams with integration tests can guard against this automatically. The feed tests in core load the template directly and suppress header warnings; the same pattern works in WP_UnitTestCase:

class Feed_Rss2_Test extends WP_UnitTestCase {
	public function test_rss2_is_well_formed(): void {
		self::factory()->post->create_many( 3 );
		$this->go_to( '/?feed=rss2' );

		ob_start();
		@require ABSPATH . WPINC . '/feed-rss2.php';
		$xml = ob_get_clean();

		$this->assertStringStartsWith( '<?xml', $xml );

		$dom = new DOMDocument();
		$this->assertTrue( $dom->loadXML( $xml ) );
	}
}

The assertion checks the raw output without trim(), because leading whitespace is exactly the error here.

#RSS feeds on a multilingual WordPress site

With WPML, Polylang or custom language prefixes, WordPress usually exposes several feed addresses. Typical mistakes:

  • the main feed mixes language versions because pre_get_posts does not filter by language,
  • the CDN caches /feed/ without distinguishing the path or cookie and serves one language’s content to subscribers of another,
  • enclosure URLs point to the wrong domain after domain mapping,
  • the channel <language> element comes from the site settings, not from the language of the content in that particular feed.

Keep one clear endpoint per language, filter by language or taxonomy early in the query, and test the feed with a reader that actually sends If-None-Match, not just a browser tab. With several brands in one installation, a separate add_feed( 'brand-a', ... ) is cleaner than a query parameter appended to the main feed: parameters fragment the cache on the CDN side, and the core ETag depends only on the last modification date, not on parameters.

#Custom WordPress RSS feed deployment checklist

Before you push changes to production:

  1. Open /feed/ in a browser and with curl -I: status 200, a sensible Content-Type.
  2. After add_feed, activate the plugin / flush rewrites and check the new URL.
  3. Remove unneeded rel=alternate links if you are disabling feeds.
  4. Check /author/.../feed/ for enumeration.
  5. If you are running a podcast, put the XML through the directory’s validator before submission.
  6. After publishing a test post, confirm that the CDN serves fresh XML within an acceptable TTL.
  7. Verify that shortcodes and dynamic blocks in the feed content do not leak admin panel HTML or session data.
  8. Send a second request with If-None-Match set to the ETag you received and check that it returns 304.

RSS in WordPress is an API layer, not a relic. You control the query, the template, the cache and the attack surface just as you would for a REST endpoint. The difference is that feed clients are older, less forgiving of bad XML and more willing to cache the response on their side.

Official reference points: Feeds (Developer Resources), add_feed and WordPress Feeds in the administration docs. Before you write yet another “RSS Pack” plugin, check which of these three contracts is actually missing from your theme.

If you need to extend a feed with custom post types, meta fields or a separate podcast channel as part of WordPress development, start with one add_feed and one template, not a monolithic “all feeds” plugin.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Want this implemented on your site?

If you want to convert the article into a working site improvement, redesign, or build plan, I can define the scope and implement it.

Related cluster

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

How can I cache my WordPress RSS feeds to improve performance?#
Core already sends Last-Modified and ETag for every feed request and answers matching conditional requests with 304. On top of that, set a short public Cache-Control on the CDN and, for heavy custom feeds, cache the response string in the object cache and clear it on save_post.
Why is my RSS feed showing formatting errors?#
Usually because whitespace or a BOM is printed before the XML declaration, most often after a closing PHP tag in functions.php or a plugin. Remove closing tags from PHP-only files and save them without BOM.
Can I disable RSS feeds entirely in WordPress?#
Yes. Remove the discovery links from wp_head first, decide what old URLs should return, then remove the default do_feed_* handlers and answer with 410 or a 301 to HTML content.
How do I validate my custom RSS feeds?#
Use standard online validators like the W3C Feed Validation Service to verify namespace attributes and XML format correctness.
How do I handle multi-language RSS feeds in WordPress?#
Keep one clear feed endpoint per language, filter the language early in the query, and make sure the CDN caches each language path separately so subscribers of one locale never get another locale's content.

Need an FAQ tailored to your industry and market? We can build one aligned with your business goals.

Let’s discuss

Related Articles