RSS a 66 redes sociales: Facebook, Instagram, X, LinkedIn, Telegram y más Blog Afiliados Contactos
Iniciar sesión Empezar gratis
Updated: 2026-10-23
Rocket RSS Feed in Rust: Request Guard, Responder and ETag

Short answer: To serve an RSS feed from a Rocket application, mount a GET route for /feed.xml, keep a database connection in managed state, select the newest published rows, escape every value while you write the XML, format dates in UTC with chrono, and return the document through a small custom responder that sets the application/rss+xml content type. Hash the finished body into an ETag, read the If-None-Match header with a request guard and answer a match with a 304, take the last build date from the newest item so the body stays identical between changes, test the route with curl and a validator, then give the public address to an auto-poster.

Rocket is a well-known web framework for Rust. Since version 0.5 it runs on stable Rust with async handlers, and it is valued for its type-driven design: routes, request guards and responders are ordinary types, and the compiler checks how they fit together. Services built on it, such as APIs, dashboards and content sites, often publish 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. This guide builds a complete Rocket RSS feed with a clean query, 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.

When a Rocket service needs a feed

A feed is worth adding whenever the service publishes things that people outside it want to follow:

  • articles, news or announcements stored in a database and served by the application;
  • release notes or a changelog for a product, a crate or an internal tool;
  • listings, events, offers or openings added by staff or by users;
  • any content that should also appear on social networks without being copied by hand.

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 prefer another Rust framework, the same approach is shown in the Axum guide; only the route, the state and the response types change.

Set up the project

Create a binary crate and add four dependencies to its manifest. The example uses SQLite through rusqlite with the bundled feature so that it runs anywhere, but the query and the loop stay the same with PostgreSQL or MySQL and their drivers.

[dependencies]
rocket = "0.5"
rusqlite = { version = "0.31", features = ["bundled"] }
chrono = "0.4"
sha2 = "0.10"

Keep 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 and stores it in managed state.

The complete route

The program below keeps the connection and the site address as managed state, loads the newest rows, writes the XML by hand with an escaping helper and returns it through a responder that knows about the validator. 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 UTC unix timestamps.

#[macro_use]
extern crate rocket;

use std::io::Cursor;
use std::sync::Mutex;

use chrono::{DateTime, Utc};
use rocket::http::{ContentType, Header, Status};
use rocket::request::{FromRequest, Outcome, Request};
use rocket::response::{self, Responder, Response};
use rocket::State;
use rusqlite::{params, Connection};
use sha2::{Digest, Sha256};

struct Config {
    site: String,
}

struct Post {
    id: i64,
    title: String,
    slug: String,
    summary: String,
    published: i64,
}

