RSS у 66 соціальних мереж: Facebook, Instagram, X, LinkedIn, Telegram та інші Блог Партнерська програма Контакти
Увійти Почати безкоштовно
Updated: 2026-10-24
Kemal RSS Feed in Crystal: XML Builder and ETag

Short answer: To serve an RSS feed from a Kemal application, add a get route for /feed.xml, read the newest published rows with the crystal-db shard, write the channel and items with XML.build so every value is escaped, format dates in UTC with a fixed GMT pattern, and return the string as application/rss+xml. Hash the body into an ETag and answer a matching If-None-Match header with a 304 using halt, 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.

Crystal is a compiled language with Ruby-like syntax that produces fast native binaries, and Kemal is the lightweight web framework that most Crystal developers use for APIs and small sites. Teams choose the pair when they want the pleasant style of a scripting language with the speed and the single-file deployment of a compiled one. When such a service publishes something that people want to follow, such as articles, offers, events or release notes, a feed is the standard way to let readers, aggregators and auto-posters watch it without an account or an API key. Kemal has no feed support, but the Crystal standard library ships an XML builder that escapes text and writes attributes safely. This guide builds a complete Kemal RSS feed with a typed 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 Kemal service needs a feed

A feed is worth adding whenever new items appear on a schedule you do not control: a changelog that grows with every release, a directory of listings, a news section or a library of guides. Readers who follow it in a feed reader get every item in order, search engines discover new pages faster and an auto-poster such as PostRSS can share each new item on social networks as soon as it is published.

If your content already lives in a blog or a CMS, use the feed that system provides; see how to find your WordPress feed link. If you work with another Ruby-flavoured stack, the Sinatra RSS feed guide covers a similar idea in Ruby. Build the route yourself when the content is in your own database and no ready-made feed exists.

Set up the project

Create a project with crystal init app feed and list the dependencies in shard.yml. The example uses Kemal, the database abstraction and the SQLite driver.

dependencies:
  kemal:
    github: kemalcr/kemal
  db:
    github: crystal-lang/crystal-db
  sqlite3:
    github: crystal-lang/crystal-sqlite3

Run shards install to fetch them. Assume a table named articles with the columns slug, title, summary and published_at, where the last one holds a UTC unix timestamp. Items scheduled for the future simply have a timestamp that has not arrived yet.

The complete application

The program defines a record for one article, a query that returns only items whose time has arrived, a builder that writes the document and the route. 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.

require "kemal"
require "xml"
require "db"
require "sqlite3"
require "digest/sha256"

SITE    = "https://example.com"
DB_URL  = "sqlite3://./site.db"
RFC822  = "%a, %d %b %Y %H:%M:%S GMT"

record Article, slug : String, title : String, summary : String, published_at : Int64

def latest_articles(limit = 20) : Array(Article)
  items = [] of Article
  DB.open(DB_URL) do |db|
    db.query(
      "SELECT slug, title, summary, published_at FROM articles " \
      "WHERE published_at <= ? ORDER BY published_at DESC LIMIT ?",
      Time.utc.to_unix, limit
    ) do |rs|
      rs.each do
        items << Article.new(rs.read(String), rs.read(String), rs.read(String), rs.read(Int64))
      end
    end
  end
  items
end

def rfc822(timestamp : Int64) : String
  Time.unix(timestamp).to_s(RFC822)
end

