Qwik or Qwik City generates correct sitemap on local but on production, it's empty? This problem is not solved by following the official Qwik documentation because it's a different type of error.
Fixing Empty Sitemap in Qwik Production with Node.js Adapter
Problem
When deploying a Qwik application using the Node.js adapter (nodeServerAdapter), the sitemap.xml file works perfectly in local development but appears empty in production:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
</urlset>
This issue affects multiple Qwik projects and is one of the most confusing deployment problems because:
- ✅ Local build works fine
- ✅ Dev server serves sitemap correctly
- ❌ Production sitemap is empty
- ❌ No error messages or warnings
Root Cause
The Node.js adapter (@builder.io/qwik-city/adapters/node-server) automatically runs Static Site Generation (SSG) during the build process, even when you're building for SSR.
When SSG runs, it:
- Scans your routes for static-exportable pages
- Generates a sitemap based on routes it finds
-
Overwrites any static
sitemap.xmlfrom yourpublic/folder
The problem: SSG cannot find any exportable routes when using:
- Dynamic routes (e.g.,
[lang]/,[id]/,[slug]/) - SSR-only routes (routes without
onStaticGenerate) - Node.js adapter (designed for runtime SSR, not static export)
Result: SSG generates an empty sitemap with zero URLs.
Solution
Option 1: Disable SSG and Use Static Sitemap (Recommended for SSR apps)
This is the simplest solution when you're building a pure SSR application with the Node.js adapter.
Step 1: Disable SSG in Node.js Adapter
Edit 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, () => {
return {
build: {
ssr: true,
rollupOptions: {
input: ["src/entry.express.tsx", "@qwik-city-plan"],
},
},
plugins: [
nodeServerAdapter({
name: "express",
ssg: null, // ⚠️ CRITICAL: Completely disable SSG - using static sitemap.xml
}),
],
};
});
Key change: ssg: null completely disables the automatic sitemap generation.
Step 2: Create Static Sitemap
Create public/sitemap.xml with your routes:
<?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>
<!-- Add all your static routes here -->
</urlset>
For multi-language sites:
<?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>
<!-- etc -->
</urlset>
Step 3: Remove SSG Commands
If your package.json has qwik city collect, remove it:
Before:
{
"scripts": {
"build:deploy": "npm run build.production && qwik city collect"
}
}
After:
{
"scripts": {
"build:deploy": "npm run build.production"
}
}
Step 4: Build and Verify
npm run build:deploy
ls -lh dist/sitemap.xml
cat dist/sitemap.xml
You should see your sitemap content (not empty) in dist/sitemap.xml.
Step 5: Verify Server Serves It
The Node.js server will automatically serve dist/sitemap.xml as a static file:
node server.js
curl http://localhost:3000/sitemap.xml
Should return your full sitemap.
Option 2: Use Dynamic SSR Route (For Complex/Dynamic Sitemaps)
If your sitemap needs to be generated dynamically (e.g., from a CMS or database), create an SSR route instead.
Create 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
` <url>
<loc>${origin}/</loc>
<lastmod>${new Date().toISOString().split('T')[0]}</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>`,
// Language-specific URLs
...SUPPORTED_LOCALES
.filter(locale => locale !== 'en-US')
.map(locale => ` <url>
<loc>${origin}/${locale}/</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`),
].join('\n');
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;
return new Response(sitemap, {
status: 200,
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
},
});
};
Still disable SSG in the adapter config to avoid conflicts.
Why This Happens
Qwik's SSG Behavior
Qwik City has two deployment modes:
- Static Site Generation (SSG) - Pre-renders pages at build time
- Server-Side Rendering (SSR) - Renders pages on-demand at runtime
The Node.js adapter is designed for SSR, but it still runs SSG by default to:
- Generate a sitemap
- Pre-render any static pages marked with
onStaticGenerate
The Problem with Dynamic Routes
When you have routes like:
-
src/routes/[lang]/index.tsx(language parameter) -
src/routes/posts/[id]/index.tsx(post ID parameter) - SSR-only routes without
onStaticGenerate
SSG cannot know which parameter values to use unless you explicitly define them with onStaticGenerate:
export const onStaticGenerate: StaticGenerateHandler = async () => {
return {
params: [
{ lang: 'en-US' },
{ lang: 'es' },
{ lang: 'de' },
// etc
],
};
};
But even with onStaticGenerate, if you're using the Node.js adapter for pure SSR, the SSG step may not recognize these routes because:
- The adapter is optimized for runtime rendering
- The route manifest may be incomplete at build time
- Environment variables (like
ORIGIN) may be missing during build
Why It Works Locally
In development (npm run dev):
- Vite serves files directly from
public/ - No SSG runs
- Your static
sitemap.xmlis served as-is
In production:
- SSG runs during build
- Finds 0 exportable routes
- Generates empty sitemap
- Overwrites your static file
Common Mistakes
❌ Using exclude: ['*'] instead of ssg: null
// This does NOT fully disable SSG:
nodeServerAdapter({
name: "express",
ssg: {
exclude: ['*'],
},
})
SSG still runs and generates an empty sitemap. Use ssg: null.
❌ Keeping qwik city collect in build script
{
"scripts": {
"build:deploy": "npm run build.production && qwik city collect"
}
}
This explicitly runs SSG and will overwrite your static sitemap. Remove it.
❌ Putting sitemap in dist/ manually
The dist/ folder is cleared on every build. Always put the static sitemap in public/.
❌ Creating both static sitemap AND SSR route
Pick one approach. If you have both public/sitemap.xml AND src/routes/sitemap.xml/, the SSR route takes precedence.
Verification Checklist
After implementing the fix:
- [ ] Build completes without "Starting Qwik City SSG..." message (if using
ssg: null) - [ ]
dist/sitemap.xmlexists and is not 109 bytes (empty sitemap size) - [ ]
cat dist/sitemap.xmlshows your actual URLs - [ ] Production server serves sitemap:
curl https://yourdomain.com/sitemap.xml - [ ] Sitemap contains expected number of URLs
- [ ] No errors in build logs
Additional Notes
Trailing Slash Issue
If /sitemap.xml/ (with trailing slash) doesn't redirect to /sitemap.xml, this is a Qwik City routing behavior. It's harmless - search engines will normalize the URL.
To enforce redirect, you can add this to your server.js:
createServer((req, res) => {
// Redirect /sitemap.xml/ to /sitemap.xml
if (req.url === '/sitemap.xml/') {
res.writeHead(301, { Location: '/sitemap.xml' });
res.end();
return;
}
// ... rest of server code
});
When to Use Each Approach
Use Static Sitemap (ssg: null + public/sitemap.xml) when:
- ✅ Pure SSR application
- ✅ Routes are known at development time
- ✅ Sitemap doesn't change often
- ✅ Simple deployment
Use SSR Route (src/routes/sitemap.xml/index.ts) when:
- ✅ Content comes from CMS/database
- ✅ Routes are dynamic
- ✅ Sitemap needs real-time updates
- ✅ Need to include user-generated content
Related Issues
This fix also resolves:
- "Sitemap is empty on Vercel/Netlify/production"
- "qwik city collect generates no pages"
- "SSG finds 0 routes in Node.js adapter"
- "public/sitemap.xml not copied to dist"
Credits
This solution was discovered after extensive debugging of Qwik v1.17.x with Node.js adapter on production deployments where local builds worked but production sitemaps were empty.
Key insight: The Node.js adapter runs SSG by default even for SSR apps, and an empty route manifest causes it to generate an empty sitemap that overwrites static files.
Need Help?
If your sitemap is still empty after this fix:
-
Check
dist/sitemap.xmlsize:ls -lh dist/sitemap.xml- 109 bytes = empty sitemap (SSG still running)
- 1-2 KB+ = static sitemap copied successfully
-
Check build logs for "Starting Qwik City SSG"
- If present, SSG is still enabled
- Should not appear when
ssg: nullis set
-
Verify
public/sitemap.xmlexists in your repository- Not ignored by
.gitignore - Committed to version control
- Not ignored by
-
Check production build includes
public/folder- CI/CD pipeline copies all files
- No build steps delete
public/assets
Top comments (0)