
Short answer: To publish an RSS feed from a Vapor application, add a route that queries the newest published rows with Fluent, builds RSS 2.0 XML with escaped text, absolute links and RFC 822 dates, and returns it with the content type application/rss+xml. Register the controller, add a short cache lifetime, check the result with curl and a validator, and then give the public address to an auto-poster so every new item reaches your social channels without manual work.
Swift on the server is a pleasant way to build APIs, and many small products, newsletters and company sites now run on Vapor. What they often lack is a feed. Vapor does not ship an RSS generator, and the usual advice for other frameworks does not apply directly. The good news is that an RSS file is only text, and Swift handles text well. This guide builds a complete feed endpoint with Vapor 4 and Fluent, explains each design choice, and finishes with the steps that make the feed dependable in production and useful for social media automation.
A feed is worth adding whenever your application publishes things that people want to follow:
Without a feed, every announcement has to be copied to social networks by hand, and people who like feed readers cannot follow you at all. A feed also makes your content easy to reuse: readers, aggregators and auto-posters all understand the same format. If you want a refresher on the structure of the file, read RSS feed XML anatomy before you continue.
The example assumes a Fluent model named Post with a title, a slug, a summary, a status, a publication date and an optional image address. Your model will differ, but the same fields are needed in every feed.
import Fluent
import Vapor
final class Post: Model, @unchecked Sendable {
static let schema = "posts"
@ID(key: .id) var id: UUID?
@Field(key: "title") var title: String
@Field(key: "slug") var slug: String
@Field(key: "summary") var summary: String
@Field(key: "status") var status: String
@Field(key: "published_at") var publishedAt: Date
@OptionalField(key: "image_url") var imageURL: String?
init() {}
}The important rule is that the feed must contain only what is public. A draft, a scheduled post with a future date and a private item must never appear, so the query will filter on both the status and the date.
Create a file named FeedController.swift. The controller loads the newest twenty published posts, turns each one into an item and wraps them in a channel.
import Fluent
import Vapor
struct FeedController: RouteCollection {
let siteURL = "https://www.example.com"
func boot(routes: RoutesBuilder) throws {
routes.get("feed.xml", use: feed)
}
func feed(req: Request) async throws -> Response {
let posts = try await Post.query(on: req.db)
.filter(\.$status == "published")
.filter(\.$publishedAt <= Date())
.sort(\.$publishedAt, .descending)
.limit(20)
.all()
let items = posts.map { post -> String in
let link = siteURL + "/blog/" + post.slug
var xml = "<item>"
xml += "<title>" + escape(post.title) + "</title>"
xml += "<link>" + escape(link) + "</link>"
xml += "<guid isPermaLink=\"false\">post-" + (post.id?.uuidString ?? "") + "</guid>"
xml += "<pubDate>" + rfc822(post.publishedAt) + "</pubDate>"
xml += "<description>" + escape(post.summary) + "</description>"
if let image = post.imageURL {
xml += "<enclosure url=\"" + escape(image) + "\" type=\"image/jpeg\" length=\"0\"/>"
}
xml += "</item>"
return xml
}
let body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<rss version=\"2.0\"><channel>"
+ "<title>Example Blog</title>"
+ "<link>" + siteURL + "/blog</link>"
+ "<description>News and articles from Example</description>"
+ items.joined()
+ "</channel></rss>"
var headers = HTTPHeaders()
headers.contentType = HTTPMediaType(type: "application", subType: "rss+xml", parameters: ["charset": "utf-8"])
headers.replaceOrAdd(name: .cacheControl, value: "public, max-age=300")
return Response(status: .ok, headers: headers, body: .init(string: body))
}
}Two details deserve attention. The route name feed.xml gives the feed a stable address that looks like a file, which many tools expect. And the response is built by hand with a string body, so you control the headers exactly instead of relying on a content negotiation default.
Two small helpers carry most of the correctness. Put them in an extension or in the same file:
func escape(_ text: String) -> String {
text.replacingOccurrences(of: "&", with: "&")
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
.replacingOccurrences(of: "\"", with: """)
}
func rfc822(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
return formatter.string(from: date)
}The ampersand must be replaced first, otherwise the entities that you create are escaped a second time. A single raw ampersand in a title is the most common reason for a feed that suddenly stops validating. The date helper fixes the locale and the time zone on purpose. Without a fixed locale the month names follow the language of the server, and a feed with a month in another language is invalid. The format is explained in RSS date formats and pubDate errors. If your summaries contain HTML, escape them or wrap them in CDATA, as described in CDATA and HTML in RSS descriptions.
| RSS element | Source in the app | Rule |
|---|---|---|
| title | post title | Escape it and keep it under about 100 characters |
| link | site address plus slug | Always absolute and always the public address |
| guid | database identifier | Never changes, even if the slug changes |
| pubDate | publication date | UTC, RFC 822 format, never in the future |
| description | summary | Plain text or escaped HTML; first sentences matter most |
| enclosure | image address | Absolute address and the real file type |
The GUID deserves a special mention. Use the database identifier and not the slug, because slugs get edited. A new GUID makes an old item look new, and an auto-poster may share it again; see why your RSS feed auto-posts the same article twice.
Open routes.swift and register the controller:
func routes(_ app: Application) throws {
try app.register(collection: FeedController())
// your other routes
}Run the application and open /feed.xml. If the route returns a 404, check that the registration is inside the same function that your application calls on start-up and that no catch-all route is defined above it. In a project with a front-end router or a wildcard path, the feed route must come first.
A feed is requested often, mostly by robots. Each request runs a database query, so a short cache is a sensible protection. The simplest approach is the header already shown: a lifetime of 300 seconds lets a reverse proxy or content delivery network serve the same copy for five minutes. If you prefer to cache inside the application, Vapor has a cache interface. Store the finished XML string with an expiry of a few minutes and return it when it exists:
if let cached = try await req.cache.get("feed-xml", as: String.self) {
return makeResponse(cached)
}
let fresh = try await buildFeedXML(req)
try await req.cache.set("feed-xml", to: fresh, expiresIn: .minutes(5))
return makeResponse(fresh)Do not cache for hours. A long lifetime delays every share by the same amount; the effect is explained in RSS feed caching explained. Compression is normally handled by the proxy in front of the application; see RSS feed compression with gzip and Brotli. The correct content type is covered in the Content-Type guide.
Vapor applications usually run behind Nginx, Caddy or a load balancer. If you build links from the request, for example from the host header, the feed may contain the internal address of the container, such as an address with a private port. That is why the example uses a fixed site address. Put it in an environment variable, read it at start-up and fail loudly if it is missing. The same rule applies to image addresses: store full addresses or build them from the same setting. Broken relative links are a classic problem, described in relative URLs in RSS feeds.
curl -i https://www.example.com/feed.xml and check for a 200 status, the XML content type and the cache header. The method is explained in debugging an RSS feed with curl.If anything looks wrong later, the checklist in RSS feed not updating helps to find the cause quickly.
Plan for growth as well. If the table later holds thousands of posts, add a database index on the status and publication date columns so the feed query stays fast. A slow feed is a real problem, because auto-posters give up after a timeout and may skip a check; see RSS feed timeouts. Keep an eye on the response time of the route in your monitoring, and keep the number of items fixed instead of letting the feed grow with the archive.
PostRSS has been publishing RSS and Atom feeds to social networks since 2014. Paste the address of your feed.xml route, connect your accounts and choose how each post is built from the title, the description or the page title. PostRSS checks the feed as often as every minute and shares each new item, with its image and link, to the channels you choose among 66 supported networks, messengers, team chats and blogs, including LinkedIn, X, Bluesky, Mastodon and Telegram. Keyword filters, UTM parameters and posting windows let you tune each channel, and webhooks connect PostRSS to your own systems. See the PostRSS features page and the plan limits on the pricing page. More developer guides are under the Technical Guide tag.
Vapor does not generate feeds for you, but a feed is a small amount of code: query only published posts, escape every value, use a stable GUID, format dates in UTC with a fixed locale, build absolute links from a configured site address and return the XML with the right content type and a short cache. Test it with curl and a validator, keep the address stable and connect it to an auto-poster. After that, every post you publish can reach your social channels automatically.
No. Vapor focuses on routing, templating and database access, so you build the XML yourself or use a small package. The example in this guide needs no extra dependency.
You can, but a plain string response is simpler and gives you full control over escaping and headers. A template makes sense only if several people edit the feed layout.
Between ten and twenty of the newest published items is enough. An auto-poster only needs recent items, and a smaller file is faster to download and parse.
Give every item a stable GUID based on the database identifier and never change it. Avoid editing publication dates of old items, because some tools treat a changed date as news.
Yes. Paste the public address of the feed as a source in PostRSS, connect your social accounts and each new item is posted automatically. Open the address in a private browser window first to confirm that it needs no login.
{“@context”: “https://schema.org”, “@type”: “FAQPage”, “mainEntity”: [{“@type”: “Question”, “name”: “Does Vapor have a built-in RSS generator?”, “acceptedAnswer”: {“@type”: “Answer”, “text”: “No. Vapor focuses on routing, templating and database access, so you build the XML yourself or use a small package. The example in this guide needs no extra dependency.”}}, {“@type”: “Question”, “name”: “Should I use Leaf to render the feed?”, “acceptedAnswer”: {“@type”: “Answer”, “text”: “You can, but a plain string response is simpler and gives you full control over escaping and headers. A template makes sense only if several people edit the feed layout.”}}, {“@type”: “Question”, “name”: “How many items should the feed contain?”, “acceptedAnswer”: {“@type”: “Answer”, “text”: “Between ten and twenty of the newest published items is enough. An auto-poster only needs recent items, and a smaller file is faster to download and parse.”}}, {“@type”: “Question”, “name”: “How do I stop old posts from being shared again?”, “acceptedAnswer”: {“@type”: “Answer”, “text”: “Give every item a stable GUID based on the database identifier and never change it. Avoid editing publication dates of old items, because some tools treat a changed date as news.”}}, {“@type”: “Question”, “name”: “Can I use the Vapor feed with PostRSS?”, “acceptedAnswer”: {“@type”: “Answer”, “text”: “Yes. Paste the public address of the feed as a source in PostRSS, connect your social accounts and each new item is posted automatically. Open the address in a private browser window first to confirm that it needs no login.”}}]}
What changed in the networks, what broke, and how to fix it before it costs you reach.
Tworzy je Internet Solutions, zespół stojący za PostRSS. Każdy produkt oszczędza czas na swój sposób.