
Short answer: Ktor has no feed plugin, but a Ktor RSS feed is one route. Load recent published posts, write the XML with the JDK’s built-in XMLStreamWriter (or a library such as Rome) so all text is escaped, format dates with DateTimeFormatter.RFC_1123_DATE_TIME, and answer with call.respondText(xml, ContentType.Application.Rss). Use a configured base URL for absolute links, add caching and compression plugins, and the feed is ready for readers and automatic social posting.
Ktor is JetBrains’ Kotlin framework for servers and is used for APIs, content back ends, internal tools and an increasing number of websites. Kotlin teams often start with JSON endpoints and only later notice that nothing announces new content to the outside world. An RSS feed fills that gap with very little code. Readers, aggregators, team chat integrations and social auto-posting services all understand it, so one endpoint replaces many custom integrations. This guide builds a clean feed route, covers the details that typically break feeds in Ktor projects, and shows how to use the feed for social sharing.
A feed that serves readers and automation tools well:
guid;For a reminder of what each element does, see what guid and pubDate actually do.
There are three reasonable options on the JVM:
XMLStreamWriter (StAX). No extra dependency, escapes all text automatically and is fast. It is a little verbose, but a small helper function solves that.This guide uses StAX because it is part of the JDK and escapes everything you write as text. If you already use Rome elsewhere, it is an equally good choice. Avoid building XML with Kotlin string templates: a single title containing an ampersand or a less-than sign makes the whole document invalid, and the bug appears only when an editor happens to type that character.
Whatever data layer you use, such as Exposed, jOOQ, JDBC or a repository over another service, the query should return only published posts whose publication time has passed. The second condition matters when posts are scheduled: otherwise a post meant for next week appears in the feed today.
data class Post(
val slug: String,
val title: String,
val summary: String,
val publishedAt: Instant,
)
interface PostRepository {
suspend fun latestPublished(limit: Int): List<Post> // published and publishedAt <= now, newest first
}
Store publication times as Instant or UTC timestamps. Mixing local times and UTC is one of the most common reasons why feed items show up hours early or late.
A small helper writes the channel and items with XMLStreamWriter:
import java.io.StringWriter
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import javax.xml.stream.XMLOutputFactory
import javax.xml.stream.XMLStreamWriter
private fun XMLStreamWriter.el(name: String, text: String) {
writeStartElement(name); writeCharacters(text); writeEndElement()
}
private fun rfc822(i: Instant): String =
DateTimeFormatter.RFC_1123_DATE_TIME.format(i.atZone(ZoneOffset.UTC))
fun buildRss(posts: List<Post>, baseUrl: String): String {
val out = StringWriter()
val w = XMLOutputFactory.newInstance().createXMLStreamWriter(out)
w.writeStartDocument("UTF-8", "1.0")
w.writeStartElement("rss"); w.writeAttribute("version", "2.0")
w.writeStartElement("channel")
w.el("title", "Example Blog")
w.el("link", "$baseUrl/")
w.el("description", "New articles from Example Blog")
w.el("language", "en")
for (p in posts) {
val url = "$baseUrl/blog/${p.slug}"
w.writeStartElement("item")
w.el("title", p.title)
w.el("link", url)
w.writeStartElement("guid"); w.writeAttribute("isPermaLink", "true")
w.writeCharacters(url); w.writeEndElement()
w.el("pubDate", rfc822(p.publishedAt))
w.el("description", p.summary)
w.writeEndElement() // item
}
w.writeEndElement(); w.writeEndElement(); w.writeEndDocument(); w.close()
return out.toString()
}
RFC_1123_DATE_TIME produces dates such as Sat, 10 Oct 2026 10:45:00 GMT. That matches the RFC 822 style RSS requires, and the formatter uses fixed English day and month names, so it is not affected by the server’s locale. Avoid ISO 8601 strings in pubDate; they belong in Atom, not RSS. Our article on pubDate errors lists more pitfalls.
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.feedRoutes(repo: PostRepository, baseUrl: String) {
routing {
get("/feed.xml") {
val xml = buildRss(repo.latestPublished(30), baseUrl)
call.response.header(HttpHeaders.CacheControl, "public, max-age=300")
call.respondText(xml, ContentType.Application.Rss.withCharset(Charsets.UTF_8))
}
}
}
ContentType.Application.Rss is Ktor’s constant for application/rss+xml. Setting the charset explicitly avoids readers guessing the encoding. If your application uses the ContentNegotiation plugin, respondText bypasses JSON serialisation, so the XML reaches the client unchanged.
The route uses a baseUrl passed in from configuration, for example a custom key in application.conf or an environment variable, instead of building URLs from the request. That is deliberate. A Ktor service usually runs behind Nginx, a load balancer or a Kubernetes ingress, and the request it receives may show an internal host, port 8080 or plain HTTP.
If you prefer request-based URLs, install the XForwardedHeaders arba ForwardedHeaders plugin so call.request.origin reflects the public scheme and host, and make sure the proxy sends those headers. Either way, check the live feed once: the first item link should open the page exactly as visitors see it. Relative or internal links are a classic reason why auto-posted links fail; see relative URLs in RSS feeds.
When the feed drives social posts, the image decides how each post looks. Add an enclosure for items with a cover image:
w.writeEmptyElement("enclosure")
w.writeAttribute("url", p.coverUrl)
w.writeAttribute("type", "image/jpeg")
w.writeAttribute("length", p.coverBytes.toString())
Many auto-posters also read the article page’s og:image tag, so set it in your HTML templates. Categories become <category> elements, one per tag, which helps route topics to different accounts and turn tags into hashtags. For full HTML content, declare the content namespace and write content:encoded with writeCData, keeping description short and plain. See how to control which image gets auto-posted.
Feeds are polled frequently, so make repeated requests cheap. Ktor has plugins for each part:
CachingHeaders plugin can set it centrally by content type.ConditionalHeaders plugin answers 304 Not Modified when the client’s If-None-Match arba If-Modified-Since matches the version you provide, such as an ETag built from the newest post’s slug and timestamp.install(Compression) { gzip() } compresses the XML for clients that ask for it. Feeds shrink dramatically.Do not cache the feed for hours. Long caches, especially at a CDN, are a frequent cause of delayed auto-posts; see RSS feed caching explained.
Add an autodiscovery link to the head of your HTML pages, so browsers, readers and tools can find the feed:
<link rel="alternate" type="application/rss+xml"
title="Example Blog" href="https://example.com/feed.xml">
For topic feeds, add get("/category/{slug}/feed.xml"), read the parameter with call.parameters["slug"], reuse the same builder with a filtered query, and respond with HttpStatusCode.NotFound for unknown categories so mistakes are noticed immediately.
Ktor’s testApplication runs the module in memory:
@Test
fun feedIsRss() = testApplication {
application { feedRoutes(FakeRepo(), "https://example.com") }
val res = client.get("/feed.xml")
assertEquals(HttpStatusCode.OK, res.status)
assertTrue(res.contentType()!!.match(ContentType.Application.Rss))
assertTrue(res.bodyAsText().contains("<link>https://example.com/blog/"))
}
Then validate the deployed feed with the W3C Feed Validation Service. The Ktor documentation on sending responses covers respondText and content types in more detail.
| Symptom | Cause | Fix |
|---|---|---|
| Feed served as text/plain | respondText without a content type | Pass ContentType.Application.Rss |
| XML breaks on one title | String templates without escaping | StAX or Rome |
| Links show port 8080 or http | URLs built from the internal request | Configured base URL or forwarded headers plugin |
| Dates rejected or localised | ISO strings or a locale-dependent pattern | RFC_1123_DATE_TIME in UTC |
| Scheduled posts appear early | Query ignores publication time | Filter on publishedAt up to now |
| Whole archive reshared | Changed URL scheme or domain, so guids changed | Keep guids permanent |
The guid issue is the most expensive one, because it happens after a seemingly harmless change such as moving the blog to a new path. Read duplicate guid fixes before you migrate.
A feed endpoint fails quietly. Nobody opens /feed.xml in a browser every day, so a broken feed can go unnoticed for weeks while readers and auto-posters see errors or stale content. A few habits prevent that:
StatusPages plugin. Returning an empty but valid channel hides the problem and makes tools think you stopped publishing./feed.xml to your uptime checks and alert on non-200 responses or unusually small bodies.For a structured approach to troubleshooting, see RSS feed not updating: how to diagnose and fix it.
You could launch a coroutine that calls each social network’s API whenever a post is published. In practice, every network has its own OAuth flow, token refresh, rate limits and media rules, and they change often. For content, the feed is the simpler contract: publish the post, let the feed announce it, and let an auto-poster handle the networks.
PostRSS has been turning RSS and Atom feeds into social posts since 2014. You paste the feed address, connect accounts and choose the post format. PostRSS checks the feed as often as every minute and publishes each new item, with its image and link, to the networks you select among 66 supported networks, messengers, team chats and blogs, from LinkedIn and Mastodon to Telegram, Discord and Google Chat. Keyword filters, UTM parameters, hashtags from categories, posting windows, a REST API v1 and outgoing webhooks are available as well. See the PostRSS features page and the plan limits on the pricing page. More developer articles are under the Technical Guide tag.
A Ktor RSS feed is a query, a small XML builder and one route. Write the XML with StAX or Rome so it is always escaped, format dates with RFC_1123_DATE_TIME in UTC, respond with ContentType.Application.Rss, and use a configured base URL for absolute links. Add caching, conditional headers and compression, keep guids permanent and validate once. Then every new post can reach your social channels automatically.
No. Ktor does not ship a feed generator. You write a route that returns XML, built with the JDK XMLStreamWriter or a library such as Rome, and respond with the RSS content type.
Use ContentType.Application.Rss, which is application/rss+xml, with the UTF-8 charset. It tells browsers, readers and auto-posters exactly what the response contains.
Convert the Instant to UTC and format it with DateTimeFormatter.RFC_1123_DATE_TIME. The result matches the RFC 822 style that RSS expects and does not depend on the server locale.
Both work well. StAX needs no dependency and is enough for a standard RSS feed. Rome is convenient if you also need Atom or feed modules such as media extensions.
Yes. Once the feed is public and valid, an RSS auto-poster such as PostRSS can read it and share each new post with the networks you connect, without extra code in your Ktor application.
What changed in the networks, what broke, and how to fix it before it costs you reach.
Sukūrė Internet Solutions – PostRSS komanda. Kiekvienas produktas sutaupo laiko savaip.