
Short answer: In a Haskell web application built with Scotty, an RSS feed is one route that returns an XML document. Keep your posts in a list or a database, render them into the channel and item elements with a small function, set the content type to the RSS media type with the raw response helper, send an ETag so that clients can ask for the file only when it has changed, and test the live address with curl and a validator. Once the address is stable, an auto-poster can read it and share every new post on your social networks.
Scotty is a small, friendly web framework for Haskell, inspired by Sinatra. It gives you routes, request headers and a response, and leaves everything else to you. That suits a feed well, because an RSS file is only a text document with a few strict rules. The danger is not the framework but the details: a date written in the wrong format, a title with a character that breaks the XML, a content type that tells browsers the file is plain text. This guide builds a complete feed route using only common libraries, explains each decision and shows how to check the result before anything depends on it.
A feed item needs a title, a permanent address, a publication date, a summary and, ideally, an image. Start with a simple record that holds those fields. In a real application the list would come from a database or from files on disk, but the shape is the same.
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import Data.List (sortOn)
import Data.Ord (Down (..))
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TLE
import Data.Time (UTCTime, defaultTimeLocale, formatTime, parseTimeOrError)
import Network.HTTP.Types (status304)
import Web.Scotty
data Post = Post
{ postSlug :: Text
, postTitle :: Text
, postSummary :: Text
, postDate :: UTCTime
}
siteUrl :: Text
siteUrl = "https://example.com"
posts :: [Post]
posts =
[ Post "spring-opening-hours" "Spring Opening Hours"
"Our new opening hours start on Monday."
(parseTimeOrError True defaultTimeLocale "%Y-%m-%d %H:%M:%S" "2026-10-29 08:00:00")
]Store dates as UTC values and nothing else. The time zone of the server then cannot change what the feed says, and the formatting step below can print the offset in the form that the RSS specification expects.
The rendering function turns the list into text. Two small helpers keep it readable: one wraps content in an element, and one protects text that may contain characters that are special in XML. Wrapping titles and summaries in a CDATA section is a simple way to do this, with one exception, the closing sequence of a CDATA section, which must be split if it ever appears in the text.
el :: Text -> Text -> Text
el name body = "<" <> name <> ">" <> body <> "</" <> name <> ">"
cdata :: Text -> Text
cdata t = "<![CDATA[" <> T.replace "]]>" "]]]]<![CDATA[>" t <> "]]>"
rfc822 :: UTCTime -> Text
rfc822 = T.pack . formatTime defaultTimeLocale "%a, %d %b %Y %H:%M:%S +0000"
renderItem :: Post -> Text
renderItem p =
let url = siteUrl <> "/blog/" <> postSlug p <> "/"
in el "item" $ T.concat
[ el "title" (cdata (postTitle p))
, el "link" url
, "<guid isPermaLink=\"true\">" <> url <> "</guid>"
, el "pubDate" (rfc822 (postDate p))
, el "description" (cdata (postSummary p))
]
renderFeed :: [Post] -> Text
renderFeed ps =
let sorted = sortOn (Down . postDate) ps
in T.concat
[ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
, "<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\"><channel>"
, el "title" (cdata "Example Journal")
, el "link" siteUrl
, el "description" (cdata "News and guides from Example Journal.")
, el "language" "en"
, el "lastBuildDate" (rfc822 (maximum (map postDate sorted)))
, "<atom:link href=\"" <> siteUrl <> "/feed.xml\" rel=\"self\" type=\"application/rss+xml\"/>"
, T.concat (map renderItem (take 20 sorted))
, "</channel></rss>"
]Three decisions here deserve a closer look. The channel date comes from the newest post and not from the clock, so the file does not change when nothing was published; a feed that changes on every request defeats caching, as explained in the guide to ETags and conditional requests. The identifier of each item is its permanent address, which does not change when the title does. And the list is sorted and cut to a fixed number, so that the newest posts come first and the file stays small.
The route sets the headers and sends the document. Use the raw response helper for the body, because helpers such as the text and html ones set their own content type and would replace the one you want.
feedRoute :: ActionM ()
feedRoute = do
let newest = maximum (map postDate posts)
etag = TL.pack (show (length posts) ++ "-" ++ formatTime defaultTimeLocale "%s" newest)
quoted = "\"" <> etag <> "\""
setHeader "Content-Type" "application/rss+xml; charset=utf-8"
setHeader "Cache-Control" "public, max-age=300"
setHeader "ETag" quoted
inm <- header "If-None-Match"
if inm == Just quoted
then status status304
else raw (TLE.encodeUtf8 (TL.fromStrict (renderFeed posts)))
main :: IO ()
main = scotty 3000 $ do
get "/feed.xml" feedRouteThe validator for the cache is built from the number of posts and the date of the newest one, so it changes exactly when the content changes. When the client sends the same value back, the route answers with a 304 status and no body. That saves bandwidth for you and for every tool that polls the feed. If you edit an old post and want clients to notice, include its modification time in the value as well. The meaning of the status codes is explained in the guide to feed status codes.
| Feed element | Source in the code | Rule |
|---|---|---|
| Title | postTitle | Plain text, protected by CDATA |
| Link and identifier | siteUrl and postSlug | Absolute, https, never changes |
| Publication date | postDate in UTC | In the past, formatted as RFC 822 |
| Description | postSummary | One or two complete sentences |
| Channel date | Newest postDate | Not the current time |
| Cache validator | Count and newest date | Changes only when content changes |
The identifier deserves the most attention. If a slug changes when someone edits a title, the address changes, and an auto-poster that stores identifiers will treat the old and new address as two posts. The causes and cures are covered in why a feed auto-posts the same article twice.
The date format for RSS items is the one used in email headers: an abbreviated weekday, a day, an abbreviated month, a year, a time and an offset. The formatting function above prints it from a UTC value with a fixed offset, so it is always valid. Never print the date with a default show function, which gives a different layout that validators reject. Never use a date in the future either; an item dated tomorrow may be hidden or shared at the wrong time. A full explanation of the fields is in the guide to feed XML.
Haskell text is Unicode, and the route encodes it as UTF-8 before sending it, which matches the encoding declared in the XML header. Both must agree, or accented letters and emoji turn into garbage. Characters that are not allowed in XML at all, such as most control characters, can arrive from copied text and break the file even inside a CDATA section; filter them out before rendering if your data comes from users. See why special characters and emoji break feeds.
Auto-posters make the best social posts from a feed that gives them a good summary and a picture. Add a short plain-text description to every post and, if you have an image, an enclosure element with its address, its size in bytes and its media type, or a media element with its address. Use absolute addresses only; a relative path in a feed is broken for everyone who reads it away from your site, as this guide on relative URLs explains. If you do not know which text should go into the description, read full text or excerpt in an RSS feed.
Scotty applications usually sit behind a reverse proxy, which terminates the encrypted connection and can also compress responses. If you prefer to compress inside the application, a middleware from the wai-extra package does it for you. Whichever you choose, check that the compressed response is still served with the correct content type; see the guide to feed compression. Also make sure the proxy forwards the request headers that your ETag logic reads, and that it does not cache an old version of the feed for longer than you intended.
Run the program and request the route locally, then test the deployed address from outside, because a local test proves nothing about the proxy in front of it.
cabal run
curl -i http://localhost:3000/feed.xml
curl -i https://example.com/feed.xml
curl -i -H 'If-None-Match: "1-1793260800"' https://example.com/feed.xmlA longer routine is in the feed validation guide, and a shorter one in debugging a feed with curl.
PostRSS has been publishing RSS and Atom feeds to social networks since 2014. Paste the address of your feed, 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 entry, 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. See the PostRSS features page and the plan limits on the pricing page. More developer guides are under the Technical Guide tag.
A Scotty feed is one route, but it has to get small things right: dates in the RSS format printed from UTC values, text that cannot break the XML, an identifier that never changes, a channel date that comes from the newest post, the correct content type through the raw helper and a validator that lets clients skip unchanged files. Test the live address with curl and a validator, then connect it to an auto-poster. After that, every post you publish reaches your readers and your social channels without extra work.
Set the content type header to the RSS media type and send the body with the raw response helper. The text and html helpers set their own content type and would replace yours.
A small function that builds the text is enough for a simple feed, as shown here. If you prefer a library, the feed package on Hackage models RSS and Atom documents and renders them for you.
Keep the date as a UTC value and print it with the time package using an RFC 822 pattern, for example abbreviated weekday, day, abbreviated month, year, time and a plus 0000 offset.
The content type is wrong. Many response helpers set a plain text or HTML type, so send the body with the raw helper after you have set the RSS media type yourself.
Yes. PostRSS reads any valid RSS or Atom address that is reachable without a login. Paste the address, connect your channels and new posts are shared as soon as they appear.
What changed in the networks, what broke, and how to fix it before it costs you reach.
PostRSSを手がけるInternet Solutionsが開発。どの製品も、それぞれの形で時間を節約します。