
Short answer: To serve an RSS feed from an Actix Web application, add a GET handler for /feed.xml, read the newest published rows with sqlx, build the document with the rss crate so every value is escaped, format dates in RFC 2822 with chrono in UTC and return an HttpResponse with the application/rss+xml content type. Compute an ETag from the newest publication time and answer a matching If-None-Match header with a 304, test the address with curl and a validator, then give the public URL to an auto-poster so every new item reaches your social channels.
Actix Web is one of the most widely used web frameworks for Rust. It is fast, mature and runs on the Tokio runtime, and it is a common choice for APIs, dashboards and content sites that want a single small binary with predictable memory use. Sooner or later such an application publishes something that people want to follow: articles, release notes, listings or event dates. 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 Actix Web RSS feed with escaping, stable identifiers, correct dates and conditional requests, 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 use Axum rather than Actix Web, see the Rust guide with Axum and the rss crate; the document building is identical and only the handler and the extractors differ.
Create a binary crate and add Actix Web, the rss crate for the document, chrono for dates and sqlx with the SQLite feature for the query. The example uses SQLite, but the query and the document builder stay the same with PostgreSQL.
cargo new feedapp && cd feedapp
cargo add actix-web rss chrono
cargo add sqlx --features runtime-tokio,sqlite
cargo add tokio --features macros,rt-multi-threadKeep the public address of the site in configuration rather than reading it from the incoming request. 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. The example reads the address from an environment variable with a safe default.
The handler below queries the published rows, builds the channel with the rss crate, serialises it and returns the response. The condition on the publication time matters: 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. Publication times are stored as epoch seconds in UTC.
use actix_web::{get, web, App, HttpRequest, HttpResponse, HttpServer};
use chrono::{DateTime, Utc};
use rss::{ChannelBuilder, GuidBuilder, ItemBuilder};
use sqlx::sqlite::SqlitePool;
struct AppState {
db: SqlitePool,
site_url: String,
}
#[derive(sqlx::FromRow)]
struct Post {
id: i64,
title: String,
slug: String,
summary: String,
published_at: i64,
}
#[get("/feed.xml")]
async fn feed(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let now = Utc::now().timestamp();
let posts: Vec<Post> = match sqlx::query_as(
"SELECT id, title, slug, summary, published_at FROM posts
WHERE status = 'published' AND published_at <= ?
ORDER BY published_at DESC LIMIT 20",
)
.bind(now)
.fetch_all(&state.db)
.await
{
Ok(rows) => rows,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
let newest = posts.first().map(|p| p.published_at).unwrap_or(0);
let etag = format!("{}{}-{}{}", '"', newest, posts.len(), '"');
if let Some(v) = req.headers().get("If-None-Match") {
if v.to_str().ok() == Some(etag.as_str()) {
return HttpResponse::NotModified()
.insert_header(("ETag", etag))
.finish();
}
}
let items: Vec<rss::Item> = posts
.iter()
.map(|p| {
let published = DateTime::<Utc>::from_timestamp(p.published_at, 0)
.unwrap_or_else(Utc::now);
ItemBuilder::default()
.title(Some(p.title.clone()))
.link(Some(format!("{}/{}/", state.site_url, p.slug)))
.guid(Some(
GuidBuilder::default()
.value(format!("post-{}", p.id))
.permalink(false)
.build(),
))
.pub_date(Some(published.to_rfc2822()))
.description(Some(p.summary.clone()))
.build()
})
.collect();
let channel = ChannelBuilder::default()
.title("Example News")
.link(format!("{}/", state.site_url))
.description("The latest posts from Example News")
.language(Some("en".to_string()))
.items(items)
.build();
HttpResponse::Ok()
.content_type("application/rss+xml; charset=utf-8")
.insert_header(("ETag", etag))
.insert_header(("Cache-Control", "public, max-age=300"))
.body(channel.to_string())
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let site_url = std::env::var("SITE_URL").unwrap_or("https://www.example.com".to_string());
let db = SqlitePool::connect("sqlite:data/site.db").await.expect("database");
let state = web::Data::new(AppState { db, site_url });
HttpServer::new(move || App::new().app_data(state.clone()).service(feed))
.bind(("127.0.0.1", 8080))?
.run()
.await
}Run it with cargo run, open the route in a browser or with curl, and you should see well-formed XML with at most twenty items, newest first. The rss crate escapes every title and description when it writes the document, which is the main reason to use it rather than build the XML by hand. If you want the Atom self link that validators recommend, add it through the extensions of the channel or with the atom feature of the crate.
| 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 timestamp in UTC | RFC 2822 with a zero offset, 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, and RFC 2822 is its modern successor with the same shape. The to_rfc2822 method in chrono prints exactly that format with English names whatever the locale of the server, and because the value is a UTC datetime, the offset is written as zero, which every parser accepts. Do not use to_rfc3339 here; 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 handler above deals with that in two ways. It computes an ETag from the newest publication time and the number of items, sets the header on every response and, when the If-None-Match header of the request matches, finishes with a 304 and no body. The Cache-Control header lets a proxy or a content delivery network keep the response for a few minutes. Together they cut the cost of each check to one query, and you can go further by keeping the rendered string in memory for the same period.
If you edit old items and want the feed to change, store an updated time and include it in the ETag. Do not cache for hours, because a long cache delays every new post by the same amount. The background to both techniques is in the guide to ETags and conditional GET và the guide to feed caching.
In production, build a release binary, run it under a process manager and put a reverse proxy in front of it that terminates HTTPS. Keep the public address in your own setting 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 with the EnclosureBuilder of the rss crate, giving the address, the byte length and the type of the image from an image column, and 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 a summary legitimately contains HTML, keep it escaped as the crate does, or use the content field for the full HTML as described in the CDATA guide; readers handle both.
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 Actix Web documentation covers handlers, state and responses in more depth.
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.
Actix Web needs only a handler, a query and the rss crate to serve a dependable RSS feed: select published items dated in the past, let the crate escape every value, use a stable GUID, format dates in RFC 2822 UTC with chrono, build links from a configured public address and return the result 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. Actix Web has no feed support, but the rss crate produces a valid feed from a few builders, and a few lines in the handler add the ETag and cache headers that make the route cheap to poll.
The crate escapes every text value when it writes the document, so a title with an ampersand or an angle bracket cannot break the XML. A template works too, but you must remember to escape every value yourself.
Yes. The atom_syndication crate produces Atom feeds with the same approach, and auto-posters read both formats. Pick one and keep its address stable.
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.
Do Internet Solutions, đội ngũ đứng sau PostRSS, phát triển. Mỗi sản phẩm giúp bạn tiết kiệm thời gian theo một cách riêng.