Skip to content

jasongitmail/super-sitemap

Repository files navigation

Super Sitemap

Sitemap focused on ease of use
and making it impossible to forget to add your paths.
🎉NEW v2.0: For TanStack Start and SvelteKit.

license badge npm badge unit tests badge

Table of Contents

Features

  • 🤓 Supports any rendering method.
  • 🪄 Automatically gathers routes + data for route parameters provided by you.
  • 👻 Exclude routes via excludeRoutePatterns (e.g. /^\/dashboard/, paginated routes, etc)
  • 🧠 Easy maintenance. Accidental omission of data for a parameterized route throws an error until either, a.) the route is excluded via excludeRoutePatterns, or b.) data is provided for its param value(s).
  • 🚀 Defaults to 1h CDN cache, no browser cache.
  • 💆 Set custom headers to override default headers: sitemap.response({ headers: { 'cache-control': 'max-age=0, s-maxage=60' } }).
  • 💡 Google, and other modern search engines, ignore priority and changefreq and use their own heuristics to determine when to crawl pages on your site. As such, these properties are not included by default to minimize KB size and enable faster crawling. Optionally, you can enable them via: sitemap.response({ defaultChangefreq: 'daily', defaultPriority: 0.7 }).
  • 🗺️ Automatic sitemap index.
  • 🌎 i18n
  • 🧪 Well tested.
  • ✨ Zero runtime dependencies.
  • 🫶 Built with TypeScript.

Installation

npm i super-sitemap

or

bun add super-sitemap

Then see the Usage, Robots.txt, & Playwright Test sections.

Usage

Basic example

TanStack Start

// /src/routes/sitemap[.]xml.ts
import { createFileRoute } from '@tanstack/react-router';
import { response } from 'super-sitemap/tanstack-start';
import { getRouter } from '../router';

export const Route = createFileRoute('/sitemap.xml')({
  server: {
    handlers: {
      GET: () =>
        response({
          origin: 'https://example.com',
          router: getRouter,
        }),
    },
  },
});

SvelteKit

// /src/routes/sitemap.xml/+server.ts
import type { RequestHandler } from '@sveltejs/kit';
import { response } from 'super-sitemap/sveltekit';

export const GET: RequestHandler = async () => {
  return await response({
    origin: 'https://example.com',
  });
};
  • Always include the .xml extension on your route name–e.g. sitemap.xml. This ensures your web server sends the correct application/xml content type even if you decide to prerender your sitemap to a static file.
  • Automatic route discovery:
    • The SvelteKit adapter discovers routes using Vite's import.meta.glob.
    • The TanStack Start adapter discovers routes via TanStack Start's official getRouter, which is derived from its generated route manifest file. This means that all TanStack Start routing methods are fully supported: file-based routing, code-based routing, or virtual file routes.
  • For all frameworks: server-only routes are excluded automatically and do not need to be listed in your route exclusions.

The "everything" example

All config properties shown here are optional, except for origin and paramValues to provide data for parameterized routes.

TanStack Start example
// /src/routes/sitemap[.]xml.ts
import { createFileRoute } from '@tanstack/react-router';
import * as blog from '../lib/data/blog';
import * as sitemap from 'super-sitemap/tanstack-start';
import { getRouter } from '../router';

