DEV Community

developer
developer

Posted on

Fix: Qwik makes empty sitemap.xml

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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Scans your routes for static-exportable pages
  2. Generates a sitemap based on routes it finds
  3. Overwrites any static sitemap.xml from your public/ 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
      }),
    ],
  };
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

After:

{
  "scripts": {
    "build:deploy": "npm run build.production"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Build and Verify

npm run build:deploy
ls -lh dist/sitemap.xml
cat dist/sitemap.xml
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

Still disable SSG in the adapter config to avoid conflicts.


Why This Happens

Qwik's SSG Behavior

Qwik City has two deployment modes:

  1. Static Site Generation (SSG) - Pre-renders pages at build time
  2. 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
    ],
  };
};
Enter fullscreen mode Exit fullscreen mode

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.xml is 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: ['*'],
  },
})
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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.xml exists and is not 109 bytes (empty sitemap size)
  • [ ] cat dist/sitemap.xml shows 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
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Check dist/sitemap.xml size: ls -lh dist/sitemap.xml

    • 109 bytes = empty sitemap (SSG still running)
    • 1-2 KB+ = static sitemap copied successfully
  2. Check build logs for "Starting Qwik City SSG"

    • If present, SSG is still enabled
    • Should not appear when ssg: null is set
  3. Verify public/sitemap.xml exists in your repository

    • Not ignored by .gitignore
    • Committed to version control
  4. Check production build includes public/ folder

    • CI/CD pipeline copies all files
    • No build steps delete public/ assets

Top comments (0)