def build_feed(items : Array(Article)) : String
  newest = items.empty? ? 0_i64 : items.first.published_at
  XML.build(indent: "  ", encoding: "UTF-8") do |xml|
    xml.element("rss", version: "2.0", "xmlns:atom": "http://www.w3.org/2005/Atom") do
      xml.element("channel") do
        xml.element("title") { xml.text "Example Journal" }
        xml.element("link") { xml.text "#{SITE}/" }
        xml.element("description") { xml.text "News and guides from Example Journal." }
        xml.element("language") { xml.text "en" }
        xml.element("lastBuildDate") { xml.text rfc822(newest) }
        xml.element("atom:link", href: "#{SITE}/feed.xml", rel: "self", type: "application/rss+xml")
        items.each do |article|
          url = "#{SITE}/articles/#{article.slug}/"
          xml.element("item") do
            xml.element("title") { xml.text article.title }
            xml.element("link") { xml.text url }
            xml.element("guid", isPermaLink: "true") { xml.text url }
            xml.element("pubDate") { xml.text rfc822(article.published_at) }
            xml.element("description") { xml.text article.summary }
          end
        end
      end
    end
  end
end

def etag_matches?(header : String?, etag : String) : Bool
  return false unless header
  header.split(",").any? do |token|
    value = token.strip.lchop("W/")
    value == "*" || value == etag
  end
end

get "/feed.xml" do |env|
  body = build_feed(latest_articles)
  etag = %("#{Digest::SHA256.hexdigest(body)[0, 32]}")
  env.response.headers["ETag"] = etag
  env.response.headers["Cache-Control"] = "public, max-age=300"
  if etag_matches?(env.request.headers["If-None-Match"]?, etag)
    halt env, status_code: 304, response: ""
  end
  env.response.content_type = "application/rss+xml; charset=utf-8"
  body
end

Kemal.run

Build and start it with crystal run src/feed.cr, or compile a release binary with crystal build --release src/feed.cr. Kemal listens on port 3000 by default, and you can change that with the port option or the command line flag.

How the builder keeps the XML valid

The XML.build helper writes elements and attributes through a writer that escapes the characters XML reserves. When you call xml.text with a title that contains an ampersand or an angle bracket, the writer turns it into an entity, so one unusual headline cannot break the document. Compare that with assembling the feed from string interpolation, where a single missing escape produces a file that validators reject and auto-posters refuse to read. The example also declares the encoding as UTF-8 and writes the namespace for the self link as a named argument, which Crystal accepts for names that contain a colon when they are written as a quoted string.

How the ETag logic works

Auto-posters, readers and search engines request a feed often, and almost always nothing has changed. The route hashes the finished string, quotes the hash as an entity tag and compares it with the If-None-Match header. A match ends the request with halt, which sends a 304 status and an empty body together with the headers that were already set, so the client keeps its stored copy. The hash is computed from the body, which brings one rule: the body must not change unless the content changes. That is why the builder takes the last build date from the newest item and not from the clock. If you wrote the current time into that element, the hash would change on every request and no client would ever receive a 304. A compressing proxy may turn a strong tag into a weak one, so the comparison strips the W/ prefix.

Where each field comes from

Feed elementSourceRule
titleArticle titlePlain text, escaped by the builder
linkPublic address of the pageAbsolute, with https, returning 200 directly
guidSame address, or a stored identifierNever changes after publication
pubDatePublication timestampRFC 822 in UTC, never the current time
descriptionShort summaryOne or two sentences of plain text
enclosureFeatured image, optionalAddress, size in bytes and media type

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. If you may rename slugs, store a separate permanent identifier and write it with isPermaLink set to false. To add an image, write an enclosure element with the address, the length in bytes and the media type as attributes.

Dates in the right format

RSS dates follow RFC 822, with English abbreviations for days and months. The pattern in the example prints the abbreviated weekday, the day, the abbreviated month, the year and the time, followed by the literal text GMT. A time created with Time.unix is already in UTC, so no conversion is needed, and the English names do not depend on the locale of the server. Mixing in local time is the most common cause of feeds whose items appear hours late or early in readers, so keep the stored values in UTC and format them in one place only.

Caching without delaying new posts

The Cache-Control header in the example lets shared caches keep the response for five minutes, and the ETag lets everyone revalidate cheaply after that. If you place a content delivery network in front of the application, keep the lifetime short so a new item does not wait long for its turn. A feed that lags behind the website by an hour defeats the purpose of automation. For the background, read how auto-posting tools use ETags and conditional requests та what 301, 304, 404 and 500 mean for auto-posting.

