Open source

indexmap

Generate sitemap indexes and child sitemaps from explicit Ruby definitions, keeping sitemap ownership predictable and reviewable.

Open source Paulo Fidalgo

indexmap is a small Ruby gem for writing XML sitemaps from plain Ruby data.

It keeps sitemap rules in app code. Teams can review them, test them, and change them without learning another framework DSL.

What

  • Builds sitemap indexes and child sitemap files from plain Ruby Section and Entry objects.
  • Supports single-file mode for sites that only need one public sitemap.xml.
  • Supports named outputs for sitemap files that need a separate refresh path.
  • Writes XML to a temporary directory, formats it, validates it, then replaces the final files only after a successful run.
  • Adds Rails tasks for creating, formatting, validating, and pinging sitemap URLs.

How

Most sitemap failures are not XML failures. They are ownership failures.

The hard questions are which routes belong, which parameterized URLs stay out, and which records are indexable. The rules also need to stay visible as the app grows.

indexmap keeps those decisions in normal Ruby.

For a larger app, you can group entries by route family or content type.

indexmap can then write a sitemap index plus child sitemap files.

Indexmap.configure do |config|
  routes = Rails.application.routes.url_helpers
  site_url_options = Rails.application.config.x.site_url_options

  config.base_url = -> { Rails.application.config.x.site_url }
  config.public_path = -> { Rails.public_path }
  config.sections = -> do
    posts = Post.published.order(published_at: :desc)

    [
      Indexmap::Section.new(
        filename: "sitemap-marketing.xml",
        entries: [
          Indexmap::Entry.new(loc: routes.root_url(**site_url_options)),
          Indexmap::Entry.new(loc: routes.pricing_url(**site_url_options)),
          Indexmap::Entry.new(loc: routes.features_url(**site_url_options))
        ]
      ),
      Indexmap::Section.new(
        filename: "sitemap-posts.xml",
        entries: posts.map do |post|
          Indexmap::Entry.new(
            loc: routes.post_url(post, **site_url_options),
            lastmod: post.updated_at
          )
        end
      )
    ]
  end
end

That is the shape of a larger app sitemap. Sections follow the public surface.

Marketing pages, posts, reports, and directory pages each keep their own inclusion rules. The URLs still come from route helpers and app-owned content repositories.

For a smaller site, single-file mode keeps the same pattern but writes one urlset directly to public/sitemap.xml.

Indexmap.configure do |config|
  routes = Rails.application.routes.url_helpers
  site_url_options = Rails.application.config.x.site_url_options

  config.base_url = -> { Rails.application.config.x.site_url }
  config.public_path = -> { Rails.public_path }
  config.format = :single_file
  config.entries = -> do
    posts = Post.published.order(published_at: :desc)

    [
      Indexmap::Entry.new(loc: routes.root_url(**site_url_options)),
      Indexmap::Entry.new(loc: routes.about_url(**site_url_options)),
      Indexmap::Entry.new(loc: routes.posts_url(**site_url_options), lastmod: posts.first&.updated_at),
      *posts.map do |post|
        Indexmap::Entry.new(
          loc: routes.post_url(post, **site_url_options),
          lastmod: post.updated_at
        )
      end
    ]
  end
end

This is the same pattern a small marketing site can use. The site only needs one public sitemap file, but the sitemap is still route-driven and explicit.

Some applications also have sitemap sections that should not run during deployment.

For example, report pages might depend on production data before they are eligible for the sitemap. For that case, indexmap supports named outputs and post-create hooks:

Indexmap.configure do |config|
  config.after_create do
    Sitemaps::ReportsRefreshJob.perform_later
  end

  config.output :reports do |output|
    output.format = :single_file
    output.index_filename = "sitemap-reports.xml"
    output.entries = -> { Sitemap::Reports.entries }
  end
end

class Sitemaps::ReportsRefreshJob < ApplicationJob
  def perform
    Indexmap.create(:reports)
  end
end

The default sitemap can stay fast and deployment-safe. The database-backed sitemap can refresh later in the app’s job system, using the same entry objects and validation rules.

Before publishing files, indexmap writes to a local temporary directory. It formats the XML, validates the generated sitemap, and only then replaces the final files.

A broken generation run leaves the previous sitemap in place.

Why

Sitemaps are an operational contract with search engines. They should not quietly become a dumping ground for every route an app can render.

indexmap is designed around that constraint:

  • route helpers keep public URLs tied to real application routes.
  • model scopes and content repositories decide which records are eligible.
  • named outputs separate fast static generation from slower data-backed refreshes.
  • validation catches malformed URLs, duplicate entries, parameterized URLs, fragments, invalid lastmod values, and URLs outside the configured site.
  • safe publishing prevents a failed generation run from deleting the last good sitemap.

The result is intentionally small. The gem generates, formats, validates, and pings. Storage, serving, and higher-level eligibility rules stay in the app, where they belong.

Why I keep it public

indexmap comes from production sitemap work. Search visibility depends on keeping page types explicit, reviewable, and safe to operate.

It is deliberately small. The gem handles generation and validation. The app keeps ownership of which URLs deserve to exist.