export const Route = createFileRoute('/sitemap.xml')({
  server: {
    handlers: {
      GET: async () => {
        // Get data for parameterized routes however you need to; this is only
        // an example.
        let blogSlugs, blogTags;
        try {
          [blogSlugs, blogTags] = await Promise.all([blog.getSlugs(), blog.getTags()]);
        } catch (err) {
          throw new Error('Could not load data for param values.');
        }

        return await sitemap.response({
          origin: 'https://example.com',
          router: getRouter,
          locales: {
            default: 'en',
            alternates: ['de', 'pt-br'],
          },
          excludeRoutePatterns: [
            /^\/dashboard/, // i.e. routes starting with `/dashboard`
            /\{\-\$page\}/, // i.e. route keys containing `{-$page}`–e.g. `/blog/{-$page}`
            /^\/admin(?:$|\/)/, // i.e. routes within an admin section
          ],
          paramValues: {
            // paramValues can be a 1D array of strings
            '/blog/$slug': blogSlugs, // e.g. ['hello-world', 'another-post']
            '/blog/tag/$tag': blogTags, // e.g. ['red', 'green', 'blue']

            // Or a 2D array of strings
            '/campsites/$country/$state': [
              ['usa', 'new-york'],
              ['usa', 'california'],
              ['canada', 'toronto'],
            ],

            // Or an array of ParamValue objects
            '/athlete-rankings/$country/$state': [
              {
                values: ['usa', 'new-york'], // required
                lastmod: '2025-01-01T00:00:00Z', // optional
                changefreq: 'daily', // optional
                priority: 0.5, // optional
              },
              {
                values: ['usa', 'california'],
                lastmod: '2025-01-01T00:00:00Z',
                changefreq: 'daily',
                priority: 0.5,
              },
            ],
          },
          headers: {
            'custom-header': 'foo', // case insensitive; xml content type & 1h CDN cache headers are included by default
          },
          additionalPaths: [
            '/foo.pdf', // for example, to a file in your public dir
          ],
          defaultChangefreq: 'daily',
          defaultPriority: 0.7,
          sort: 'alpha', // default is false; 'alpha' sorts paths alphabetically.
          processPaths: (paths) => {
            // Optional callback to allow arbitrary processing of your path objects. See the
            // processPaths() section of the README.
            return paths;
          },
        });
      },
    },
  },
});
SvelteKit example
// /src/routes/sitemap.xml/+server.ts
import { error } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import * as blog from '$lib/data/blog';
import * as sitemap from 'super-sitemap/sveltekit';

export const prerender = true; // optional

export const GET: RequestHandler = async () => {
  // Get data for parameterized routes however you need to; this is only an
  // example.
  let blogSlugs, blogTags;
  try {
    [blogSlugs, blogTags] = await Promise.all([blog.getSlugs(), blog.getTags()]);
  } catch (err) {
    throw error(500, 'Could not load data for param values.');
  }

  return await sitemap.response({
    origin: 'https://example.com',
    locales: {
      default: 'en',
      alternates: ['de', 'pt-br'],
    },
    excludeRoutePatterns: [
      /^\/dashboard/, // i.e. routes starting with `/dashboard`
      /\[page=integer\]/, // i.e. route keys containing `[page=integer]`–e.g. `/blog/[page=integer]`
      /\(authenticated\)/, // i.e. routes within a group
    ],
    paramValues: {
      // paramValues can be a 1D array of strings
      '/blog/[slug]': blogSlugs, // e.g. ['hello-world', 'another-post']
      '/blog/tag/[tag]': blogTags, // e.g. ['red', 'green', 'blue']

      // Or a 2D array of strings
      '/campsites/[country]/[state]': [
        ['usa', 'new-york'],
        ['usa', 'california'],
        ['canada', 'toronto'],
      ],

      // Or an array of ParamValue objects
      '/athlete-rankings/[country]/[state]': [
        {
          values: ['usa', 'new-york'], // required
          lastmod: '2025-01-01T00:00:00Z', // optional
          changefreq: 'daily', // optional
          priority: 0.5, // optional
        },
        {
          values: ['usa', 'california'],
          lastmod: '2025-01-01T00:00:00Z',
          changefreq: 'daily',
          priority: 0.5,
        },
      ],
    },
    headers: {
      'custom-header': 'foo', // case insensitive; xml content type & 1h CDN cache headers are included by default
    },
    additionalPaths: [
      '/foo.pdf', // for example, to a file in your static dir
    ],
    defaultChangefreq: 'daily',
    defaultPriority: 0.7,
    sort: 'alpha', // default is false; 'alpha' sorts paths alphabetically.
    processPaths: (paths) => {
      // Optional callback to allow arbitrary processing of your path objects. See the
      // processPaths() section of the README.
      return paths;
    },
  });
};

Sitemap Index

You only need to read and enable if you have >50,000 URLs in your sitemap, which is the number recommended by sitemaps.org.

You can enable sitemap index support with just two changes.

See the Sitemap Index docs.

Param Values

Routes that contain parameters need to have their values defined. You can provide these values as: string[], string[][], or ParamValue[].