fn esc(text: &str) -> String {
    text.replace('&', "&")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

fn rfc822(timestamp: i64) -> String {
    DateTime::<Utc>::from_timestamp(timestamp, 0)
        .map(|moment| moment.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
        .unwrap_or_default()
}

fn load_posts(db: &Mutex<Connection>) -> rusqlite::Result<Vec<Post>> {
    let conn = db.lock().unwrap();
    let mut stmt = conn.prepare(concat!(
        "SELECT id, title, slug, summary, published_at FROM posts ",
        "WHERE status = 'published' AND published_at <= ?1 ",
        "ORDER BY published_at DESC LIMIT 20"
    ))?;
    let rows = stmt.query_map(params![Utc::now().timestamp()], |row| {
        Ok(Post {
            id: row.get(0)?,
            title: row.get(1)?,
            slug: row.get(2)?,
            summary: row.get(3)?,
            published: row.get(4)?,
        })
    })?;
    let posts: rusqlite::Result<Vec<Post>> = rows.collect();
    posts
}

fn build_feed(site: &str, posts: &[Post]) -> String {
    let newest = posts.first().map(|post| post.published).unwrap_or(0);
    let mut xml = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
    xml.push_str(r#"<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>"#);
    xml.push_str("<title>Example News</title>");
    xml.push_str(&format!("<link>{}/</link>", site));
    xml.push_str("<description>The latest posts from Example News</description>");
    xml.push_str("<language>en</language>");
    xml.push_str(&format!("<lastBuildDate>{}</lastBuildDate>", rfc822(newest)));
    xml.push_str(&format!(
        r#"<atom:link href="{}/feed.xml" rel="self" type="application/rss+xml"/>"#,
        site
    ));
    for post in posts {
        xml.push_str("<item>");
        xml.push_str(&format!("<title>{}</title>", esc(&post.title)));
        xml.push_str(&format!("<link>{}/{}/</link>", site, post.slug));
        xml.push_str(&format!(
            r#"<guid isPermaLink="false">post-{}</guid>"#,
            post.id
        ));
        xml.push_str(&format!("<pubDate>{}</pubDate>", rfc822(post.published)));
        xml.push_str(&format!("<description>{}</description>", esc(&post.summary)));
        xml.push_str("</item>");
    }
    xml.push_str("</channel></rss>");
    xml
}

struct IfNoneMatch(Option<String>);

#[rocket::async_trait]
impl<'r> FromRequest<'r> for IfNoneMatch {
    type Error = std::convert::Infallible;

    async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
        let value = req.headers().get_one("If-None-Match").map(String::from);
        Outcome::Success(IfNoneMatch(value))
    }
}

enum FeedReply {
    Fresh { etag: String, body: String },
    NotModified { etag: String },
}

impl<'r> Responder<'r, 'static> for FeedReply {
    fn respond_to(self, _req: &'r Request<'_>) -> response::Result<'static> {
        match self {
            FeedReply::Fresh { etag, body } => Response::build()
                .status(Status::Ok)
                .header(ContentType::new("application", "rss+xml"))
                .header(Header::new("ETag", etag))
                .header(Header::new("Cache-Control", "public, max-age=300"))
                .sized_body(body.len(), Cursor::new(body))
                .ok(),
            FeedReply::NotModified { etag } => Response::build()
                .status(Status::NotModified)
                .header(Header::new("ETag", etag))
                .ok(),
        }
    }
}

