RSS у 66 соціальних мереж: Facebook, Instagram, X, LinkedIn, Telegram та інші Блог Партнерська програма Контакти
Увійти Почати безкоштовно
Updated: 2026-10-17
Swift Vapor RSS Feed: Build It With Fluent and Auto-Post

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.

When a Vapor app needs a feed

A feed is worth adding whenever your application publishes things that people want to follow:

  • a blog, changelog or release notes page served by the same application;
  • a marketplace or catalogue where new listings appear every day;
  • an events or news section for a club, a school or a small company;
  • a status or announcement page that customers watch.

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 model

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.

The controller

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.

Escaping and dates

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: "&amp;")
        .replacingOccurrences(of: "<", with: "&lt;")
        .replacingOccurrences(of: ">", with: "&gt;")
        .replacingOccurrences(of: "\"", with: "&quot;")
}

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.

Where each field comes from

RSS elementSource in the appRule
titlepost titleEscape it and keep it under about 100 characters
linksite address plus slugAlways absolute and always the public address
guiddatabase identifierNever changes, even if the slug changes
pubDatepublication dateUTC, RFC 822 format, never in the future
descriptionsummaryPlain text or escaped HTML; first sentences matter most
enclosureimage addressAbsolute 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.

Registering the route

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.

Headers, caching and compression

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.

Absolute links and the proxy trap

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.

Test the feed before connecting it

  1. Request the feed with 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.
  2. Open the file in a browser or editor and check that the newest post is first.
  3. Run it through the W3C Feed Validation Service and fix every error. Warnings about the self link can be fixed by adding an atom:link element.
  4. Publish a test post, wait for the cache to expire and confirm that it appears.
  5. Schedule a post for the future and confirm that it does not appear until its time.

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.

Common mistakes to avoid

  • Including drafts or future posts. Filter on status and on the date in the query itself.
  • Unescaped text. One raw ampersand can invalidate the whole file.
  • Locale-dependent dates. Set the locale and the time zone explicitly.
  • Slug-based GUIDs. Renaming a post then creates a duplicate share.
  • Internal addresses in links. Use a fixed public site address.
  • Blocking the event loop. Keep the query asynchronous and do not run heavy work inside the route.

How PostRSS works with a Vapor feed

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.

Пов’язане читання

The bottom line

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.

FAQ

Does Vapor have a built-in RSS generator?

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.

Should I use Leaf to render the feed?

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.

How many items should the feed contain?

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.

How do I stop old posts from being shared again?

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.

Can I use the Vapor feed with PostRSS?

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.”}}]}

New guides, once a month

What changed in the networks, what broke, and how to fix it before it costs you reach.

We send a confirmation e-mail first. Unsubscribe any time.

Більше інструментів від нашої команди

Створено Internet Solutions — командою PostRSS. Кожен продукт заощаджує ваш час по-своєму.

ШІ-чат для сайтів Talkmio Ваш сайт відповідає відвідувачам цілодобово на основі вашого контенту та їхньою мовою. Безкоштовний план · без картки ШІ-асистент Ask Mio Чат, код, дизайн, тексти й дослідження. Mio обирає найкращу модель для кожного завдання. Безкоштовний план ШІ-автопілот для блогу й соцмереж AI Blog Autopilot ШІ пише SEO-статті на 2 000–3 000 слів і публікує кожну в 60 соцмережах. Перші 3 статті безкоштовно Перевірка стану сайту Аудит ШІ сайту SEO, швидкість, SSL, безпека та налаштування пошти в одному звіті — за пріоритетом виправлень. Перший аудит безкоштовно Глибокий SEO-аудит Аудит SEO ШІ сайту Повний SEO-обхід за 7 напрямами, включно з видимістю в ШІ-пошуку, з виправленнями за впливом. Перший аудит безкоштовно RSS і товарні фіди Створювач RSS‑каналів Створюйте RSS з будь-якої вебсторінки, а також товарні фіди для Google і Meta, що оновлюються самі. Безкоштовний план Розробка сайтів і SEO Internet Solutions Сайти, інтернет-магазини та індивідуальні системи — проєктуємо, створюємо й підтримуємо самі. З 2011 року