TanStack Start example
paramValues: {
  // Required params use TanStack's `$param` syntax.
  '/blog/$slug': ['hello-world', 'another-post'],

  // Optional params use TanStack's `{-$param}` syntax.
  '/products/{-$category}': ['shoes', 'shirts'],

  // Multiple params use a 2D array, matched positionally.
  '/campsites/$country/$state': [
    ['usa', 'colorado'],
    ['canada', 'toronto'],
  ],

  // Splat/rest params use TanStack's bare `$` segment.
  '/docs/$': ['intro/getting-started'],

  // Keep locale segments in the key, but omit locale values from the value
  // array because locale values are specified once at the config level.
  '/$locale/blog/$slug': ['hello-world'],
  '/{-$locale}/docs/$slug': ['intro'],

  // Pathless layout segments and route group directories are omitted from keys.
  // For example, `/_layout/(dashboard)/users/$id` is keyed as:
  '/users/$id': ['42'],

  // Optional params expand into route variants. The base route (`/foo`)
  // needs no values, but dynamic variants need values unless excluded. For
  // multiple optional params, provide values for each emitted dynamic variant
  // that you keep.
  '/foo/{-$paramA}': ['foo', 'bar'],
  '/foo/{-$paramA}/{-$paramB}': [
    ['foo', 'one'],
    ['bar', 'two'],
  ],

  // If you need per-entry metadata, use ParamValue objects.
  '/athlete-rankings/$country/$state': [
    {
      values: ['usa', 'new-york'], // required
      lastmod: '2025-01-01T00:00:00Z', // optional
      changefreq: 'daily', // optional
      priority: 0.5, // optional
    },
    {
      values: ['usa', 'california'],
      lastmod: '2025-01-01T01:16:52Z',
      changefreq: 'daily',
      priority: 0.5,
    },
  ],
},
SvelteKit example
paramValues: {
  // Required params use SvelteKit's `[param]` syntax.
  '/blog/[slug]': ['hello-world', 'another-post'],

  // Optional params use SvelteKit's `[[param]]` syntax.
  '/products/[[category]]': ['shoes', 'shirts'],

  // Matcher params preserve the matcher name in the key.
  '/blog/[page=integer]': ['2', '3'],
  '/archive/[[year=integer]]': ['2024', '2025'],

  // Multiple params use a 2D array, matched positionally.
  '/campsites/[country]/[state]': [
    ['usa', 'colorado'],
    ['canada', 'toronto'],
  ],

  // Rest params use SvelteKit's `[...rest]` syntax.
  '/docs/[...rest]': ['intro/getting-started'],

  // Keep locale segments in the key, but omit locale values from the value
  // array because locale values are specified once at the config level.
  '/[[locale]]/blog/[slug]': ['hello-world'],
  '/[locale]/docs/[slug]': ['intro'],

  // Route groups are omitted from keys.
  // For example, `/(dashboard)/users/[id]` is keyed as:
  '/users/[id]': ['42'],

  // Optional params expand into route variants. The base route (`/foo`)
  // needs no values, but dynamic variants need values unless excluded.
  '/foo/[[paramA]]': ['foo', 'bar'],
  '/foo/[[paramA]]/[[paramB]]': [
    ['foo', 'one'],
    ['bar', 'two'],
  ],

  // If you need per-entry metadata, use ParamValue objects.
  '/athlete-rankings/[country]/[state]': [
    {
      values: ['usa', 'new-york'], // required
      lastmod: '2025-01-01T00:00:00Z', // optional
      changefreq: 'daily', // optional
      priority: 0.5, // optional
    },
    {
      values: ['usa', 'california'],
      lastmod: '2025-01-01T01:16:52Z',
      changefreq: 'daily',
      priority: 0.5,
    },
  ],
},

Keys for Param Values

Keys in the paramValues object must match Super Sitemap's expected syntax.

View allowed keys
Route feature TanStack Start key SvelteKit key
Required param '/blog/$slug' '/blog/[slug]'
Optional param '/products/{-$category}' '/products/[[category]]'
Required params (2+) '/campsites/$country/$state' '/campsites/[country]/[state]'
Optional params (2+) On disk: /foo/{-$paramA}/{-$paramB}
Use: '/foo/{-$paramA}'
'/foo/{-$paramA}/{-$paramB}'
On disk: /foo/[[paramA]]/[[paramB]]
Use: '/foo/[[paramA]]'
'/foo/[[paramA]]/[[paramB]]'
Splat / rest param '/docs/$' '/docs/[...rest]'
Param matcher (No equivalent) '/blog/[page=integer]'
Optional matcher (No equivalent) '/archive/[[year=integer]]'
Route groups are omitted On disk: /(dashboard)/users/$id
Use: '/users/$id'
On disk: /(dashboard)/users/[id]
Use: '/users/[id]'
Pathless layout segments are omitted On disk: /_layout/users/$id
Use: '/users/$id'
(No equivalent)
Optional locale param '/{-$locale}/blog/$slug' '/[[locale]]/blog/[slug]'
Required locale param '/$locale/docs/$slug' '/[locale]/docs/[slug]'

