Indexmap

Gem Version Ruby

indexmap is a small Ruby gem for generating XML sitemaps from explicit Ruby data.

It is designed for Rails apps that want:

  • deterministic sitemap output
  • plain Ruby configuration
  • first-party rake tasks instead of a large DSL
  • easy extraction of sitemap logic into app-owned manifests

By default, indexmap writes a sitemap index plus one or more child sitemap files. For simpler sites, it also supports :single_file mode, which writes a single urlset directly to sitemap.xml.

Installation

Add this line to your application's Gemfile:

gem "indexmap"

And then execute:

bundle install

Or install it directly:

gem install indexmap

Upgrading an existing app? Read UPGRADE.md before deploying, especially if the app uses custom storage or stores sitemap files under a directory prefix such as sitemaps/.

Ruby Usage

require "indexmap"

sections = [
  Indexmap::Section.new(
    filename: "sitemap-marketing.xml",
    entries: [
      Indexmap::Entry.new(loc: "https://example.com/"),
      Indexmap::Entry.new(loc: "https://example.com/pricing", lastmod: Date.new(2026, 4, 21))
    ]
  )
]

Indexmap::Writer.new(
  sections: sections,
  base_url: "https://example.com"
).write

Rails Usage

In an initializer:

Indexmap.configure do |config|
  config.base_url = -> { "https://example.com" }
  config.storage = -> do
    Indexmap::Storage::Filesystem.new(
      path: Rails.public_path,
      public_url: config.base_url
    )
  end
  config.sections = -> do
    [
      Indexmap::Section.new(
        filename: "sitemap-marketing.xml",
        entries: [
          Indexmap::Entry.new(loc: "https://example.com/")
        ]
      )
    ]
  end
end

Then run:

bin/rails indexmap:sitemap:create
bin/rails indexmap:sitemap:format
bin/rails indexmap:sitemap:validate

indexmap:sitemap:create is the main task. It builds sitemap files in memory, formats them, validates the result, then writes the final XML files to the configured storage. Existing sitemap files are left untouched if generation or validation fails.

Default Index Mode

This is the default behavior. indexmap writes:

  • sitemap.xml as a sitemap index
  • one or more child sitemap files from config.sections

Single-File Mode

For sites that only want one sitemap.xml file:

Indexmap.configure do |config|
  config.base_url = -> { "https://example.com" }
  config.storage = -> { Indexmap::Storage::Filesystem.new(path: Rails.public_path, public_url: config.base_url) }
  config.format = :single_file
  config.entries = -> do
    [
      Indexmap::Entry.new(loc: "https://example.com/"),
      Indexmap::Entry.new(loc: "https://example.com/about", lastmod: Date.new(2026, 4, 21))
    ]
  end
end

In :single_file mode, indexmap writes a urlset directly to sitemap.xml and reads entries from config.entries instead of config.sections.

Named Outputs

Most apps only need the default output. Use named outputs when one part of the sitemap must be generated separately, for example when static pages can be generated during deploy but database-heavy pages should refresh later. Named outputs write through the same configured storage as the default output.

Indexmap.configure do |config|
  config.base_url = -> { "https://example.com" }
  config.storage = -> { Indexmap::Storage::Filesystem.new(path: Rails.root.join("storage/sitemaps"), public_url: config.base_url) }
  config.sections = -> { Sitemap.sections }

  config.output :insights_data do |output|
    output.format = :single_file
    output.index_filename = "sitemap-insights-data.xml"
    output.entries = -> { Sitemap.insights_data_entries }
  end
end

Generate the default output:

Indexmap.create

Generate only the named output:

Indexmap.create(:insights_data)

Named outputs inherit base_url and format from the main configuration unless you override them. Storage is configured once and shared by every output.

Indexmap.create uses the same safe publish flow as the rake task: build, format, validate, and then write the final XML file or files to storage.

Storage

Every indexmap operation reads and writes through config.storage. The storage object is the source of truth for generation, validation, parsing, Google submission, IndexNow submission, and IndexNow verification files.

The filesystem adapter stores files in a directory and exposes public URLs from the same filenames:

Indexmap.configure do |config|
  config.base_url = "https://example.com"
  config.storage = Indexmap::Storage::Filesystem.new(
    path: Rails.public_path,
    public_url: "https://example.com"
  )
end

Rails apps that store sitemap files in Active Storage can use the optional adapter. indexmap does not depend on activestorage; this adapter only uses the model and attachment object you pass in.

Indexmap.configure do |config|
  config.base_url = "https://example.com"
  config.storage = Indexmap::Storage::ActiveStorage.new(
    model: SitemapArtifact,
    filename_column: :filename,
    attachment: :file,
    public_url: "https://example.com"
  )
end

Custom storage backends can implement the same small interface:

storage.write(filename, body, content_type:)
storage.read(filename)
storage.exist?(filename)
storage.list(prefix:, suffix:)
storage.delete(filename)
storage.public_url(filename)

Deferred Dynamic Sections

Use after_create when indexmap:sitemap:create should publish the default sitemap first, then schedule slower dynamic sections for the background. The callback runs only after the generated files have been formatted, validated, and replaced successfully.

