For a small Vite site, add sitemap.xml to the public directory and check the copied file after building. For a site with changing content, generate the XML from your actual public URLs during the build or serve it through your production backend. If a framework already manages your routes, its sitemap integration may be the simplest owner.
These five approaches solve different maintenance needs. Whichever you choose, check the deployed response as well as the generated file: a sitemap address can return your app’s HTML with a successful HTTP status. A sitemap helps search engines discover preferred URLs; it does not guarantee crawling or indexing.
Choose the source of your public URLs
Choose one sitemap owner. Running a manual file, a build plugin and a framework generator against the same output path can leave you publishing whichever version wrote last.
| Approach | Fits when | Source of URLs | Check before deployment |
|---|---|---|---|
Static file in public |
A few stable pages | A maintained XML file | The built file contains your current pages |
| Post-build script | Content is available when you build | A published-content or route inventory | The script runs after a successful build |
| Vite build plugin | You want XML emitted with the client bundle | The same explicit inventory | The bundle includes sitemap.xml |
| Framework generator | Your framework already knows its pages | The framework’s page/content pipeline | Dynamic pages and deployment base are covered |
| Server endpoint | Public content changes between builds | A current server-side inventory | The production endpoint returns current XML |
Vite’s build manifest maps assets to build output. It is not a list of your application’s public pages. A router template such as /guides/:slug also needs concrete published URLs before it belongs in a sitemap.
Use preferred canonical URLs that you want indexed. Exclude private dashboards, drafts, redirects, error pages and duplicate URL variants. Match your actual trailing-slash policy rather than adding slashes just for the examples.
The examples below use https://example.com/app/ as the public deployment base. For a root deployment, use https://example.com/. Listing a URL does not fix its rendering, access or indexing problems; those need a separate JavaScript crawlability check.
1. Keep a small sitemap in Vite’s public directory
For a handful of stable pages, create public/sitemap.xml:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/app/</loc>
</url>
<url>
<loc>https://example.com/app/about/</loc>
</url>
</urlset>
Run your existing build command, normally npm run build. With the default output directory and public-file copying enabled, the result includes dist/sitemap.xml. Vite’s public directory behavior preserves these files without hashing their names.
Check that file’s contents, then check the deployed address. If your host mounts the contents of dist at /app/, the expected address is https://example.com/app/sitemap.xml. The host’s deployment mapping determines that URL; the filesystem path alone does not.
A custom publicDir, build.outDir or disabled build.copyPublicDir changes these assumptions. Also, Vite’s base setting does not repair absolute <loc> values inside a handwritten XML file. Set those URLs correctly yourself.
This method is easy to inspect but depends on maintenance: adding a page does not update the XML. Use it when you can keep the small list synchronized. Once routes or content change regularly, a generated inventory is easier to maintain.
2. Generate the sitemap after the Vite build
A Node script suits sites whose public URLs are available during deployment. Feed it a curated route inventory or an export of published CMS records. Generation automates serialization; it does not discover every valid page by itself.
Create scripts/routes.json with paths relative to your deployment base:
["", "about/", "guides/first/"]
Here, the empty path represents https://example.com/app/. Keep the paths free of leading slashes: resolving /about/ against that base would point at the domain root instead of /app/about/.
Create scripts/sitemap.mjs:
export const site = 'https://example.com/app/';
const escapeXml = (value) => value.replace(/[<>&'"]/g, (char) => ({
'<': '<', '>': '>', '&': '&',
"'": ''', '"': '"'
}[char]));
export function sitemap(paths) {
const base = new URL(site);
if (!base.pathname.endsWith('/') || base.search || base.hash) {
throw new Error('Use a deployment base ending in / without a query or hash');
}
const urls = paths.map((path) => {
if (typeof path !== 'string' || path.startsWith('/') ||
/[?#:*\[\]]/.test(path)) {
throw new Error('Use concrete paths relative to the deployment base');
}
const url = new URL(path, base);
if (url.origin !== base.origin ||
!url.pathname.startsWith(base.pathname)) {
throw new Error('Path escapes the deployment base');
}
return url.href;
});
const entries = [...new Set(urls)].map((url) =>
' <url><loc>' + escapeXml(url) + '</loc></url>'
).join('\n');
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
entries + '\n</urlset>\n';
}
Change site to your canonical public base. The helper rejects route templates and paths that escape that base, removes duplicates and escapes XML characters. It deliberately omits optional lastmod, changefreq and priority. Google’s sitemap guidance says that lastmod should reflect a significant page update; setting every URL to today on each build misrepresents that information.
Next, create scripts/generate-sitemap.mjs:
import { readFile, writeFile } from 'node:fs/promises';
import { sitemap } from './sitemap.mjs';
const paths = JSON.parse(await readFile(
new URL('./routes.json', import.meta.url), 'utf8'
));
await writeFile(
new URL('../dist/sitemap.xml', import.meta.url),
sitemap(paths),
'utf8'
);
Add this entry alongside your existing package.json scripts:
"build:sitemap": "npm run build && node scripts/generate-sitemap.mjs"
Use npm run build:sitemap as the deployment build command. It preserves your existing build steps and only writes the sitemap after they succeed. Adapt ../dist/ if you use another output directory. Vite can empty its output directory before building, so writing there beforehand can lose the file.
For dynamic pages, export actual records such as guides/install-vite/ and guides/deploy-vite/; never put guides/:slug in the inventory. Export all eligible published records, including later API pages, and fail the build if that export is unavailable rather than silently publishing an incomplete list.
Check that dist/sitemap.xml contains the three expected absolute URLs. This approach needs a rebuild when the inventory changes. It is a build pipeline for a static file, not a live CMS endpoint.
3. Emit sitemap.xml from a Vite build plugin
A custom plugin can make sitemap generation part of the bundle itself. This is useful when a plain Vite client build is your deployment entry point and you want generation to run whenever that build runs.
Reuse scripts/routes.json and scripts/sitemap.mjs from the previous method. Add this plugin to your existing vite.config.js configuration, preserving your framework plugins and other options:
import { defineConfig } from 'vite';
import { readFileSync } from 'node:fs';
import { sitemap } from './scripts/sitemap.mjs';
export default defineConfig({
base: '/app/',
plugins: [{
name: 'emit-public-sitemap',
apply: 'build',
generateBundle() {
const paths = JSON.parse(readFileSync(
new URL('./scripts/routes.json', import.meta.url), 'utf8'
));
this.emitFile({
type: 'asset',
fileName: 'sitemap.xml',
source: sitemap(paths)
});
}
}]
});
Vite’s plugin API supports output generation and asset emission. The fixed fileName creates sitemap.xml in the configured build output rather than a hashed asset name. apply: 'build' keeps this plugin out of the development server.
Use your normal build command, then parse the emitted file and check its URL list. Remove the competing handwritten public/sitemap.xml and stop using the post-build generator if this plugin becomes the owner. The shared inventory and serializer remain.
This example targets an ordinary client build. SSR and multi-environment frameworks can have multiple outputs and different asset-emission rules. Use their documented pipeline instead of assuming this plugin belongs in every build environment. It also does not add a development /sitemap.xml route or discover router pages automatically.
4. Use the framework’s own sitemap generator
If your site already uses a Vite-based framework, check its sitemap support before maintaining a separate route list. The framework can connect its page pipeline to sitemap generation without reverse-engineering a client router.
For example, VitePress 1.6.4 documents a native sitemap option. For a site deployed under /app/, its .vitepress/config.js can include:
export default {
base: '/app/',
sitemap: {
hostname: 'https://example.com/app/'
}
};
For a domain-root deployment, use base: '/' and hostname: 'https://example.com/'. Preserve your other VitePress options. The versioned documentation matters here: this example describes VitePress configuration, not a sitemap key that plain Vite understands.
Build through your existing framework command and inspect the generated sitemap in that framework’s output directory. Check a normal page, a nested page and any generated content URLs against what was actually published. VitePress also documents transformItems for adjusting the generated list when necessary.
Choose this route when the framework already owns page generation. A separate client-build plugin can duplicate or conflict with that ownership. If the generated list misses dynamic content, fix its content/route integration rather than adding unexpanded route placeholders to the XML.
5. Serve XML from your production server
A runtime endpoint fits sites whose public content changes between builds. It requires a backend, SSR server or serverless runtime in production. A static Vite bundle cannot execute a server endpoint, and Vite preview is not a production server.
Here is a small Node example, saved as server.mjs beside the earlier scripts directory:
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { site, sitemap } from './scripts/sitemap.mjs';
const sitemapPath = new URL('sitemap.xml', site).pathname;
createServer(async (req, res) => {
const path = new URL(req.url, 'http://localhost').pathname;
if (req.method !== 'GET' || path !== sitemapPath) {
res.writeHead(404);
res.end();
return;
}
try {
const paths = JSON.parse(await readFile(
new URL('./scripts/routes.json', import.meta.url), 'utf8'
));
const xml = sitemap(paths);
res.writeHead(200, {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'no-store'
});
res.end(xml);
} catch {
res.writeHead(503, {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store'
});
res.end('Sitemap temporarily unavailable');
}
}).listen(3000, '127.0.0.1');
Run it with node server.mjs. With the example base, the local endpoint is http://127.0.0.1:3000/app/sitemap.xml. This standalone example serves only the sitemap; all other requests return 404. It does not serve your Vite app.
For production, integrate the handler into your existing server before its SPA catch-all, or route the public sitemap path to this service through your existing host configuration. Adapt the binding and routing to your platform. A static-only host needs a supported runtime or one of the static-generation methods.
The example reads routes.json on each request, so updating that server-side file changes the next response without rebuilding client assets. Your publishing process still has to keep the file current. An existing backend can instead query its published-content inventory; complete that query, including pagination, before serializing the response.
The deliberately uncached response favors freshness for this small example. A busy site should use a cache with invalidation tied to publishing, not an indefinitely stale list. If the source is unavailable, return an error rather than a successful empty sitemap. Check the deployed endpoint after routing it: its XML content type, body and current URLs all need to agree.
Check the built file and the deployed response
Run the same checks regardless of which method owns the sitemap. First inspect the generated file, if your method produces one. Then fetch the actual public endpoint:
curl --fail --silent --show-error --dump-header sitemap.headers --output deployed-sitemap.xml https://example.com/app/sitemap.xml
On Windows PowerShell, use curl.exe if curl resolves to a PowerShell alias. Inspect sitemap.headers: expect a successful final response and an XML content type. If there is a redirect, resolve the final public address and fetch that response too. An HTML body or login page is not a sitemap, even if its status is 200.
Compare XML with an independent expected list
Create expected-urls.txt from your publishing inventory, independently of the generated XML:
https://example.com/app/
https://example.com/app/about/
https://example.com/app/guides/first/
If you use the two-page handwritten example, the expected list should contain those two pages instead. Do not manufacture the expected list by extracting URLs from the sitemap you are checking; that cannot expose omissions.
Save this standard-library Python script as scripts/check-sitemap.py:
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from urllib.parse import urlsplit
ns = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
root = ET.parse(sys.argv[1]).getroot()
if root.tag != ns + "urlset":
raise SystemExit("Expected a sitemap urlset with the standard namespace")
urls = []
for item in root.findall(ns + "url"):
locs = item.findall(ns + "loc")
if len(locs) != 1 or not (locs[0].text or "").strip():
raise SystemExit("Each url needs exactly one nonempty loc")
url = locs[0].text.strip()
parts = urlsplit(url)
if parts.scheme not in ("http", "https") or not parts.netloc or parts.fragment:
raise SystemExit("Invalid absolute page URL: " + url)
urls.append(url)
if not urls or len(urls) != len(set(urls)):
raise SystemExit("Empty sitemap or duplicate URLs")
expected = {
line.strip()
for line in Path(sys.argv[2]).read_text(encoding="utf-8").splitlines()
if line.strip()
}
missing = sorted(expected - set(urls))
extra = sorted(set(urls) - expected)
print("Missing:", missing)
print("Unexpected:", extra)
if missing or extra:
raise SystemExit(1)
print("XML and expected URL set agree:", len(urls), "URLs")
For a build-generated file, run:
python scripts/check-sitemap.py dist/sitemap.xml expected-urls.txt
python scripts/check-sitemap.py deployed-sitemap.xml expected-urls.txt
For a runtime-only endpoint, run the second command. A successful comparison confirms the XML structure checked here and agreement with your expected set. It is not full schema validation or proof that every listed page is indexable.
Diagnose the failure you actually see
| Symptom | Likely place to investigate |
|---|---|
| XML exists locally but public response is HTML | SPA fallback precedence, missing upload or wrong deployment path |
| Sitemap disappears after rebuilding | A script wrote into the output directory before Vite cleared it |
URLs lose /app/ |
A leading slash reset URL resolution to the origin root |
| Dynamic pages are missing | The content export omitted records or never expanded route templates |
| XML parsing fails near an ampersand | URL values were interpolated without XML escaping |
| XML parses but contains obsolete URLs | Stale content inventory, cache or competing sitemap owner |
Fetch representative listed pages too: verify their final response, preferred canonical URL and indexing directives. For a small sitemap, check every URL. Our XML sitemap optimization guide covers the broader URL-selection checks.
Keep each sitemap within 50,000 URLs and 50 MB uncompressed; split larger inventories into multiple sitemaps and an index. The examples here target a small, single-site XML urlset, not a sitemap index.
After the deployed XML passes, reference its real address in the site’s existing robots.txt, preserving other directives:
Sitemap: https://example.com/app/sitemap.xml
This gives crawlers a discovery reference. Keep the inventory and endpoint correct as content changes; neither a valid sitemap nor its discovery guarantees indexing.