Syntax differs between framework adapters (TanStack Start, SvelteKit), a.) to support framework-specific features (like SvelteKit's param matchers or TanStack Start's pathless layout segments), and b.) to remain close to how each framework defines its routes.

If in doubt, call your sitemap route in a test. For prerendered sitemaps, also build your app. You'll see errors for any keys that are missing or don't match what Super Sitemap expects, so you can correct them.

Route Exclusions

Use excludeRoutePatterns to remove routes before paths are generated.

excludeRoutePatterns matches against route keys, which are the same keys used by paramValues; see Keys for Param Values.

excludeRoutePatterns: [/^\/dashboard(?:$|\/)/],

This excludes /dashboard and any route below it, like /dashboard/settings.

Routing organization segments, like pathless layout segments and route groups, are not present in route keys and cannot be matched against.

Optional Params

You only need to read this if you want to understand how Super Sitemap handles optional params and why.

Optional params expand into route variants. Super Sitemap will include each path variation and will require you to either exclude those route patterns using excludeRoutePatterns or provide param values for them using paramValues, within your sitemap config object.

See the Optional Params docs to learn more.

processPaths() callback

The processPaths() callback is powerful, but rarely needed.

It allows you to arbitrarily process the path objects for your site before they become XML.

See the Process Paths docs.

i18n

Super Sitemap supports multilingual site annotations. This allows search engines to be aware of alternate language versions of your pages.

See the i18n docs.

Get Sample Paths

getSamplePaths() is useful when you want one visitable path for each public route shape, usually for testing or monitoring purposes.

See the Get Sample Paths docs.

Robots.txt

Create a robots.txt so search engines know where to find your sitemap.

Create it at:

  • SvelteKit: /static/robots.txt
  • TanStack Start: /public/robots.txt
User-agent: *
Allow: /

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

Playwright Test

It's recommended to set up an e2e test, like Playwright, that calls your sitemap route.

Why? For pre-rendered sitemaps, paramValues are loaded at build time, so misconfigurations fail during the build. But for non-prerendered sitemaps, paramValues are loaded at runtime, so a functional test is necessary to catch configuration mistakes before deployment.

Playwright example
// /src/tests/sitemap.test.js

import { expect, test } from '@playwright/test';

test('/sitemap.xml is valid', async ({ page }) => {
  const response = await page.goto('/sitemap.xml');
  expect(response.status()).toBe(200);

  // Ensure XML is valid. Playwright parses the XML here and will error if it
  // cannot be parsed.
  const urls = await page.$$eval('url', (urls) =>
    urls.map((url) => ({
      loc: url.querySelector('loc').textContent,
      // changefreq: url.querySelector('changefreq').textContent, // if you enabled in your sitemap
      // priority: url.querySelector('priority').textContent,
    }))
  );

  // Sanity check
  expect(urls.length).toBeGreaterThan(5);

  // Ensure entries are in a valid format.
  for (const url of urls) {
    expect(url.loc).toBeTruthy();
    expect(() => new URL(url.loc)).not.toThrow();
    // expect(url.changefreq).toBe('daily');
    // expect(url.priority).toBe('0.7');
  }
});

Tip: Querying your database to get param values

Examples of how to query an SQL database to obtain data to provide as paramValues for your routes:

-- Route: /blog/[slug]
SELECT slug FROM blog_posts WHERE status = 'published';

-- Route: /blog/tag/[tag]
SELECT DISTINCT LOWER(category) FROM blog_posts WHERE status = 'published';

-- Route: /campsites/[country]/[state]
SELECT DISTINCT LOWER(country), LOWER(state) FROM campsites;

Using DISTINCT prevents duplicates in your result set. Use this when your table could contain multiple rows with the same params, like in the 2nd and 3rd examples.

Convert the result into a supported param value type. This example uses a 2D array:

const arrayOfArrays = resultFromDB.map((row) => Object.values(row));
// [['usa','new-york'],['usa', 'california']]

Then provide these values within your sitemap config's paramValues object for the appropriate route.

Example sitemap output