#[get("/feed.xml")]
fn feed(
    db: &State<Mutex<Connection>>,
    config: &State<Config>,
    if_none_match: IfNoneMatch,
) -> Result<FeedReply, Status> {
    let posts = load_posts(db).map_err(|_| Status::InternalServerError)?;
    let body = build_feed(&config.site, &posts);
    let digest = format!("{:x}", Sha256::digest(body.as_bytes()));
    let etag = format!(r#""{}""#, &digest[..16]);
    if if_none_match.0.as_deref() == Some(etag.as_str()) {
        return Ok(FeedReply::NotModified { etag });
    }
    Ok(FeedReply::Fresh { etag, body })
}

#[launch]
fn rocket() -> _ {
    let site = std::env::var("SITE_URL")
        .unwrap_or_else(|_| "https://www.example.com".to_string());
    let conn = Connection::open("data/site.db").expect("open the database");
    rocket::build()
        .manage(Mutex::new(conn))
        .manage(Config { site })
        .mount("/", routes![feed])
}

Run it with cargo run and open the route in a browser or with curl. You should see well-formed XML with at most twenty items, newest first. Rocket checks at compile time that every route argument is a valid guard and that the return type is a responder, so a mistake such as a missing state type is reported before the server starts. The query holds a lock only for the short moment it runs; if your traffic is heavy, switch to a connection pool or an async driver, and the rest of the route stays the same.

Why a custom responder

Rocket ships helpers for several content types, including a raw XML wrapper, but that wrapper labels the response as application/xml. Feed readers and auto-posters accept it, yet the proper media type for an RSS document is application/rss+xml, and some tools use it to tell a feed from a sitemap. A responder is simply a type that knows how to turn itself into a response, so a small enum with two variants is enough: one for the full document and one for the empty 304 answer. Both carry the ETag, because a 304 must repeat the validator.

How the ETag logic works

Rocket does not add validators on its own, so the route builds one. It hashes the finished body, keeps the first sixteen hexadecimal characters, wraps them in quotation marks as the HTTP specification requires and compares the result with the If-None-Match header read by the request guard. A match means the client already holds exactly these bytes, so the route returns the empty variant. A poll that finds nothing new then costs one query and a few hundred bytes of headers.

The hash is computed from the body, which brings one rule: the body must not change unless the content changes. That is why the example takes the last build date from the newest item and not from the clock. If you wrote the current time there, the hash would change on every request, the ETag would never match and the 304 answer would never be sent. The same rule applies to any generator that stamps the current time into its output, in any language. The background to this technique is in the guide to ETags and conditional GET.

Where each field comes from

Feed fieldSource in the exampleRule to keep
titleThe title columnPlain text, escaped by the helper
linkSite address plus slugAbsolute address that returns 200 directly
guidThe row id with a prefixNever changes, even if the slug changes
pubDateThe publication timestamp in UTCRFC 822 layout with GMT, never in the future
descriptionThe summary columnOne or two sentences that make sense alone
lastBuildDateThe newest publication timestampChanges 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.

Dates in the right format

RSS dates follow RFC 822, with English abbreviations for days and months. The format string in the example produces exactly that shape, with the literal text GMT as the zone, which every parser accepts. The timestamp is converted to a UTC value first, so the output never depends on the time zone of the server. 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.

Escaping and the case for a library

The helper in the example escapes the three characters that can break an element, which is enough for text placed between tags. It is not enough for attribute values or for markup you want to keep, so never pass untrusted text into an attribute without extending it. If your feed grows to carry categories, enclosures and HTML content, move to a crate that builds the document from typed values and escapes each part itself; the structure of the route stays the same. Keep the HTML question separate: markup inside a description needs a CDATA section, explained in the CDATA guide.

Caching without delaying new posts

Auto-posters, readers and search engines request a feed often, and almost always nothing has changed. The Cache-Control header in the example allows shared caches to keep the response for five minutes, and the ETag lets everything else end in a 304. Choose the period with care: a long one delays every new post by the same amount, because caches keep serving the old list until it expires. A few minutes is a good balance for most sites. If the table grows large, add an index on the status and publication columns so that the query stays fast. More on this trade-off is in the guide to feed caching.

Running Rocket in production

Build the binary with cargo build and the release flag, and set the address and port through the ROCKET_ADDRESS and ROCKET_PORT environment variables or a Rocket.toml file. 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.

Test the feed before you connect it

  1. Request the route with curl and look at the headers: the status should be 200, the content type should be application/rss+xml and an Etag header should be present.
  2. Repeat the request with that value in an If-None-Match header and confirm that a 304 comes back.
  3. Paste the public address into the W3C Feed Validation Service and fix every error.
  4. Insert a test row and check that it appears at the top of the feed on the next request.
  5. Set a publication time in the future and check that the item stays hidden until then.

There is a longer checklist in the feed validation guide and a short curl routine in the guide to debugging a feed with curl.

Common mistakes to avoid

  • Letting the build date follow the clock. Derive it from the newest item so that the ETag can match.
  • Future items. Filter by publication time so scheduled content stays hidden until its moment.
  • Unstable GUIDs. Use an identifier that never changes.
  • Unescaped text. A single ampersand in a title makes the whole document invalid.
  • Addresses taken from the request. Build links from the configured public address.
  • Changing the feed address. Keep the route, or redirect the old one permanently.

How PostRSS works with a Rocket feed

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.

Lectura relacionada

The bottom line

Rocket needs only a route, managed state and a small responder to serve a dependable RSS feed: select published items dated in the past, escape every value, use a stable GUID, format dates in UTC, derive the last build date from the newest item and send the result with the right content type. Add a hash-based ETag and a request guard for If-None-Match so repeat requests end in a 304. 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.

FAQ

Does Rocket have a built-in RSS generator?

No. Rocket has no feed support, but a document written with an escaping helper, or built with a feed crate, is returned by a route with the right content type.

Do I need to write ETag code in Rocket?

Yes, for a conditional response. Rocket does not compute validators by itself, so the route hashes the body, sets the ETag header and returns 304 when a request guard reports a matching If-None-Match value.

Why does my feed never return 304?

Most often the body changes between requests, for example because the last build date is the current time. Take that date from the newest item and the hash stays the same until the content changes.

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.

Can I use this feed with PostRSS?

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.

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.

Más herramientas de nuestro equipo

Creadas por Internet Solutions, el equipo detrás de PostRSS. Cada una te ahorra tiempo a su manera.