Test the feed before you connect it

A few minutes of testing prevents days of confusion later. Start the service and check it from the outside:

  • Run curl -i https://example.com/feed.xml and confirm a 200 status, the right content type and an ETag header.
  • Repeat the request with -H 'If-None-Match: ...' and the tag you received, and confirm that the answer is 304.
  • Paste the public address into the W3C Feed Validation Service and fix every error.
  • Publish a test item, wait for the cache lifetime and confirm that it appears once, at the top.

There is a longer checklist in the feed validation guide and a short curl routine in the guide to debugging a feed with curl. Crystal also ships a spec library, and the builder function is easy to cover with a spec that parses its output.

Running Kemal in production

Compile a release binary and run it behind a reverse proxy that terminates HTTPS and passes the real host and scheme, so absolute links are correct. Crystal programs use one thread by default, so run several instances behind the proxy if you need more throughput. Keep the site address in an environment variable instead of a constant, so a staging copy never publishes production links, and monitor the feed route separately from the home page. If the route sits behind a login or a firewall rule for unknown clients, an auto-poster will not reach it; the feed must be public.

Common mistakes to avoid

  • Current time in the body. It changes the hash on every request and disables the 304 answer.
  • Unstable GUIDs. Changing them makes auto-posters share old stories again; see why a feed auto-posts the same article twice.
  • Building XML from strings. A missing escape breaks the document; use the builder.
  • Future items in the feed. Always filter on the publication time.
  • Forgetting the content type. Kemal sends HTML by default for string responses; set the feed type explicitly, as described in the content type header guide.
  • Opening a connection per request without limits. The example is simple on purpose; use a shared pool when traffic grows.

How PostRSS works with a Kemal 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.

Пов’язане читання

The bottom line

Kemal needs only a route, a typed query and the standard XML builder to serve a dependable RSS feed: select published items dated in the past, let the builder 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 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 Kemal have a built-in RSS generator?

No. Kemal has no feed support, but the XML builder in the Crystal standard library produces a valid RSS document and escapes every value, and the route returns it with the right content type.

How do I send a 304 response in Kemal?

Set the ETag header, compare it with the If-None-Match value and call halt with a 304 status and an empty body. The client then keeps the copy it already has.

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.

Більше інструментів від нашої команди

Створено Internet Solutions — командою PostRSS. Кожен продукт заощаджує ваш час по-своєму.

ШІ-чат для сайтів Talkmio Ваш сайт відповідає відвідувачам цілодобово на основі вашого контенту та їхньою мовою. Безкоштовний план · без картки ШІ-асистент Ask Mio Чат, код, дизайн, тексти й дослідження. Mio обирає найкращу модель для кожного завдання. Безкоштовний план ШІ-автопілот для блогу й соцмереж AI Blog Autopilot ШІ пише SEO-статті на 2 000–3 000 слів і публікує кожну в 60 соцмережах. Перші 3 статті безкоштовно Перевірка стану сайту Аудит ШІ сайту SEO, швидкість, SSL, безпека та налаштування пошти в одному звіті — за пріоритетом виправлень. Перший аудит безкоштовно Глибокий SEO-аудит Аудит SEO ШІ сайту Повний SEO-обхід за 7 напрямами, включно з видимістю в ШІ-пошуку, з виправленнями за впливом. Перший аудит безкоштовно RSS і товарні фіди Створювач RSS‑каналів Створюйте RSS з будь-якої вебсторінки, а також товарні фіди для Google і Meta, що оновлюються самі. Безкоштовний план Розробка сайтів і SEO Internet Solutions Сайти, інтернет-магазини та індивідуальні системи — проєктуємо, створюємо й підтримуємо самі. З 2011 року