How to build a dynamic sitemap for your Next.js project


How to build a dynamic sitemap for Next.js

At the time of this writing, there are official docs on building a sitemap for your Next.js project that uses the newer app router pattern.

However, they're on the sparse side, and there's more than a little confusion going around about how to pull this off based on the npm packages and GitHub issues I encountered when doing my own research.

When you're finished this post, you'll have the knowledge and code you need to build a dynamic sitemap for your Next.js project that includes both 'static' routes such as /about or /home and dynamic routes such as /blog/[id]/page.tsx or /posts/[slug]/page.js.

You can also feel free to skip ahead to the implementation below. This is what I used to get a working sitemap that included all the routes I cared about and, more importantly, was accepted by Google Search's console / robots:

Google Search Console says our sitemap is valid!

Key things to understand

Here's the things I needed to know upfront when I wanted to build my own sitemap:

The Next.js file to route convention is supported

You can use the file-convention approach, meaning that a file that lives at src/app/sitemap.(js|ts)

Your file should export a default function - conventionally named sitemap. I've demonstrated this in the implementation example below.

You can test your sitemap locally

To test your sitemap, you can use curl like curl http://localhost:3000/sitemap.xml > /tmp/sitemap.xml and I recommend redirecting the output to a file as shown so that you can examine it more closely.

At a minimum, all of the URLs you want search engines and other readers of sitemaps to be aware of need to be present in this file and wrapped in <loc> or location tags.

If your sitemap is missing key URLs you're looking to get indexed, there's something wrong in your logic causing your routes to be excluded.

You can do print debugging and add logging statments throughout you sitemap file so that when you're running npm run dev and you curl your sitemap, you can see the output in your terminal to help you diagnose what's going on.

Next.js dynamic and static sitemap.xml example

The following implementation creates a sitemap out of my portfolio project.

It works by defining the root directory of the project (src/app), and then reading all the directories it finds there, applying the following rules:

  1. If the sub-directory is named blog or videos, it is a dynamic route, which needs to be recursively read for individual posts
  2. If the sub-directory is not named blog or videos, it is a static or top-level route, and processing can stop after obtaining the entry name. This will be the case for any top-level pages such as /photos or /subscribe, for example.

Note that if you're copy-pasting this into your own file, you need to update:

  1. your baseUrl variable, since it's currently pointing to my domain
  2. the names of your dynamic directories. For my current site version, I only have /blog and /videos set up as dynamic routes
  3. Your excludeDirs. I added this because I didn't want my API routes that support backend functionality in my sitemap
import fs from 'fs';
import path from 'path';

const baseUrl = process.env.SITE_URL || 'https://zackproser.com';
const baseDir = 'src/app';
const dynamicDirs = ['blog', 'videos']
const excludeDirs = ['api'];

function getRoutes() {
  const fullPath = path.join(process.cwd(), baseDir);
  const entries = fs.readdirSync(fullPath, { withFileTypes: true });
  let routes = [];

  entries.forEach(entry => {
    if (entry.isDirectory() && !excludeDirs.includes(entry.name)) {
      // Handle 'static' routes at the baseDir
      routes.push(`/${entry.name}`);

      // Handle dynamic routes 
      if (dynamicDirs.includes(entry.name)) {
        const subDir = path.join(fullPath, entry.name);
        const subEntries = fs.readdirSync(subDir, { withFileTypes: true });

        subEntries.forEach(subEntry => {
          if (subEntry.isDirectory()) {
            routes.push(`/${entry.name}/${subEntry.name}`);
          }
        });
      }
    }
  });

  return routes.map(route => ({
    url: `${baseUrl}${route}`,
    lastModified: new Date(),
    changeFrequency: 'weekly',
    priority: 1.0,
  }));
}

function sitemap() {
  return getRoutes();
}

export default sitemap;

That's it! Save this code to your src/app/sitemap.js file and test it as described above.

I hope this was helpful, and if it was, please consider subscribing to my newsletter below! 🙇