
Short answer: To serve an RSS feed from a Micronaut application, add a controller with a GET route for /feed.xml, read the newest published items through a Micronaut Data repository, build the RSS 2.0 XML with an escape helper, format dates with the RFC 1123 formatter in UTC, and return the text with the content type application/rss+xml. Add an ETag and a short Cache-Control header so repeat requests get a cheap 304 answer, test the address with curl and a validator, and then give the public URL to an auto-poster so every new item reaches your social channels.
Micronaut is a JVM framework known for fast start-up and low memory use, which makes it a popular choice for small services and serverless functions. Many of those services publish something people want to follow: articles, release notes, listings, event dates or product updates. A feed is the standard way to let readers, aggregators and auto-posters watch that content without an account or an API key. This guide builds a complete Micronaut RSS feed with caching and conditional requests, explains each decision and ends with the checks that keep the feed dependable once other systems depend on it.
A feed is worth adding whenever the service publishes things that people outside the application 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. For the same job in another JVM framework, see the Spring Boot guide.
Create a new application with Micronaut Launch or with the Micronaut command line tool. Choose Java and Gradle, then add the Data JDBC feature, a connection pool and the driver for your database. The example below assumes a table named posts with a numeric id, a title, a slug, a short summary, a status and a publication time.
mn create-app com.example.feed --build=gradle --lang=java
cd feed
# add the Data JDBC feature, a JDBC pool and your database driver in build.gradle,
# then describe the connection in src/main/resources/application.ymlKeep the public address of the site in configuration rather than reading it from the incoming request. Behind a proxy, the request may show an internal host name, and a feed full of internal addresses is useless to every reader.
feed:
site-url: https://www.example.com
title: Example News
description: The latest articles from Example NewsMicronaut Data turns method names into queries at compile time, so a repository interface is all you need to ask for the newest published items. The method name below means: take the first twenty rows with the given status and a publication time that is not in the future, newest first.
package com.example.feed;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
import java.time.Instant;
import java.util.List;
@MappedEntity("posts")
public record Post(@Id Long id, String title, String slug,
String summary, String status, Instant publishedAt) { }
@JdbcRepository(dialect = Dialect.POSTGRES)
public interface PostRepository extends CrudRepository<Post, Long> {
List<Post> findTop20ByStatusAndPublishedAtLessThanEqualsOrderByPublishedAtDesc(
String status, Instant now);
}The condition on the publication time matters. Without it, a post scheduled for tomorrow would appear in the feed today, and an auto-poster would share it before it exists on the website.
The controller builds the XML, adds an ETag taken from the content and answers conditional requests. The escape helper handles the five characters that are special in XML. Never insert a title or a summary into the feed without it.
package com.example.feed;
import io.micronaut.context.annotation.Value;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Header;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
@Controller
public class FeedController {
private final PostRepository posts;
private final String siteUrl;
private final String title;
private final String description;
public FeedController(PostRepository posts,
@Value("${feed.site-url}") String siteUrl,
@Value("${feed.title}") String title,
@Value("${feed.description}") String description) {
this.posts = posts;
this.siteUrl = siteUrl;
this.title = title;
this.description = description;
}
@Get(uri = "/feed.xml", produces = "application/rss+xml")
public HttpResponse<String> feed(@Header(HttpHeaders.IF_NONE_MATCH) Optional<String> ifNoneMatch)
throws Exception {
List<Post> items = posts
.findTop20ByStatusAndPublishedAtLessThanEqualsOrderByPublishedAtDesc(
"published", Instant.now());
String xml = build(items);
String etag = "\"" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(xml.getBytes(StandardCharsets.UTF_8)), 0, 8) + "\"";
if (ifNoneMatch.filter(etag::equals).isPresent()) {
return HttpResponse.notModified().header(HttpHeaders.ETAG, etag);
}
return HttpResponse.ok(xml)
.contentType(MediaType.of("application/rss+xml;charset=UTF-8"))
.header(HttpHeaders.ETAG, etag)
.header(HttpHeaders.CACHE_CONTROL, "public, max-age=300");
}
private String build(List<Post> items) {
StringBuilder sb = new StringBuilder();
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
.append("<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n<channel>\n")
.append("<title>").append(esc(title)).append("</title>\n")
.append("<link>").append(esc(siteUrl)).append("/</link>\n")
.append("<description>").append(esc(description)).append("</description>\n")
.append("<language>en</language>\n");
for (Post p : items) {
String url = siteUrl + "/" + p.slug() + "/";
sb.append("<item>\n")
.append("<title>").append(esc(p.title())).append("</title>\n")
.append("<link>").append(esc(url)).append("</link>\n")
.append("<guid isPermaLink=\"false\">post-").append(p.id()).append("</guid>\n")
.append("<pubDate>").append(rfc822(p.publishedAt())).append("</pubDate>\n")
.append("<description>").append(esc(p.summary())).append("</description>\n")
.append("</item>\n");
}
return sb.append("</channel>\n</rss>\n").toString();
}
private static String rfc822(Instant t) {
return DateTimeFormatter.RFC_1123_DATE_TIME.format(t.atOffset(ZoneOffset.UTC));
}
private static String esc(String s) {
return s == null ? "" : s.replace("&", "&").replace("<", "<")
.replace(">", ">").replace("\"", """).replace("'", "'");
}
}Run the application and open the route in a browser or with curl. You should see well-formed XML with at most twenty items, newest first.
| Feed field | Source in the example | Rule to keep |
|---|---|---|
| title | The title column | Plain text, escaped, no markup |
| link | Site address plus slug | Absolute address that returns 200 directly |
| guid | The row id with a prefix | Never changes, even if the slug changes |
| pubDate | The publication time in UTC | English day and month names, never in the future |
| description | The summary column | One or two sentences that make sense alone |
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 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 formatter in java.time always produces English names, regardless of the language of the server, and printing the time at the UTC offset ends it with GMT. Do not use a formatter that follows the default locale; a server with a different language setting would print month names that readers cannot parse. If you see date warnings in a validator, 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 above handles that in two ways. It sends an ETag that is a short hash of the finished XML, and when the client returns the same value in the If-None-Match header, the answer is a 304 with no body. It also sends a Cache-Control header that lets a proxy or a content delivery network keep the response for a few minutes. Together they cut the cost of each check to a query and a hash.
If the query is heavy or the traffic is high, keep the finished XML in a small in-memory holder and rebuild it only when a post is published. The background to both techniques is in the guide to ETags and conditional GET oraz the guide to feed caching. Do not cache for hours, because a long cache delays every new post by the same amount.
The route is declared with the type application/rss+xml, and the response states UTF-8. Readers and validators accept text/xml as well, but a plain text type or an HTML type can make a browser show the markup or trigger a download. Make sure the XML declaration, the header and the actual bytes agree. A mismatch usually shows up as broken accented letters in titles. More detail is in the guide to the RSS content type header.
An auto-poster is more useful when each item carries a picture. Add an enclosure element with the address, the byte length and the type of the image, or a media content element if you prefer that namespace. 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. If the summary contains HTML, wrap it in a CDATA block or escape it, as described in the CDATA guide.
There is a longer checklist in the feed validation guide and a short curl routine in the guide to debugging a feed with curl.
The three frameworks solve the same problem in a similar way, and the feed itself is plain XML in all of them. Micronaut does its dependency injection and query generation at compile time, which keeps start-up quick and memory small, and that suits a service that runs briefly in a container or a function. Spring Boot has the largest library of examples and a ready-made feed view class, while Quarkus is close to Micronaut in speed and offers a different programming model. Choose the framework your team already knows. A feed route is small enough that the choice makes almost no difference to readers or to auto-posters.
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.
Micronaut needs only a repository method, a controller and a small escape helper to serve a dependable RSS feed: select published items dated in the past, escape every value, use a stable GUID, format dates in English UTC, build links from a configured public address and return the XML with the right content type, an ETag and a short 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. Micronaut has no feed support in its core. You build the XML yourself or use a library such as ROME, and the controller returns the text with the right content type.
Auto-posters and readers check feeds very often. When the ETag has not changed, they receive a 304 answer without a body, which saves bandwidth and processing on both sides.
Yes. ROME builds RSS and Atom from Java objects and handles escaping for you. The rules stay the same: only published items, newest first, a limit of about twenty and a stable GUID for each item.
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.
Tworzy je Internet Solutions, zespół stojący za PostRSS. Każdy produkt oszczędza czas na swój sposób.