Simple sitemaps in tanstack-start

Sitemap generation with @unom/vite-sitemap-plugin

If you're running a Vite-based app with TanStack Start and want a solid SEO foundation, you quickly hit two perennials: sitemap.xml and robots.txt. TanStack Start's built-in sitemap support is still very rudimentary at the time of writing — no changefreq or priority, and the result often lands somewhere you don't actually want it.

So I built @unom/vite-sitemap-plugin — a small Vite plugin that reliably produces both files at build time, is fully configurable, and supports i18n via hreflang from the start.

The package isn't published on npmjs.org — it's available through my self-hosted Gitea registry at git.unom.io/unom/vite-sitemap-plugin.


What the plugin does

  • Writes sitemap.xml in Sitemap 0.9 format to the output directory
  • Writes robots.txt with configurable Allow/Disallow rules
  • Supports i18n routing with <xhtml:link rel="alternate" hreflang="…"> for every locale/path combination
  • Allows per-route changefreq, priority and lastmod values
  • Can be fully customised with a custom formatUrl function
  • No runtime dependencies — pure build-time plugin

Requirements

  • Node.js ≥ 18 or Bun
  • Vite ≥ 4 (peer dependency)

Step 1: Configure the registry

Since the package isn't on npmjs.org, the Gitea registry needs to be set as the source for the @unom scope.

With npm:

npm config set @unom:registry https://git.unom.io/api/packages/unom/npm/

With pnpm:

pnpm config set @unom:registry https://git.unom.io/api/packages/unom/npm/

Or directly in the .npmrc at the project root:

@unom:registry=https://git.unom.io/api/packages/unom/npm/

That way the package manager knows that all packages under @unom/* should be sourced from this registry — everything else still resolves through the default npmjs.org registry.


Step 2: Install the package


bun


bun add -D @unom/vite-sitemap-plugin


npm


npm install -D @unom/vite-sitemap-plugin


pnpm


pnpm add -D @unom/vite-sitemap-plugin


Step 3: Wire the plugin into vite.config.ts

The plugin gets added to the plugins array like any other Vite plugin:


vite.config.ts
import { defineConfig } from "vite";
import { sitemapPlugin } from "@unom/vite-sitemap-plugin";

export default defineConfig({
  plugins: [
    sitemapPlugin({
      siteUrl: "https://example.com",
      routes: [
        "",
        "/about",
        "/contact",
        { path: "/blog", changefreq: "weekly", priority: 0.8 },
      ],
    }),
  ],
});


After the next build (vite build) you'll find sitemap.xml and robots.txt in the output directory (defaults to public/).


Step 4: robots.txt configuration

By default the plugin produces a robots.txt with no restrictions. The robots option lets you customise that:


vite.config.ts
sitemapPlugin({
  siteUrl: "https://example.com",
  routes: ["", "/about"],
  robots: {
    userAgent: "*",        // Standard: "*"
    disallow: ["/admin/", "/api/"],
    allow: ["/api/public/"],
    extraLines: [
      "Crawl-delay: 10",
    ],
  },
})


The generated robots.txt then looks like this:


robots.txt
User-agent: *
Disallow: /admin/
Disallow: /api/
Allow: /api/public/
Crawl-delay: 10
Sitemap: https://example.com/sitemap.xml


If you don't want a robots.txt at all, pass robots: false.


Step 5: Multilingual sites with hreflang

For multilingual projects this is the main reason the plugin exists. With the locales option the plugin emits a separate <url> entry for every locale/path combination, including all <xhtml:link rel="alternate"> references:

vite.config.ts
sitemapPlugin({
  siteUrl: "https://example.com",
  locales: ["de", "en", "fr"],
  defaultLocale: "de",
  routes: [
    "",
    "/ueber-uns",
    "/kontakt",
    { path: "/blog", changefreq: "weekly", priority: 0.9 },
  ],
})


That produces URLs in the form https://example.com/de/, https://example.com/en/, https://example.com/fr/ etc. Each <url> block then carries the full hreflang alternates plus an x-default pointing to the defaultLocale.

Excerpt from the resulting sitemap.xml:


sitemap.xml
<url>
  <loc>https://example.com/de/</loc>
  <lastmod>2026-05-12</lastmod>
  <xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/"/>
  <xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/"/>
  <xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/"/>
  <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/de/"/>
</url>



Step 6: Customising the URL format

By default the plugin builds URLs following the pattern ${siteUrl}/${locale}${path}. If your own routing looks different — e.g. de.example.com instead of example.com/de/ or query parameters instead of path segments — that can be fully overridden via formatUrl:


vite.config.ts
sitemapPlugin({
  siteUrl: "https://example.com",
  locales: ["de", "en"],
  defaultLocale: "de",
  routes: ["", "/about"],
  formatUrl: (locale, path) =>
    locale === "de"
      ? `https://example.com${path}`        // Deutsch ohne Locale-Prefix
      : `https://${locale}.example.com${path}`, // Subdomains für andere Locales
})




All options at a glance

Option

Type

Default

Description

siteUrl

string

(required)

Base URL of the site

routes

Array<string | SitemapEntry>

(required)

List of paths to index

locales

string[]

not set

Locale codes for i18n hreflang

defaultLocale

string

first locale

Locale for the x-default hreflang

formatUrl

(locale, path) => string

${siteUrl}/${locale}${path}

Custom URL formatter

lastmod

string | Date

build time

Global lastmod for all entries

outDir

string

"public"

Output directory

sitemapFilename

string

"sitemap.xml"

Sitemap filename

robotsFilename

string

"robots.txt"

robots.txt filename

robots

RobotsOptions | false

{}

robots.txt configuration

SitemapEntry accepts: path, lastmod, changefreq, priority.

RobotsOptions accepts: userAgent, disallow, allow, extraLines.


Full example

A realistic setup for a German-language primary site with English translation, a blog and careers pages:


vite.config.ts
import { defineConfig } from "vite";
import { sitemapPlugin } from "@unom/vite-sitemap-plugin";

export default defineConfig({
  plugins: [
    sitemapPlugin({
      siteUrl: "https://meineprojekt.de",
      locales: ["de", "en"],
      defaultLocale: "de",
      routes: [
        { path: "",        changefreq: "daily",   priority: 1.0 },
        { path: "/about",  changefreq: "monthly", priority: 0.5 },
        { path: "/blog",   changefreq: "weekly",  priority: 0.8 },
        { path: "/contact",changefreq: "monthly", priority: 0.6 },
      ],
      robots: {
        disallow: ["/admin/", "/api/internal/"],
      },
    }),
  ],
});




Why not just vite-plugin-sitemap?

There are a few sitemap plugins for Vite already. Issues I ran into:

  • No or weak hreflang handling for multilingual projects
  • No control over robots.txt from the same plugin
  • TanStack Start's own sitemap support is still very early — no configuration knobs, no hreflang
  • I wanted a plugin I fully understand and control, with no runtime overhead

The result is a plugin in ~200 lines of TypeScript, with no external dependencies, and exactly the options I needed.