Fix: Qwik makes empty sitemap.xml
Source: Dev.to
Problem
When deploying a Qwik (or Qwik City) application with the Node.js adapter (nodeServerAdapter), the sitemap.xml works locally but is empty in production:
<!-- empty sitemap -->
- Local build: ✅ works
- Dev server: ✅ serves the sitemap correctly
- Production build: ❌ empty sitemap, no warnings
The Node.js adapter runs Static Site Generation (SSG) during the build, even for SSR builds. During this step it:
- Scans routes for static‑exportable pages
- Generates a sitemap from the found routes
- Overwrites any
public/sitemap.xmlyou placed manually
If the SSG step cannot find any exportable routes (e.g., dynamic routes, SSR‑only routes, or when using the Node.js adapter), it produces an empty sitemap.
Quick Fix – Disable SSG
For a pure SSR application you can completely turn off SSG in the adapter configuration.
adapters/node-server/vite.config.ts
import { nodeServerAdapter } from "@builder.io/qwik-city/adapters/node-server/vite";
import { extendConfig } from "@builder.io/qwik-city/vite";
import baseConfig from "../../vite.config";
export default extendConfig(baseConfig, () => ({
build: {
ssr: true,
rollupOptions: {
input: ["src/entry.express.tsx", "@qwik-city-plan"],
},
},
plugins: [
nodeServerAdapter({
name: "express",
// ⚠️ CRITICAL: Completely disable SSG – use a static sitemap.xml
ssg: null,
}),
],
}));
Key change: ssg: null stops the automatic sitemap generation, so the file you place in public/ is served unchanged.
Provide a Static Sitemap
Create public/sitemap.xml with the URLs you want indexed.
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://yourdomain.com/</loc>
<lastmod>2024-12-01</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://yourdomain.com/about/</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
Multi‑language example
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://yourdomain.com/</loc>
<priority>1.0</priority>
</url>
<url>
<loc>https://yourdomain.com/es/</loc>
<priority>0.8</priority>
</url>
<url>
<loc>https://yourdomain.com/de/</loc>
<priority>0.8</priority>
</url>
</urlset>
Adjust Build Scripts
If your package.json runs qwik city collect, remove it—otherwise SSG will be forced again.
{
"scripts": {
// Before
// "build:deploy": "npm run build.production && qwik city collect"
// After
"build:deploy": "npm run build.production"
}
}
Run the build and verify:
npm run build:deploy
ls -lh dist/sitemap.xml
cat dist/sitemap.xml
You should see the full sitemap content (not an empty file).
The Node.js server will now serve dist/sitemap.xml as a static asset:
node server.js
curl http://localhost:3000/sitemap.xml
Dynamic Sitemap (SSR Route)
If the sitemap must be generated on‑the‑fly (e.g., from a CMS), create an SSR route.
src/routes/sitemap.xml/index.ts
import type { RequestHandler } from '@builder.io/qwik-city';
const SUPPORTED_LOCALES = ['en-US', 'es', 'ru', 'de', 'fr', 'ja'];
export const onGet: RequestHandler = async ({ url }) => {
const origin = url.origin;
const urls = [
// Root URL
`
${origin}/
${new Date().toISOString().split('T')[0]}
weekly
1.0
`,
// Language‑specific URLs
...SUPPORTED_LOCALES
.filter(l => l !== 'en-US')
.map(l => `
${origin}/${l}/
weekly
0.8
`),
].join('\n');
const sitemap = `
${urls}
`;
return new Response(sitemap, {
status: 200,
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=86400', // 1 day
},
});
};
Note: Keep
ssg: nullin the adapter config to avoid the static‑generation step overwriting this dynamic route.
Why SSG Runs with the Node.js Adapter
- Static Site Generation (SSG) – pre‑renders pages at build time, generates a sitemap, and writes static files.
- Server‑Side Rendering (SSR) – renders pages on demand at runtime.
The Node.js adapter is intended for SSR, but it still triggers SSG by default to produce a sitemap and pre‑render any pages that have onStaticGenerate. When routes are dynamic or lack onStaticGenerate, SSG finds zero exportable routes and writes an empty sitemap.
Common Pitfalls
| Issue | Why it Happens | Fix |
|---|---|---|
ssg: { exclude: ['*'] } | Excludes files but still runs SSG, which still overwrites the sitemap. | Use ssg: null. |
qwik city collect in build script | Explicitly runs SSG after the build, overwriting your static file. | Remove the command. |
Relying on public/ only for dynamic content | Dynamic routes need an SSR handler, not a static file. | Implement an SSR route as shown above. |
dist/ cleared on each build | Static files must live in public/, not copied manually to dist/. | Keep public/sitemap.xml. |
Checklist After Applying the Fix
- Build finishes without “Starting Qwik City SSG…” (when
ssg: null). -
dist/sitemap.xmlexists and is larger than the empty 109‑byte file. -
cat dist/sitemap.xmlshows the expected URLs. - Production server returns the full sitemap (
curl https://yourdomain.com/sitemap.xml). - No SSG‑related warnings or errors appear in the build logs.
Optional: Redirect /sitemap.xml/ to /sitemap.xml
If a trailing slash is requested, add a simple redirect in your server entry:
import { createServer } from "http";
createServer((req, res) => {
if (req.url === "/sitemap.xml/") {
res.writeHead(301, { Location: "/sitemap.xml" });
res.end();
return;
}
// ...rest of server logic
}).listen(3000);
The redirect is harmless for search engines but can tidy up URLs.
Choosing Between a Static Sitemap and an SSR Route
| Use‑case | Recommended Approach |
|---|---|
| Pure SSR app, routes known at dev time, sitemap rarely changes | Static sitemap (public/sitemap.xml + ssg: null) |
| Sitemap data comes from a CMS, database, or user‑generated content | SSR route (src/routes/sitemap.xml/index.ts) |
| Need real‑time updates or per‑locale logic | SSR route |
| Simple deployment, no extra build steps | Static sitemap |
Implement the approach that matches your project’s needs, and the empty‑sitemap issue will be resolved.