Click to expand
  <urlset
    xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
    xmlns:xhtml="http://www.w3.org/1999/xhtml"
  >
    <url>
        <loc>https://example.com/</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/about</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/blog</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/login</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/pricing</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/privacy</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/signup</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/support</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/terms</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/blog/hello-world</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/blog/another-post</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/blog/tag/red</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/blog/tag/green</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/blog/tag/blue</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/campsites/usa/new-york</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/campsites/usa/california</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/campsites/canada/toronto</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://example.com/foo.pdf</loc>
        <changefreq>daily</changefreq>
        <priority>0.7</priority>
    </url>
</urlset>

Migrating from v1 to v2

  • Use the new, framework-specific import:
    • import * as sitemap from 'super-sitemap/sveltekit', or
    • import * as sitemap from 'super-sitemap/tanstack-start'
  • lang was renamed to locales.
    • Use locales: { default: 'en', alternates: ['de'] }.
  • Dynamic locale route params must be named locale.
    • SvelteKit: use [[locale]], not [[lang]].
    • TanStack Start: use {-$locale}.
  • excludeRoutePatterns now uses JavaScript regex literals, not strings.
    • E.g. Use /^\/dashboard/, not "^/dashboard".
  • excludeRoutePatterns now matches against valid paramValues keys.
    • route groups like (authenticated) cannot be matched against.
  • sampledUrls() and sampledPaths() were removed.

Changelog

  • 2.0.5 - Fixed paramValues validation to allow empty arrays.
  • 2.0.0 - BREAKING: Added framework-specific adapters for SvelteKit and TanStack Start. See Migrating from v1 to v2.
    • Public APIs now live at super-sitemap/sveltekit and super-sitemap/tanstack-start.
    • The lang config option was renamed to locales; dynamic locale route params must be named locale.
    • excludeRoutePatterns now accepts RegExp objects and matches normalized route keys.
    • sampledUrls() and sampledPaths() were removed; use getSamplePaths().
  • 1.0.11 - Remove all runtime dependencies!
  • 1.0.0 - BREAKING: priority renamed to defaultPriority, and changefreq renamed to defaultChangefreq. NON-BREAKING: Support for paramValues to contain either string[], string[][], or ParamValue[] values to allow per-path specification of lastmod, changefreq, and priority.
  • 0.15.0 - BREAKING: Rename excludePatterns to excludeRoutePatterns.
  • 0.14.20 - Adds processPaths() callback.
  • 0.14.19 - Support .md and .svx route extensions for mdsvex users.
  • 0.14.17 - Support for param matchers (e.g. [[lang=lang]]) & required lang params (e.g. [lang]). Thanks @JadedBlueEyes & @epoxide!
  • 0.14.13 - Support route files named to allow breaking out of a layout.
  • 0.14.12 - Adds i18n support.
  • 0.14.11 - Adds optional params support.
  • 0.14.0 - Adds sitemap index support.
  • 0.13.0 - Added legacy sampledUrls() and sampledPaths() utilities.
  • 0.12.0 - Adds config option to sort 'alpha' or false (default).
  • 0.11.0 - BREAKING: Rename to super-sitemap on npm! 🚀
  • 0.10.0 - Adds ability to use unlimited dynamic params per route! 🎉
  • 0.9.0 - BREAKING: Adds configurable changefreq and priority and excludes these by default. See the README's features list for why.
  • 0.8.0 - Adds ability to specify additionalPaths that live outside /src/routes, such as /foo.pdf located at /static/foo.pdf.

Contributing

git clone https://github.com/jasongitmail/super-sitemap.git
bun install
bun run ready     # lint, format, typecheck, and tests

Runnable example apps live in examples/sveltekit and examples/tanstack-start. Each is a self-contained app that imports the library from source and serves as both an integration test and a dev playground:

cd examples/sveltekit   # or examples/tanstack-start
bun install
bun run test            # end-to-end sitemap tests against the real framework
bun run dev             # browse the example, including /sitemap.xml

Publishing

Run the interactive release helper:

bun run release

Choose the semver type with arrow keys. patch is selected by default.

After publish, push the release commit and tag:

git push origin main --follow-tags

Confirm the published npm version:

npm view super-sitemap version time.modified dist-tags

Credits

About

Sitemap focused on ease of use and making it impossible to forget to add your paths. For TanStack Start & SvelteKit.

Topics

Resources

License

Stars

263 stars

Watchers

2 watching

Forks

Contributors