When your Phoenix application grows beyond a few hundred pages, maintaining a single monolithic sitemap.xml becomes unwieldy. Search engines like Google recommend splitting large sitemaps into smaller, organized chunks. Let's build a WordPress-style sitemap structure in Phoenix!

🎯 What We're Building

Instead of one giant sitemap, we'll create:

  • /sitemap.xml - Main index pointing to sub-sitemaps

  • /items-sitemap.xml - All rental items

  • /categories-sitemap.xml - Category pages

  • /pages-sitemap.xml - Static pages and locations

Why Split Sitemaps?

Benefits:

  • Better organization and maintainability

  • Faster generation (regenerate only what changed)

  • Better caching strategies

  • Search engine friendly (Google's recommended approach)

  • Easier debugging (isolate issues to specific content types)

Step 1: Setup Routes

First, add the sitemap routes in your router.ex:

elixir

pipeline :xml do
  plug :accepts, ["xml"]
end

scope "/", RentgaraWeb do
  pipe_through :xml
  get "/sitemap.xml", SitemapController, :index
  get "/items-sitemap.xml", SitemapController, :items
  get "/categories-sitemap.xml", SitemapController, :categories
  get "/pages-sitemap.xml", SitemapController, :pages
end

Important: Place this BEFORE your /:locale scoped routes to avoid route conflicts.

Step 2: Create the Sitemap Controller

Create lib/rentgara_web/controllers/sitemap_controller.ex:

elixir

defmodule RentgaraWeb.SitemapController do
  use RentgaraWeb, :controller

  alias Rentgara.Items
  alias Rentgara.Locations

  # Supported locales for multi-language support
  @locales ["en", "ne", "ne_RO"]

  @static_pages [
    "about",
    "how-it-works",
    "contact",
    "safety",
    "terms",
    "privacy"
  ]

  # Main sitemap index (points to sub-sitemaps)
  def index(conn, _params) do
    xml = """
    <?xml version="1.0" encoding="UTF-8"?>
    <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
      <sitemap>
        <loc>#{url(~p"/items-sitemap.xml")}</loc>
        <lastmod>#{Date.utc_today()}</lastmod>
      </sitemap>
      <sitemap>
        <loc>#{url(~p"/categories-sitemap.xml")}</loc>
        <lastmod>#{Date.utc_today()}</lastmod>
      </sitemap>
      <sitemap>
        <loc>#{url(~p"/pages-sitemap.xml")}</loc>
        <lastmod>#{Date.utc_today()}</lastmod>
      </sitemap>
    </sitemapindex>
    """

    conn
    |> put_resp_content_type("text/xml")
    |> send_resp(200, xml)
  end

  # Items sitemap
  def items(conn, _params) do
    items = Items.list_items()

    xml = """
    <?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
      #{generate_item_urls(items)}
    </urlset>
    """

    conn
    |> put_resp_content_type("text/xml")
    |> send_resp(200, xml)
  end

  # Categories sitemap
  def categories(conn, _params) do
    categories = Items.list_categories_raw()

    xml = """
    <?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
      #{generate_category_urls(categories)}
    </urlset>
    """

    conn
    |> put_resp_content_type("text/xml")
    |> send_resp(200, xml)
  end

  # Pages sitemap (static pages + locations + home)
  def pages(conn, _params) do
    locations = Locations.list_active_locations()

    xml = """
    <?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
      #{generate_home_urls()}
      #{generate_static_urls()}
      #{generate_location_urls(locations)}
    </urlset>
    """

    conn
    |> put_resp_content_type("text/xml")
    |> send_resp(200, xml)
  end

  # Helper functions for generating URLs
  
  defp generate_home_urls do
    for locale <- @locales do
      """
      <url>
        <loc>#{url(~p"/#{locale}")}</loc>
        <changefreq>daily</changefreq>
        <priority>1.0</priority>
      </url>
      """
    end
    |> Enum.join("\n")
  end

  defp generate_static_urls do
    for locale <- @locales, page <- @static_pages do
      """
      <url>
        <loc>#{url(~p"/#{locale}/#{page}")}</loc>
        <changefreq>weekly</changefreq>
        <priority>0.8</priority>
      </url>
      """
    end
    |> Enum.join("\n")
  end

  defp generate_location_urls(locations) do
    for locale <- @locales, location <- locations do
      """
      <url>
        <loc>#{url(~p"/#{locale}/location/#{location.slug}")}</loc>
        <changefreq>weekly</changefreq>
        <priority>0.9</priority>
      </url>
      """
    end
    |> Enum.join("\n")
  end

  defp generate_category_urls(categories) do
    for locale <- @locales, category <- categories do
      """
      <url>
        <loc>#{url(~p"/#{locale}/items?category=#{category.slug}")}</loc>
        <changefreq>weekly</changefreq>
        <priority>0.8</priority>
      </url>
      """
    end
    |> Enum.join("\n")
  end

  defp generate_item_urls(items) do
    for locale <- @locales, item <- items do
      if item.location do
        lastmod = 
          if item.updated_at, 
            do: Calendar.strftime(item.updated_at, "%Y-%m-%d"), 
            else: nil
        
        lastmod_tag = if lastmod, do: "<lastmod>#{lastmod}</lastmod>", else: ""

        """
        <url>
          <loc>#{url(~p"/#{locale}/items/#{item.location.slug}/#{item.slug}")}</loc>
          <changefreq>daily</changefreq>
          <priority>1.0</priority>
          #{lastmod_tag}
        </url>
        """
      else
        ""
      end
    end
    |> Enum.join("\n")
  end
end

🎨 Key Features Explained

1. Multi-Language Support

The @locales list ensures every page is generated for each supported language:

elixir

@locales ["en", "ne", "ne_RO"]

2. Verified URLs with ~p Sigil

Phoenix's verified routes (~p) ensure type-safe URL generation:

elixir

url(~p"/#{locale}/items/#{location.slug}/#{item.slug}")

3. Dynamic Priority & Change Frequency

Different content types get different priorities:

  • Items: priority: 1.0, changefreq: daily (most important, changes often)

  • Locations: priority: 0.9, changefreq: weekly (important landing pages)

  • Static Pages: priority: 0.8, changefreq: weekly (stable content)

4. Last Modified Dates

For dynamic content like items, include lastmod tags:

elixir

lastmod = Calendar.strftime(item.updated_at, "%Y-%m-%d")

πŸš€ Testing Your Sitemaps

Start your Phoenix server and visit:

bash

http://localhost:4000/sitemap.xml
http://localhost:4000/items-sitemap.xml
http://localhost:4000/categories-sitemap.xml
http://localhost:4000/pages-sitemap.xml

Your main sitemap should look like:

xml

<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>http://localhost:4000/items-sitemap.xml</loc>
    <lastmod>2025-12-21</lastmod>
  </sitemap>
  <sitemap>
    <loc>http://localhost:4000/categories-sitemap.xml</loc>
    <lastmod>2025-12-21</lastmod>
  </sitemap>
  <sitemap>
    <loc>http://localhost:4000/pages-sitemap.xml</loc>
    <lastmod>2025-12-21</lastmod>
  </sitemap>
</sitemapindex>

🎯 Production Optimizations

1. Add Caching

elixir

def items(conn, _params) do
  items = 
    Cachex.fetch(:sitemap_cache, "items", fn ->
      {:commit, Items.list_items()}
    end)
    |> elem(1)
  
  # ... generate XML
end

2. Invalidate Cache on Updates

elixir

# In your Items context
def create_item(attrs) do
  with {:ok, item} <- create_item_changeset(attrs) do
    Cachex.del(:sitemap_cache, "items")
    {:ok, item}
  end
end

3. Submit to Search Engines

Add to your robots.txt:

Sitemap: https://yourdomain.com/sitemap.xml

Submit manually to:

  • Google Search Console

  • Bing Webmaster Tools

πŸ“Š Performance Benefits

Before (single sitemap):

  • 10,000 items Γ— 3 locales = 30,000 URLs in one file

  • ~3-5 second generation time

  • Cache invalidated on ANY content change

After (split sitemaps):

  • Items changed? Regenerate only /items-sitemap.xml

  • ~0.5-1 second per sitemap

  • Better cache hit rates

πŸŽ“ Takeaways

  1. Split large sitemaps by content type for better maintainability

  2. Use Phoenix verified routes (~p) for type-safe URLs

  3. Cache aggressively and invalidate strategically

  4. Follow SEO best practices with proper priorities and change frequencies

  5. Support multiple languages from day one if planning i18n

πŸ“š Resources

Like ..