Skip to content

feat(router-sitemap): Add sitemap package for generating sitemap xml #4543

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/react/basic/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ dist-ssr
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.cache/
public/sitemap.xml
1 change: 1 addition & 0 deletions examples/react/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"dependencies": {
"@tanstack/react-router": "^1.123.2",
"@tanstack/react-router-devtools": "^1.123.2",
"@tanstack/router-sitemap": "^1.121.37",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"redaxios": "^0.5.1",
Expand Down
30 changes: 29 additions & 1 deletion examples/react/basic/vite.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { sitemapPlugin } from '@tanstack/router-sitemap/vite-plugin'
import { fetchPosts } from './src/posts'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
sitemapPlugin({
sitemap: {
siteUrl: 'http://localhost:3000',
priority: 0.5,
changefreq: 'weekly',
routes: [
'/',
'/posts',
[
'/posts/$postId',
async () => {
const posts = await fetchPosts()
return posts.map((post) => ({
path: `/posts/${post.id}`,
priority: 0.8,
changefreq: 'daily',
}))
},
],
'/route-a',
'/route-b',
],
},
}),
],
})
1 change: 1 addition & 0 deletions examples/react/start-basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"@tanstack/react-router": "^1.123.2",
"@tanstack/react-router-devtools": "^1.123.2",
"@tanstack/react-start": "^1.123.2",
"@tanstack/router-sitemap": "^1.121.37",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0",
Expand Down
33 changes: 30 additions & 3 deletions examples/react/start-basic/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathle
import { Route as PostsPostIdDeepRouteImport } from './routes/posts_.$postId.deep'
import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b'
import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a'
import { ServerRoute as SitemapDotxmlServerRouteImport } from './routes/sitemap[.]xml'
import { ServerRoute as CustomScriptDotjsServerRouteImport } from './routes/customScript[.]js'
import { ServerRoute as ApiUsersServerRouteImport } from './routes/api/users'
import { ServerRoute as ApiUsersUserIdServerRouteImport } from './routes/api/users.$userId'
Expand Down Expand Up @@ -102,6 +103,11 @@ const PathlessLayoutNestedLayoutRouteARoute =
path: '/route-a',
getParentRoute: () => PathlessLayoutNestedLayoutRoute,
} as any)
const SitemapDotxmlServerRoute = SitemapDotxmlServerRouteImport.update({
id: '/sitemap.xml',
path: '/sitemap.xml',
getParentRoute: () => rootServerRouteImport,
} as any)
const CustomScriptDotjsServerRoute = CustomScriptDotjsServerRouteImport.update({
id: '/customScript.js',
path: '/customScript.js',
Expand Down Expand Up @@ -217,30 +223,43 @@ export interface RootRouteChildren {
}
export interface FileServerRoutesByFullPath {
'/customScript.js': typeof CustomScriptDotjsServerRoute
'/sitemap.xml': typeof SitemapDotxmlServerRoute
'/api/users': typeof ApiUsersServerRouteWithChildren
'/api/users/$userId': typeof ApiUsersUserIdServerRoute
}
export interface FileServerRoutesByTo {
'/customScript.js': typeof CustomScriptDotjsServerRoute
'/sitemap.xml': typeof SitemapDotxmlServerRoute
'/api/users': typeof ApiUsersServerRouteWithChildren
'/api/users/$userId': typeof ApiUsersUserIdServerRoute
}
export interface FileServerRoutesById {
__root__: typeof rootServerRouteImport
'/customScript.js': typeof CustomScriptDotjsServerRoute
'/sitemap.xml': typeof SitemapDotxmlServerRoute
'/api/users': typeof ApiUsersServerRouteWithChildren
'/api/users/$userId': typeof ApiUsersUserIdServerRoute
}
export interface FileServerRouteTypes {
fileServerRoutesByFullPath: FileServerRoutesByFullPath
fullPaths: '/customScript.js' | '/api/users' | '/api/users/$userId'
fullPaths:
| '/customScript.js'
| '/sitemap.xml'
| '/api/users'
| '/api/users/$userId'
fileServerRoutesByTo: FileServerRoutesByTo
to: '/customScript.js' | '/api/users' | '/api/users/$userId'
id: '__root__' | '/customScript.js' | '/api/users' | '/api/users/$userId'
to: '/customScript.js' | '/sitemap.xml' | '/api/users' | '/api/users/$userId'
id:
| '__root__'
| '/customScript.js'
| '/sitemap.xml'
| '/api/users'
| '/api/users/$userId'
fileServerRoutesById: FileServerRoutesById
}
export interface RootServerRouteChildren {
CustomScriptDotjsServerRoute: typeof CustomScriptDotjsServerRoute
SitemapDotxmlServerRoute: typeof SitemapDotxmlServerRoute
ApiUsersServerRoute: typeof ApiUsersServerRouteWithChildren
}

Expand Down Expand Up @@ -348,6 +367,13 @@ declare module '@tanstack/react-router' {
}
declare module '@tanstack/react-start/server' {
interface ServerFileRoutesByPath {
'/sitemap.xml': {
id: '/sitemap.xml'
path: '/sitemap.xml'
fullPath: '/sitemap.xml'
preLoaderRoute: typeof SitemapDotxmlServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/customScript.js': {
id: '/customScript.js'
path: '/customScript.js'
Expand Down Expand Up @@ -452,6 +478,7 @@ export const routeTree = rootRouteImport
._addFileTypes<FileRouteTypes>()
const rootServerRouteChildren: RootServerRouteChildren = {
CustomScriptDotjsServerRoute: CustomScriptDotjsServerRoute,
SitemapDotxmlServerRoute: SitemapDotxmlServerRoute,
ApiUsersServerRoute: ApiUsersServerRouteWithChildren,
}
export const serverRouteTree = rootServerRouteImport
Expand Down
48 changes: 48 additions & 0 deletions examples/react/start-basic/src/routes/sitemap[.]xml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createServerFileRoute } from '@tanstack/react-start/server'
import { generateSitemap } from '@tanstack/router-sitemap'
import { fetchPosts } from '~/utils/posts'

export const ServerRoute = createServerFileRoute('/sitemap.xml').methods({
GET: async () => {
const sitemap = await generateSitemap({
siteUrl: 'http://localhost:3000',
priority: 0.5,
changefreq: 'weekly',
routes: [
'/',
'/posts',
'/route-a',
'/route-b',
'/deferred',
[
'/posts/$postId',
async () => {
const posts = await fetchPosts()
return posts.map((post) => ({
path: `/posts/${post.id}`,
priority: 0.8,
changefreq: 'daily',
}))
},
],
[
'/posts/$postId/deep',
async () => {
const posts = await fetchPosts()
return posts.map((post) => ({
path: `/posts/${post.id}/deep`,
priority: 0.7,
changefreq: 'weekly',
}))
},
],
],
})

return new Response(sitemap, {
headers: {
'Content-Type': 'application/xml',
},
})
},
})
3 changes: 3 additions & 0 deletions labeler-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
'package: router-plugin':
- changed-files:
- any-glob-to-any-file: 'packages/router-plugin/**/*'
'package: router-sitemap':
- changed-files:
- any-glob-to-any-file: 'packages/router-sitemap/**/*'
'package: router-utils':
- changed-files:
- any-glob-to-any-file: 'packages/router-utils/**/*'
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@
"@tanstack/eslint-plugin-router": "workspace:*",
"@tanstack/server-functions-plugin": "workspace:*",
"@tanstack/directive-functions-plugin": "workspace:*",
"@tanstack/router-utils": "workspace:*"
"@tanstack/router-utils": "workspace:*",
"@tanstack/router-sitemap": "workspace:*"
}
}
}
147 changes: 147 additions & 0 deletions packages/router-sitemap/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# TanStack Router Sitemap Generator

Create an XML sitemap for your TanStack Router/Start based website.

This package provides a `generateSitemap` function and a `sitemapPlugin` vite plugin.

- Use the `sitemapPlugin` if you want to generate a static XML file during your Vite build.
- Use the `generateSitemap` function to generate a sitemap at request time in a Server Function/API Route using TanStack Start.

See the [configuration section](#sitemap-configuration) for more details on how to declare your sitemap.

## Installation

```bash
npm install @tanstack/router-sitemap
```

## Vite Plugin

The `examples/react/basic` example includes a sitemap generated using the Vite plugin.

```ts
// vite.config.ts
import { defineConfig } from 'vite'
import { sitemapPlugin } from '@tanstack/router-sitemap/vite-plugin'

export default defineConfig({
plugins: [
sitemapPlugin({
sitemap: {
siteUrl: 'https://tanstack.com',
routes: ['/', '/posts'],
},
}),
],
})
```

### Plugin Configuration

| Property | Type | Default | Description |
| --------- | --------------- | --------------- | --------------------------------------------- |
| `sitemap` | `SitemapConfig` | Required | Sitemap configuration object |
| `path` | `string` | `'sitemap.xml'` | Output file path relative to public directory |

## Start API Route

The `examples/react/start-basic` example includes a `sitemap.xml` API route.

```ts
// routes/sitemap[.]xml.ts
import { createServerFileRoute } from '@tanstack/react-start/server'
import { generateSitemap } from '@tanstack/router-sitemap'
import { fetchPosts } from '~/utils/posts'

export const ServerRoute = createServerFileRoute('/sitemap.xml').methods({
GET: async () => {
const sitemap = await generateSitemap({
siteUrl: 'https://tanstack.com',
routes: [
'/',
[
'/posts/$postId',
async () => {
const posts = await fetchPosts()
return posts.map((post) => ({
path: `/posts/${post.id}`,
priority: 0.8,
changefreq: 'daily',
}))
},
],
],
})

return new Response(sitemap, {
headers: {
'Content-Type': 'application/xml',
},
})
},
})
```

## Sitemap Configuration

### Configuration Options

| Property | Type | Required | Description |
| ------------ | ------------------ | -------- | -------------------------------------------------------- |
| `siteUrl` | `string` | Yes | Base URL of your website (e.g., `'https://example.com'`) |
| `routes` | `Array<RouteItem>` | Yes | Array of routes to include in the sitemap |
| `priority` | `number` | No | Default priority for all routes (0.0-1.0) |
| `changefreq` | `ChangeFreq` | No | Default change frequency for all routes |

### Route Configuration

Route strings are inferred from your router similar to a `Link`'s `to` prop.

Routes can be configured as:

1. **Simple strings**: `'/'`, `'/about'`
2. **Configuration tuples**: `['/route-path', options]`

### Route Options

| Property | Type | Description |
| ------------ | --------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `path` | `string` | Custom path for dynamic routes |
| `lastmod` | `string \| Date` | Last modification date |
| `changefreq` | `'always' \| 'hourly' \| 'daily' \| 'weekly' \| 'monthly' \| 'yearly' \| 'never'` | How frequently the page changes |
| `priority` | `number` | Priority of this URL relative to other URLs (0.0-1.0) |

### Static Route Configuration

```ts
routes: [
'/', // Simple string
['/about', { priority: 0.9, changefreq: 'monthly' }], // With options
['/dynamic', async () => ({ lastmod: await getLastModified() })], // Async function
]
```

### Dynamic Route Configuration

For routes with parameters, use functions that return arrays:

```ts
routes: [
[
'/posts/$postId',
async () => {
const posts = await fetchPosts()
return posts.map((post) => ({
path: `/posts/${post.id}`,
lastmod: post.updatedAt,
priority: 0.8,
changefreq: 'daily',
}))
},
],
]
```

## Credit

This package is partly based on an existing plugin from the community: [tanstack-router-sitemap](https://github.com/Ryanjso/tanstack-router-sitemap).
16 changes: 16 additions & 0 deletions packages/router-sitemap/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// @ts-check

import rootConfig from '../../eslint.config.js'

export default [
...rootConfig,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
Loading