Creating your own sitemap module for Nuxt

Published
Tags
nuxtmodulesitemap

When you're dotting the i's and crossing the t's of a shiny new Nuxt website, you will almost certainly want to ensure your site has a sitemap so that search engines know what pages of your site to index. At the moment, the Nuxt sitemap module hasn't yet been updated for Nuxt 3. But that shouldn't hold you back; let's make a quick-n-dirty module to generate a sitemap.

Deciding on the requirements

Here's what we need to achieve for the sitemap I have in mind:

  1. It's for a static site - so no need to fetch pages at runtime. (If this is something you need, look below instead.)
  2. We want both raw XML and gzipped sitemaps.

Scaffolding a module

I routinely extract boilerplate out into VS Code snippets. Here's my snippet for a module. If you want to add it into your own settings, type Cmd-Shift-P, select Snippets: Configure User Snippets and then typescript.json (TypeScript).

snippets/typescript.json
{
  "Nuxt Module": {
    "prefix": "mod",
    "body": [
      "import { defineNuxtModule, useNuxt } from '@nuxt/kit'",
      "",
      "export default defineNuxtModule({",
      "  meta: {",
      "    name: '$1',",
      "  },",
      "  setup () {",
      "    const nuxt = useNuxt()",
      "    $2",
      "  },",
      "})"
    ]
  }
}

To be fair, this isn't much boilerplate, but still - it saves time.

Start by creating a new file in ~/modules/sitemap.ts and type mod + Tab to fill in the scaffolding. Hey presto - we have a Nuxt module!

The good news is that Nitro stores a list of all the routes that have been prerendered; all we need to do is get this list. We can do this by hooking into nitro:init to get access to the Nitro builder. It has its own hooks, and we can use the Nitro close hook to output our sitemap at the very end of the build process.

~/modules/sitemap.ts
const nuxt = useNuxt()
nuxt.hook('nitro:init', nitro => {
  nitro.hooks.hook('close', async () => {
    const routes = nitro._prerenderedRoutes
      // you might also have other logic to ensure only pages are included
      ?.filter(r => r.fileName?.endsWith('.html'))
      .map(r => r.route)

    if (!routes?.length) return
    // ...
  })
})

Now we just need to convert these routes into a sitemap and write it to disk. The good news is that it's not that complex of a file format, and we're not planning on taking advantage of any advanced features of the sitemap like <priority> or <lastmod> for our simple little static site. So something like this should work just fine:

const timestamp = new Date().toISOString()
const sitemap = [
  '<?xml version="1.0" encoding="UTF-8"?>',
  '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
  ...routes.map(route =>
    [
      '<url>',
      `  <loc>https://yourdomain.com${route}</loc>`,
      `  <lastmod>${timestamp}</lastmod>`,
      '</url>',
    ].join('')
  ),
  '</urlset>',
].join('')

Finally, all we need to do is write that to disk.

const dir = nitro.options.output.publicDir
await writeFile(join(dir, 'sitemap.xml'), sitemap)
await writeFile(join(dir, 'sitemap.xml.gz'), gzipSync(sitemap))

Here's the full module:

import { writeFile } from 'node:fs/promises'
import { gzipSync } from 'node:zlib'
import { defineNuxtModule, useNuxt } from '@nuxt/kit'
import { join } from 'pathe'

export default defineNuxtModule({
  meta: {
    name: 'sitemap',
  },
  setup() {
    const nuxt = useNuxt()
    nuxt.hook('nitro:init', nitro => {
      nitro.hooks.hook('close', async () => {
        const routes = nitro._prerenderedRoutes
          ?.filter(r => r.fileName?.endsWith('.html'))
          .map(r => r.route)
        if (!routes?.length) return
        const timestamp = new Date().toISOString()
        const sitemap = [
          `<?xml version="1.0" encoding="UTF-8"?>`,
          `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`,
          ...routes.map(
            route =>
              `<url><loc>https://yourdomain.com${route}</loc><lastmod>${timestamp}</lastmod></url>`
          ),
          `</urlset>`,
        ].join('')
        const dir = nitro.options.output.publicDir
        await writeFile(join(dir, 'sitemap.xml'), sitemap)
        await writeFile(join(dir, 'sitemap.xml.gz'), gzipSync(sitemap))
      })
    })
  },
})

Enabling the module

All you need to do to enable your new module is to add it to your nuxt.config file.

~/nuxt.config.ts
export default defineNuxtConfig({
  modules: ['~/modules/sitemap'],
})

Now you can run nuxi generate and check your .output/public folder to make sure that sitemap.xml and sitemap.xml.gz are present and correct!

A different approach for a dynamic sitemap

Alternatively, your website may be dynamic (for example, the page slugs may come from a CMS) or you may not be prerendering your routes. In this case, you can skip the module entirely.

Instead, create ~/server/routes/sitemap.xml.get.ts and add the following:

~/server/routes/sitemap.xml.get.ts
function ()
export default defineEventHandler(async event => {
  // perform async logic
  const routes = await fetchMyRoutesFromCMS()

  // copy the logic from the module above though you might consider,
  // if relevant, using your CMS's modified date for <lastmod> instead
  const timestamp = new Date().toISOString()
  const sitemap = [
    '<?xml version="1.0" encoding="UTF-8"?>',
    '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
    ...routes.map(
      route => [
          '<url>',
          `  <loc>https://yourdomain.com${route}</loc>`,
          `  <lastmod>${timestamp}</lastmod>`,
          '</url>'
        ].join('')
    ),
    '</urlset>',
  ].join('')

  setHeader(event, 'content-type', 'application/xml')
  return sitemap
})

You can then prerender this, if it isn't going to change, with a line in your config file:

export default defineNuxtConfig({
  nitro: {
    prerender: {
      routes: ['/sitemap.xml'],
    },
  },
})

If you need it to be dynamic but would benefit from light caching, you can use defineCachedEventHandler instead of defineEventHandler and Nitro will apply some optimisations for you.


Mentions

Loading