
Short answer: Sanity has no built-in RSS feed, because it is a headless content platform that serves structured content through an API. To build a Sanity RSS feed, write a GROQ query that returns your latest published posts with their slug, date, summary and image URL, run it from a server route in your front end, for example a Next.js route handler at /rss.xml, and render the result as RSS XML. Use your website’s URLs for links, Sanity’s CDN URLs for images, the document ID as a stable GUID, and refresh the feed with a webhook. Then connect the feed to an auto-poster so every new post is shared on social media automatically.
Sanity is popular with product teams and publishers because content is stored as structured JSON in its Content Lake, edited in a customisable Studio and queried with GROQ, a compact query language. The website is a separate application, usually built with Next.js, Astro, Remix, SvelteKit or Nuxt. That separation is why a feed is not created for you: Sanity does not know your website’s URLs. This guide shows the schema fields a feed needs, the GROQ query, a complete route handler, how to handle Portable Text and images, how to keep drafts and scheduled posts out, and how to refresh the feed when editors publish.
A feed needs four things: a list of items, a public URL for each one, a date and some text. Sanity has the first, third and fourth, but only your front end knows the second. A post with the slug autumn-update might live at /blog/autumn-update, /en/news/autumn-update or in a mobile app. So the feed belongs in the front end, which combines Sanity content with its own routing.
This is the same situation as with other headless platforms, described in general in auto-posting from a headless CMS without native RSS. The difference with Sanity is that GROQ lets you shape the data exactly as the feed needs it in a single query.
Most Sanity blog schemas already have what is required. A typical post type in the Studio:
import { defineType, defineField } from 'sanity';
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
defineField({ name: 'publishedAt', type: 'datetime' }),
defineField({ name: 'excerpt', type: 'text', rows: 3 }),
defineField({ name: 'mainImage', type: 'image' }),
defineField({ name: 'body', type: 'array', of: [{ type: 'block' }] }),
],
});Two fields deserve attention. publishedAt should be set by editors, or filled automatically with an initial value, because Sanity’s system timestamps _createdAt un _updatedAt do not mean the same thing: the first is when the document was created, often long before publication, and the second changes on every edit. The excerpt field gives you a clean summary of one or two sentences, which is exactly what a social post should start with. Add validation rules so neither can be left empty.
One query returns everything the feed needs, already in the right shape:
*[_type == "post"
&& defined(slug.current)
&& defined(publishedAt)
&& publishedAt <= now()]
| order(publishedAt desc)[0...20]{
_id,
title,
"slug": slug.current,
publishedAt,
"summary": coalesce(excerpt, pt::text(body)[0...300]),
"image": mainImage.asset->url
}Line by line:
defined(slug.current) skips posts without a slug, which would produce broken links.publishedAt <= now() keeps posts with a future date out of the feed until that moment, which is useful when editors prepare posts in advance.[0...20] limits the feed to the 20 newest posts. The three dots make the range exclusive, so this returns items 0 to 19.pt::text(body) converts Portable Text to plain text, a fallback when the excerpt is empty.mainImage.asset->url follows the reference to the image asset and returns its absolute CDN URL.In Sanity, a draft is a separate document whose ID starts with drafts.. Drafts are only visible to authenticated requests, so a query made without a token from a public dataset returns published documents only. If your front end uses a token, for example for preview mode, make the feed request explicit:
const posts = await client.fetch(FEED_QUERY, {}, { perspective: 'published' });Recent API versions use the published perspective by default, but setting it in the feed code costs nothing and protects you from a configuration change later. Test it once: create a draft with an obvious title and confirm it does not appear. Anything that reaches the feed can be shared by automation within minutes.
With the App Router, a feed is a route handler. Create app/rss.xml/route.ts:
import { createClient } from '@sanity/client';
import { Feed } from 'feed';
const SITE = process.env.SITE_URL ?? 'https://example.com';
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: 'production',
apiVersion: '2025-02-19',
useCdn: true,
});
export const revalidate = 600;
export async function GET() {
const posts = await client.fetch(FEED_QUERY, {}, { perspective: 'published' });
const feed = new Feed({
title: 'Example Blog',
description: 'News and articles from Example',
id: SITE + '/blog',
link: SITE + '/blog',
copyright: 'Example',
});
for (const p of posts) {
const url = SITE + '/blog/' + p.slug;
feed.addItem({
title: p.title,
id: 'sanity:' + p._id,
link: url,
description: p.summary,
date: new Date(p.publishedAt),
image: p.image ? p.image + '?w=1200&h=630&fit=crop' : undefined,
});
}
return new Response(feed.rss2(), {
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
});
}The feed package from npm handles XML escaping and date formatting. revalidate tells Next.js to regenerate the cached feed at most every ten minutes. The same code works in an Astro endpoint, a Remix or React Router resource route or a SvelteKit server route, with only the export shape changing. Framework details are covered in adding an RSS feed to a Next.js site un the Remix RSS feed guide.
Sanity serves images from its own CDN with absolute URLs, so the usual relative path problem does not arise. The CDN also transforms images through URL parameters: w un h set the size and fit=crop crops to fill. A 1200 by 630 crop matches the preview format most networks use. If you need hotspot and crop settings from the Studio respected, use the @sanity/image-url builder instead of adding parameters by hand:
import imageUrlBuilder from '@sanity/image-url';
const builder = imageUrlBuilder(client);
const img = builder.image(post.mainImage).width(1200).height(630).url();For that, return the whole mainImage object from GROQ rather than only the URL. Use the same image for the og:image tag on the post page, because many networks build the link preview from the page itself. Image selection is explained in how to control which image gets auto-posted.
Sanity stores rich text as Portable Text, an array of JSON blocks, not HTML. For a summary feed, the excerpt or pt::text() is all you need. If you want full content for feed readers, convert the blocks to HTML on the server:
import { toHTML } from '@portabletext/to-html';
const html = toHTML(post.body, { components: myComponents });Custom block types, such as code blocks, embeds or call-to-action boxes, need their own serializers in components, or they are dropped. Internal links stored as references must be turned into absolute URLs, and images inside the body need CDN URLs. A common compromise is a summary feed for automation and a separate full-text feed for readers; the trade-offs are explained in full-text vs excerpt RSS feeds.
| Feed element | Sanity source | Note |
|---|---|---|
| title | title | Plain string, escaped by the feed library |
| link | Site URL plus slug.current | Built in the front end |
| guid | _id | Stable even if the slug changes |
| pubDate | publishedAt | Editor-controlled, not _updatedAt |
| description | excerpt or pt::text(body) | Short plain text works best |
| image | mainImage.asset->url | Absolute CDN URL, resizable |
With time-based revalidation, a new post appears in the feed within the revalidate window. For faster updates, create a GROQ-powered webhook in the Sanity project settings that fires when a post is created or updated, and point it at an API route in your app that calls revalidatePath('/rss.xml'). Protect that route with the webhook secret; the next-sanity package includes a helper to verify the signature.
Be aware of the API CDN as well. With useCdn: true, queries are served from a cache that is usually updated quickly but not instantly. If the webhook triggers a rebuild a moment after publishing, a CDN response can still miss the newest post. For the feed route, consider useCdn: false, which always reads fresh data and is fine for a request that runs only every few minutes. How automation tools detect new items is described in RSS vs webhooks vs API polling.
Because the feed is just a query, topic feeds cost almost nothing. Add a dynamic route such as app/rss/[category]/route.ts and pass the category to GROQ as a parameter, so user input is never pasted into the query string:
*[_type == "post"
&& $category in categories[]->slug.current
&& publishedAt <= now()]
| order(publishedAt desc)[0...20]{ _id, title, "slug": slug.current, publishedAt, excerpt }Call it with client.fetch(QUERY, { category: params.category }). A product-updates feed can then go to LinkedIn and X, while a tutorials feed goes to DEV and Mastodon. Multilingual projects work the same way: whether you store languages as separate documents with a language field or with a translation plugin, filter by language and publish one feed per locale, connected to the accounts for that audience. Topic routing is explained in category filtering for auto-posting.
/rss.xml in a private window and check that the newest post is first.application/rss+xml and the status is 200.GROQ functions, perspectives, the image pipeline and webhooks are documented in the Sanity documentation.
PostRSS has been publishing RSS and Atom feeds to social networks since 2014. Paste the address of your feed route, connect your accounts and choose how each post is built from the item title, the description or the page title. PostRSS checks the feed as often as every minute and shares each new post, with its image and link, to the channels you choose among 66 supported networks, messengers, team chats and blogs, including LinkedIn, X, Threads, Bluesky, Mastodon and Slack through webhooks. Keyword filters, UTM parameters, hashtags from categories and posting windows let you adjust each channel, and the REST API is available for your own integrations. See the PostRSS features page and the plan limits on the pricing page. More developer guides are under the Technical Guide tag.
A Sanity RSS feed is a GROQ query plus a server route in your front end. Select published posts with a slug and a past publication date, newest first, return a plain summary and the image URL, build links from your own site and use the document ID as the GUID. Render with a feed library, revalidate on a short timer or through a webhook, and validate the result. Then every post your editors publish in Sanity can reach your social channels automatically.
No. Sanity is a headless content platform that serves JSON through its API. You build the feed in your front end with a GROQ query and a server route that returns XML.
Query with the published perspective, or without a token on a public dataset. Drafts have IDs that start with drafts and are only visible to authenticated requests.
Use an editor-controlled publishedAt field. The system field _updatedAt changes on every edit and _createdAt marks creation, so neither is a reliable publication date.
Yes. Image assets are served from the Sanity CDN with absolute URLs, and you can add width, height and crop parameters to get a size that suits social previews.
Create a webhook in the Sanity project that calls a protected route in your app, and revalidate the feed path there. Reading the feed data without the API CDN avoids stale results.
What changed in the networks, what broke, and how to fix it before it costs you reach.
Izstrādājusi Internet Solutions — PostRSS komanda. Katrs produkts ietaupa laiku savā veidā.