Building in Public#building-in-public

Adding Category Pages to Your Next.js Sitemap (The Right Way)

By Mohamed Abdi Guled

Category pages were built as real static routes, and the sitemap was updated in the same change to include them.

Adding Category Pages to Your Next.js Sitemap

When ShabelleHub launched, the sitemap covered the homepage, the tools directory, blog posts, and static pages. It did not include category pages — because category pages did not exist yet as static routes. They were client-side filtered views of a single page.

When we built real static category pages at /tools/category/[slug], we updated the sitemap at the same time. This article covers how the sitemap is generated, why category pages needed to be added explicitly, and what the complete implementation looks like.


How the Sitemap Works

ShabelleHub serves its sitemap at /sitemap.xml through a Next.js page at pages/sitemap.xml.js. This uses getServerSideProps to generate the XML on each request:

// pages/sitemap.xml.js
import { tools, blogPosts, categories } from '../data';
import { generateSitemapEntries } from '../lib/seo';

const BASE_URL = 'https://shabellehub.com';

function SitemapPage() { return null; }

export async function getServerSideProps({ res }) {
  const entries = generateSitemapEntries(tools, blogPosts, categories);

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${entries.map(e => `  <url>
    <loc>${BASE_URL}${e.url}</loc>
    <changefreq>${e.changefreq}</changefreq>
    <priority>${e.priority}</priority>
  </url>`).join('\n')}
</urlset>`;

  res.setHeader('Content-Type', 'application/xml');
  res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate');
  res.write(xml);
  res.end();

  return { props: {} };
}

export default SitemapPage;

The actual URL list is generated by generateSitemapEntries in lib/seo.js. This separation keeps the sitemap page thin and puts the logic where it belongs — in the shared library used by other SEO functions.


The Original generateSitemapEntries

Before category pages existed, the function was:

export function generateSitemapEntries(tools, blogPosts) {
  const static_ = [
    { url: '/',           changefreq: 'daily',   priority: 1.0 },
    { url: '/tools',      changefreq: 'daily',   priority: 0.9 },
    { url: '/blog',       changefreq: 'weekly',  priority: 0.8 },
    { url: '/about',      changefreq: 'monthly', priority: 0.5 },
    { url: '/contact',    changefreq: 'monthly', priority: 0.4 },
    { url: '/privacy',    changefreq: 'yearly',  priority: 0.3 },
    { url: '/disclosure', changefreq: 'yearly',  priority: 0.3 },
  ];

  const toolEntries = tools.map(t => ({
    url: `/tools/${t.slug}`,
    changefreq: 'weekly',
    priority: 0.8,
  }));

  const blogEntries = blogPosts.map(p => ({
    url: `/blog/${p.slug}`,
    changefreq: 'monthly',
    priority: 0.7,
  }));

  return [...static_, ...toolEntries, ...blogEntries];
}

This generated a correct sitemap for the existing pages. It had no awareness of category pages because they did not exist.


Adding Category Pages

When the static category pages were built, three updates were needed:

1. Import categoryToSlug into lib/seo.js:

import { categoryToSlug } from './categories';

2. Add a categories parameter to the function:

export function generateSitemapEntries(tools, blogPosts, categories = []) {

The default of [] makes the parameter optional — existing call sites that do not pass categories will not break.

3. Generate category entries:

const categoryEntries = categories
  .filter(c => c.name !== 'All')
  .map(c => ({
    url: `/tools/category/${categoryToSlug(c.name)}`,
    changefreq: 'weekly',
    priority: 0.7,
  }));

return [...static_, ...toolEntries, ...categoryEntries, ...blogEntries];

The complete updated function:

export function generateSitemapEntries(tools, blogPosts, categories = []) {
  const static_ = [
    { url: '/',           changefreq: 'daily',   priority: 1.0 },
    { url: '/tools',      changefreq: 'daily',   priority: 0.9 },
    { url: '/blog',       changefreq: 'weekly',  priority: 0.8 },
    { url: '/faq',        changefreq: 'monthly', priority: 0.6 },
    { url: '/about',      changefreq: 'monthly', priority: 0.5 },
    { url: '/contact',    changefreq: 'monthly', priority: 0.4 },
    { url: '/privacy',    changefreq: 'yearly',  priority: 0.3 },
    { url: '/terms',      changefreq: 'yearly',  priority: 0.3 },
    { url: '/disclosure', changefreq: 'yearly',  priority: 0.3 },
  ];

  const toolEntries = tools.map(t => ({
    url: `/tools/${t.slug}`,
    changefreq: 'weekly',
    priority: 0.8,
  }));

  const categoryEntries = categories
    .filter(c => c.name !== 'All')
    .map(c => ({
      url: `/tools/category/${categoryToSlug(c.name)}`,
      changefreq: 'weekly',
      priority: 0.7,
    }));

  const blogEntries = blogPosts.map(p => ({
    url: `/blog/${p.slug}`,
    changefreq: 'monthly',
    priority: 0.7,
  }));

  return [...static_, ...toolEntries, ...categoryEntries, ...blogEntries];
}

Priority Values

The priority field tells search engines the relative importance of pages on the site, on a scale from 0.0 to 1.0. It is a hint, not an instruction — Google may ignore it — but setting it thoughtfully reflects the actual structure of the site:

| Page type | Priority | Reasoning |

|-----------|----------|-----------|

| Homepage | 1.0 | Entry point for all traffic |

| Tools directory | 0.9 | Primary content hub |

| Blog index | 0.8 | Secondary content hub |

| Individual tool pages | 0.8 | Core content — one per tool |

| FAQ | 0.6 | Useful but not primary |

| Category pages | 0.7 | Valuable for SEO, updated frequently |

| Blog posts | 0.7 | Content that ages |

| About, Contact | 0.4–0.5 | Trust pages, rarely updated |

| Legal pages | 0.3 | Required but low search value |

Category pages at 0.7 reflects that they are genuinely useful landing pages for category-level search queries, but less central than individual tool pages.


changefreq Values

changefreq tells crawlers how often a page's content is likely to change:

  • daily — the homepage and tools directory may show new featured content daily
  • weekly — tool pages and category pages are reviewed and updated periodically
  • monthly — blog posts and FAQ entries change occasionally
  • yearly — legal pages change rarely

These are approximations. The sitemap does not guarantee that a crawler will respect them. Their main value is as a signal during the initial crawl of new pages — a page marked daily is more likely to be recrawled quickly after an update than one marked yearly.


Verifying the Sitemap

After deploying, we verified the sitemap at shabellehub.com/sitemap.xml and confirmed:

    • All 12 category URLs were present
    • All 50 tool URLs were present
    • All blog post URLs were present
    • Static pages including /faq and /terms were present
    • No duplicate URLs existed

The total entry count after the update:

9 static pages
+ 50 tool pages
+ 12 category pages
+ 24+ blog posts
= 95+ sitemap entries

We then submitted the sitemap URL to Google Search Console under the Coverage → Sitemaps section. Google processes submitted sitemaps faster than sitemaps discovered through crawling.


The robots.txt Connection

The sitemap only works if search engines know it exists. ShabelleHub's public/robots.txt references it:

User-agent: *
Allow: /

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

The Sitemap: directive tells any crawler that reads robots.txt where to find the sitemap. This is the standard discovery mechanism alongside Search Console submission.


What Happens When a New Category Is Added

With this architecture, adding a new category to the data/index.js categories array automatically:

    • Creates a new static page at /tools/category/[slug] via getStaticPaths
    • Adds a footer link via the dynamic footer
    • Adds a sitemap entry via generateSitemapEntries

One change in one file. No manual sitemap editing. No forgotten footer links. The same single-source-of-truth pattern applied to a third surface.


*This article is part of the ShabelleHub Building in Public series.*

*Next: Fixing Optional Chaining Runtime Crashes in React*

📬 Get the latest AI tool reviews

Expert picks and comparisons, weekly. No spam.

← Back to Blog