
Short answer: To serve an RSS feed from a Play Framework application written in Scala, add a route for /feed.xml, create a controller action that loads the newest published items, build the document with Scala XML literals so every embedded value is escaped, format dates with the RFC 1123 formatter in UTC, and return the text with the application/rss+xml content type. Add an ETag and a 304 answer for repeat requests, test the route with curl and a validator, then give the public address to an auto-poster so every new item reaches your social channels.
Play is a mature web framework for the JVM that is popular in Scala projects for its asynchronous model, type-safe routes and clean separation between controllers and services. Teams use it for publishing platforms, e-commerce back ends, media sites and internal tools, and almost every one of them eventually has something that people want to follow: articles, release notes, offers, events or listings. A feed is the standard way to let readers, aggregators and auto-posters watch that content without an account or an API key. Scala has an unusual advantage here, because XML literals are part of the language, which makes a hand-built feed short and safe. This guide builds a complete Play RSS feed, explains each decision and ends with the checks that keep the feed dependable once other systems rely on it.
A feed is worth adding whenever the application publishes things that people outside it want to follow:
If your content already lives in a blog or a CMS, use the feed it provides instead; see how to find your WordPress feed link. If you work on the JVM with other frameworks, the same idea is covered for Spring Boot and Ktor; only the controller style changes.
The route is a single line in the conf/routes file, which points the public address at a controller method. Keep the address short and permanent, because auto-posters and readers store it.
GET /feed.xml controllers.FeedController.feedScala XML lives in a separate library in recent Scala versions, so add it to build.sbt if it is not already on your class path. Also store the public address of the site in the configuration file under a key such as site.url. Behind a reverse proxy, the request may show an internal host name or a plain http scheme, and a feed full of internal addresses is useless to every reader.
libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "2.3.0"The controller below receives a repository that returns the newest published posts, builds the XML, computes an ETag and answers. The condition on the publication time belongs inside the repository query: without it, an item scheduled for tomorrow would appear in the feed today, and an auto-poster would share it before it exists on the website. The example uses the javax namespace of Play 2.9; in Play 3 the injection annotations come from jakarta.inject instead.
package controllers
import java.security.MessageDigest
import java.time.{Instant, ZoneOffset, ZonedDateTime}
import java.time.format.DateTimeFormatter
import javax.inject._
import play.api.Configuration
import play.api.mvc._
import scala.concurrent.{ExecutionContext, Future}
import scala.xml.Elem
case class Post(id: Long, title: String, slug: String, summary: String, publishedAt: Instant)
trait PostRepository {
def latestPublished(limit: Int, now: Instant): Future[Seq[Post]]
}
@Singleton
class FeedController @Inject()(
cc: ControllerComponents,
config: Configuration,
posts: PostRepository
)(implicit ec: ExecutionContext) extends AbstractController(cc) {
private val siteUrl = config.get[String]("site.url")
private def rfc822(at: Instant): String =
DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.ofInstant(at, ZoneOffset.UTC))
private def item(p: Post): Elem =
<item>
<title>{p.title}</title>
<link>{s"$siteUrl/${p.slug}/"}</link>
<guid isPermaLink="false">{s"post-${p.id}"}</guid>
<pubDate>{rfc822(p.publishedAt)}</pubDate>
<description>{p.summary}</description>
</item>
def feed: Action[AnyContent] = Action.async { request =>
posts.latestPublished(20, Instant.now()).map { items =>
val newest = items.headOption.map(_.publishedAt).getOrElse(Instant.EPOCH)
val xml =
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Example News</title>
<link>{s"$siteUrl/"}</link>
<description>The latest posts from Example News</description>
<language>en</language>
<lastBuildDate>{rfc822(newest)}</lastBuildDate>
<atom:link href={s"$siteUrl/feed.xml"} rel="self" type="application/rss+xml"/>
{items.map(item)}
</channel>
</rss>
val body = """<?xml version="1.0" encoding="UTF-8"?>""" + "\n" + xml.toString
val digest = MessageDigest.getInstance("SHA-256").digest(body.getBytes("UTF-8"))
val etag = "\"" + digest.take(16).map("%02x".format(_)).mkString + "\""
if (request.headers.get("If-None-Match").contains(etag))
NotModified.withHeaders("ETag" -> etag)
else
Ok(body).as("application/rss+xml; charset=utf-8")
.withHeaders("ETag" -> etag, "Cache-Control" -> "public, max-age=300")
}
}
}Start the application with sbt run and open the route in a browser or with curl. You should see well-formed XML with at most twenty items, newest first. The repository itself can use Slick, Anorm, Doobie or plain JDBC; the controller does not care, as long as it receives a sequence of posts.
When you embed a value in curly braces inside an XML literal, Scala treats it as text, and the serializer converts an ampersand, an angle bracket or a quote to the proper entity when the document is written. That is the main reason to prefer literals over joining strings by hand: a title such as Tom & Jerry cannot break the feed. The structure is checked by the compiler as well, so a missing closing tag is a compile error and not a bug in production. Keep descriptions as plain text. If you want to put HTML inside the description, embed it as text so that it is escaped, or read the CDATA guide for the alternative.
| Feed field | Source in the example | Rule to keep |
|---|---|---|
| title | The title of the post | Plain text, escaped by the literal |
| link | Site address plus slug | Absolute address that returns 200 directly |
| guid | The id with a prefix | Never changes, even if the slug changes |
| pubDate | The publication instant in UTC | RFC 822 style, never in the future |
| description | The summary of the post | One or two sentences that make sense alone |
| lastBuildDate | The newest publication instant | Changes only when the content changes |
The identifier is the most important decision. An auto-poster remembers which items it has already shared by their GUID. If the value changes when someone edits a slug or republishes an article, the same story is shared again. Using the database id with a fixed prefix, and marking it as not a permanent link, avoids that. The problem and its fixes are described in why a feed auto-posts the same article twice.
RSS dates follow RFC 822, with English abbreviations for days and months. The RFC_1123_DATE_TIME formatter in java.time prints that shape, and because the instant is converted to UTC first, the zone is written as GMT, which every parser accepts. The formatter never uses the machine locale for month names, so a server with a different language setting cannot produce dates that readers cannot parse. Do not use ISO 8601 in an RSS document; that format belongs to Atom feeds, and many RSS readers reject it. If a validator complains about dates, read the guide to RSS date format errors.
Auto-posters, readers and search engines request a feed often, and almost always nothing has changed. The controller handles that with two small pieces. The ETag header carries a hash of the body, and a request whose If-None-Match header matches it receives a 304 answer with no body. The Cache-Control header allows shared caches to keep the response for five minutes. Note the last build date: it is taken from the newest item and not from the clock, so the body stays identical while the content is identical and the hash can match. If you used the current time there, the ETag would change on every request and the 304 answer would never be sent. The background to both techniques is in the guide to ETags and conditional GET and the guide to feed caching.
Choose the cache period with care. A long period delays every new post by the same amount, because shared caches keep serving the old list until it expires. A few minutes is a good balance for most sites. If traffic is high, you can also keep the rendered body in the Play cache API for a short time, so the database is queried once per period rather than once per request.
In production, build a distribution with sbt, run it under a process manager and put a reverse proxy in front of it that terminates HTTPS. Keep the public address in the configuration rather than trusting forwarded headers, so that links in the feed are always correct. Check that the proxy does not strip the ETag and Cache-Control headers and that it does not add its own long cache that keeps old XML for too long. After every deployment, request the feed once to confirm that it still returns 200 and that the address is exactly the one you gave to the auto-poster. If the feed ever stops updating, this checklist shows what to inspect first.
An auto-poster is more useful when each item carries a picture. Add an enclosure element to the item literal with the address, the length in bytes and the type of the image as attributes, and fill it from an image field of the post. Use an absolute https address and a file of reasonable size. Without an image, many networks show a plain text card. How tools read these elements is explained in the guide to enclosure tags.
There is a longer checklist in the feed validation guide and a short curl routine in the guide to debugging a feed with curl.
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 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.
A Play application needs only a route, a controller action and an XML literal to serve a dependable RSS feed: select published items dated in the past, let the literal escape every value, use a stable GUID, format dates in UTC with the RFC 1123 formatter, derive the last build date from the newest item and send the result with the right content type, an ETag and a short public cache. Test the route with curl and a validator, keep the address unchanged and connect it to an auto-poster. After that, every item you publish can reach your social channels automatically.
No. Play has no feed support, but Scala XML literals produce a valid RSS document with escaped values in a few lines, and the controller returns it with the right content type.
Not for a simple feed. Scala XML literals are enough, and in recent Scala versions the scala-xml module is a separate dependency that you add to the build. A feed library such as Rome also works if you prefer an object model.
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 identifier and use it as the GUID. Avoid editing the publication date of items that are already public, because some tools treat a changed date as a new item.
Yes. PostRSS reads any valid RSS or Atom address that is reachable without a login. Paste the address, connect your channels and new items are shared as soon as they appear.
What changed in the networks, what broke, and how to fix it before it costs you reach.
Built by Internet Solutions, the team behind PostRSS. Each product saves you time in its own way.