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 ..

coincidently, i was searching the same for my small content driven elixir app, thanks man.

Like ..