Indexmap.configure do |config|
  config.base_url = -> { "https://example.com" }
  config.storage = -> { Indexmap::Storage::Filesystem.new(path: Rails.root.join("storage/sitemaps"), public_url: config.base_url) }
  config.sections = -> { Sitemap.sections }

  config.output :insights_data do |output|
    output.format = :single_file
    output.index_filename = "sitemap-insights-data.xml"
    output.entries = -> { Sitemap.insights_data_entries }
  end

  config.after_create do
    Insights::SitemapRefreshJob.perform_later
  end
end

Then the job can stay small:

class Insights::SitemapRefreshJob < ApplicationJob
  def perform
    Indexmap.create(:insights_data)
  end
end

This keeps deploys fast: the deploy only waits for indexmap:sitemap:create, while database-dependent output is refreshed by the job backend.

Validation And Parsing

indexmap also includes small utilities for working with generated sitemap files:

parser = Indexmap::Parser.new(source: "sitemap.xml")
parser.paths
# => ["/", "/about", "/articles/example"]

Indexmap::Validator.new.validate!

The built-in validator checks for:

  • missing sitemap files
  • malformed sitemap XML
  • empty sitemap files
  • missing or duplicate child sitemap references
  • duplicate sitemap URLs
  • parameterized URLs in sitemap entries
  • fragment URLs in sitemap entries
  • non-HTTP or relative URLs
  • URLs outside the configured base_url
  • invalid lastmod values

Search Engine Ping

indexmap can ping Google Search Console and IndexNow after sitemap generation.

Available rake tasks:

bin/rails indexmap:sitemap:validate
bin/rails indexmap:google:ping
bin/rails indexmap:index_now:ping
bin/rails indexmap:index_now:write_key
bin/rails indexmap:ping

Google Search Console

Google pinging requires service account credentials:

Indexmap.configure do |config|
  config.google.credentials = -> { ENV["GOOGLE_SITEMAP"] }
end

If config.google.credentials is blank, indexmap:google:ping skips Google submission.

You can optionally override the Search Console property identifier:

Indexmap.configure do |config|
  config.google.credentials = -> { ENV["GOOGLE_SITEMAP"] }
  config.google.property = -> { "sc-domain:example.com" }
end

If config.google.property is not set, indexmap defaults to sc-domain:<host>.

IndexNow

IndexNow submission requires a key. indexmap supports two ways to provide it:

  • set config.index_now.key
  • or keep a valid verification file in the configured storage as <key>.txt

Configured-key example:

Indexmap.configure do |config|
  config.index_now.key = -> { ENV["INDEXNOW_KEY"] }
end

If config.index_now.key is set, indexmap:sitemap:create also ensures the matching <key>.txt verification file exists in storage. It leaves an existing valid key file unchanged.

If you need a non-standard verification filename, configure it explicitly:

Indexmap.configure do |config|
  config.index_now.key = -> { ENV["INDEXNOW_KEY"] }
  config.index_now.key_filename = -> { "#{ENV.fetch("INDEXNOW_KEY")}.txt" }
end

You can also disable automatic key-file writes entirely:

Indexmap.configure do |config|
  config.index_now.key = -> { ENV["INDEXNOW_KEY"] }
  config.index_now.write_key_file = false
end

If you prefer the file-based flow, run:

bin/rails indexmap:index_now:write_key

That task:

  • reuses an existing valid key file when present
  • otherwise generates a new key in <key>.txt
  • makes that key available to indexmap:index_now:ping without adding config.index_now.key

If neither a configured key nor a valid key file is present, indexmap:index_now:ping skips IndexNow submission.

Development

Run tests:

bundle exec rake test

Run lint:

bundle exec rake standard

Run the full default task:

bundle exec rake

Tests generate a coverage report automatically.

Note: Gemfile.lock is intentionally not tracked for this gem, following normal Ruby library conventions.

Git hooks

We use lefthook with the Ruby commitlint gem to enforce Conventional Commits on every commit. We also use Standard Ruby to keep code style consistent. CI validates commit messages, Standard Ruby, tests, and git-cliff changelog generation on pull requests and pushes to main/master.

Run the hook installer once per clone:

bundle exec lefthook install

Release

Releases are tag-driven and published by GitHub Actions to RubyGems. Local release commands never publish directly.

Install git-cliff locally before preparing a release. The release task regenerates CHANGELOG.md from Conventional Commits.

Before preparing a release, make sure you are on main or master with a clean worktree.

Then run one of:

bundle exec rake 'release:prepare[patch]'
bundle exec rake 'release:prepare[minor]'
bundle exec rake 'release:prepare[major]'
bundle exec rake 'release:prepare[0.1.0]'

The task will:

  1. Regenerate CHANGELOG.md with git-cliff.
  2. Update lib/indexmap/version.rb.
  3. Commit the release changes.
  4. Create and push the vX.Y.Z tag.

The Release workflow then runs tests, publishes the gem to RubyGems, and creates the GitHub release from the changelog entry.

License

MIT License, see LICENSE.txt

About

Made by the team at Ethos Link — practical software for growing businesses. We build tools for hospitality operators who need clear workflows, fast onboarding, and real human support.

We also build Reviato, “Capture. Interpret. Act.”. Turn guest feedback into clear next steps for your team. Collect private appraisals, spot patterns across reviews, and act before small issues turn into public ones.