LaunchKit
← All posts
· 5 min read · by The LaunchKit team · 3 views

Generating a sitemap in Rails without a gem

You don't need a gem for this, and the version without one has a property the generated-file version can't match: it's never stale. Here's the whole thing, then the three numbers that decide whether it stays this simple.

The whole implementation

# config/routes.rb
get "/sitemap.xml", to: "sitemaps#show", defaults: { format: "xml" }

# app/controllers/sitemaps_controller.rb
class SitemapsController < ApplicationController
  def show
    @articles = Article.published.select(:slug, :updated_at)
    expires_in 1.hour, public: true
  end
end
<%# app/views/sitemaps/show.xml.builder %>
xml.instruct!
xml.urlset(xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9") do
  xml.url do
    xml.loc root_url
    xml.changefreq "daily"
    xml.priority "1.0"
  end

  @articles.each do |article|
    xml.url do
      xml.loc article_url(article.slug)
      xml.lastmod article.updated_at.iso8601
      xml.changefreq "monthly"
      xml.priority "0.8"
    end
  end
end

That's it. article_url rather than article_path, because the spec wants absolute URLs and a relative one is silently dropped. select only the columns you use, because this action loads every published row and there's no reason to instantiate the bodies.

Three numbers off a real one

We measured this site's own live sitemap rather than a synthetic one:

URLs 243
Uncompressed 46,218 bytes
Per URL entry 190 bytes
Gzipped 3,122 bytes
Gzip ratio 14.8x

The spec caps a single sitemap at 50,000 URLs and 50 MB uncompressed. At 190 bytes an entry, 50 MB holds about 275,000 URLs, so the count limit arrives roughly five times sooner than the byte limit. Unless your URLs are unusually long, the only limit you'll ever meet is 50,000, and you can stop thinking about the other one.

That gzip ratio is worth a sentence too. 14.8x is extraordinary for text, and it's because a sitemap is the same six tags repeated a few hundred times over a shared URL prefix. Rails will gzip this for you if Rack::Deflater is in the stack, and it's close to the cheapest bandwidth win in the app.

A single sitemap drawn against its two spec limits, to scale. The 50,000 URL cap and the 50 MB byte cap are shown as two ceilings, with the measured 190 bytes per entry placing the byte ceiling at roughly 275,000 entries, about five times further away than the count ceiling, so the count is what binds. A second panel shows the same 46,218 byte document compressing to 3,122 bytes, a 14.8x reduction, with a note that a sitemap compresses unusually well because it is the same six tags repeated over a shared URL prefix.

Why dynamic beats a generated file

The common alternative generates a file nightly and serves it from disk. It's faster to serve and it's wrong most of the day.

lastmod is the field this matters for. It's the one element in the spec that crawlers demonstrably act on, because it tells them whether re-fetching a URL is worth a request. Publish an article at ten in the morning and a file generated at midnight says nothing about it until tomorrow. The action above reads updated_at at request time, so it's accurate by construction and there's no job to fail quietly.

The cost is a database query per request, on a path that gets hit a handful of times a day. expires_in 1.hour, public: true hands that to your CDN or your reverse proxy and the cost goes away. If your sitemap is large enough that this stops being true, you're near the 50,000 limit and you have a different job to do anyway.

Two fields in that template are mostly decoration. changefreq and priority are advisory, Google has said publicly it ignores them, and priority in particular is relative within your own site rather than a claim about the internet. They're harmless. They're also not where your effort goes.

Past fifty thousand

The answer is a sitemap index, which is a sitemap whose entries are other sitemaps:

<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>https://example.com/sitemaps/articles-1.xml</loc>
    <lastmod>2026-10-17T09:00:00Z</lastmod>
  </sitemap>
</sitemapindex>

The index itself is capped at 50,000 entries, which gives you two and a half billion URLs before the structure runs out, and nobody reading this will meet that. Shard by something stable, like a page number over an ordered scope, so a given URL keeps landing in the same child sitemap and lastmod on the index stays meaningful.

The part a sitemap doesn't do

Worth ending on, because it's where the expectation goes wrong.

A sitemap asks for a crawl. An internal link argues for one. A page that appears in your sitemap and is linked from nowhere gets discovered, queued, and frequently left in whatever your search console calls "discovered, not indexed" indefinitely. We've watched exactly that happen on this site, to pages that were in the sitemap from the day they shipped.

So when a section isn't getting indexed, the sitemap is the first thing people check and almost never the problem. The question to ask instead is which page on the site links to it.

What this page doesn't cover

No crawler. Everything here is about producing a valid, accurate document, and nothing in it measures what Google then does with one, because that takes weeks and a property you control.

Image and video sitemap extensions aren't here, nor is hreflang for a multilingual site, which is a genuinely fiddly corner where the alternates have to be reciprocal. And nothing above touches robots.txt, which is where you announce the sitemap's location so a crawler that has never seen your site can find it without being told.

#rails #seo #routing

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.