Next.js sitemap.xml returns 404 on Vercel production with dynamic data

Problem

  • /sitemap.xml works correctly in local development
  • After deploying to Vercel, /sitemap.xml returns > 404
  • No obvious errors in the UI

Expected Behavior

  • /sitemap.xml should be accessible in production and return a valid sitemap

Code & Setup

File location

app/sitemap.ts

Implementation

import { getBayaanSlug } from '@/services/bayaans/bayaan.service';
import type { MetadataRoute } from 'next';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const domain = process.env.NEXT_PUBLIC_SITE_URL;

  const audioList = await getBayaanSlug();

  const audioSitemap: MetadataRoute.Sitemap = audioList?.items?.map((audio: any) => ({
    url: `${domain}/audio/${audio?.slug}`,
    lastModified: audio?.sys?.publishedAt,
    changeFrequency: 'yearly',
    priority: 0.8,
  }));

  return [
    {
      url: domain || "",
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 1,
    },
    ...(audioSitemap || []),
  ];
}

Steps to Reproduce

  1. Run project locally → /sitemap.xml works
  2. Deploy to Vercel
  3. Visit /sitemap.xml → returns 404

Additional Context

  • Removing dynamic data and returning a static sitemap works

There’s another community post with 404 debugging tips that might be helpful. Please give these solutions a try and let us know how it goes.

A human should be around soon to offer more advice. But you can also get helpful information quickly by asking v0.

Hey! The issue is that your sitemap.ts is trying to fetch dynamic data at build time, which might be failing on Vercel. Here’s how to fix it:

Option 1: Force Dynamic Rendering
Add this to the top of your sitemap.ts file to make it render at request time:

export const dynamic = 'force-dynamic'

Option 2: Add Error Handling
Wrap your data fetching in try/catch to prevent build failures:

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const domain = process.env.NEXT_PUBLIC_SITE_URL;
  
  try {
    const audioList = await getBayaanSlug();
    const audioSitemap = audioList?.items?.map((audio: any) => ({
      url: `${domain}/audio/${audio?.slug}`,
      lastModified: audio?.sys?.publishedAt,
      changeFrequency: 'yearly' as const,
      priority: 0.8,
    })) || [];
    
    return [
      {
        url: domain || '',
        lastModified: new Date(),
        changeFrequency: 'monthly',
        priority: 1,
      },
      ...audioSitemap,
    ];
  } catch (error) {
    console.error('Failed to fetch audio data for sitemap:', error);
    // Return at least the homepage
    return [{
      url: domain || '',
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 1,
    }];
  }
}

Also check that your getBayaanSlug() function works correctly in production - it might need environment variables or API endpoints that aren’t available during build time.

updated sitemap with the try catch blcok. check below deployment logs.

BUILD LOGS

Route (app)

┌ ƒ /

├ ○ /_not-found

├ ○ /about

├ ● /audio/[slug]

│ ├ /audio/how-to-become-close-to-allah

│ ├ /audio/why-dont-we-see-the-promise-of-allah

│ ├ /audio/hashar-hisaab

│ └ [+71 more paths]

├ ○ /robots.txt

└ ƒ /sitemap.xml

ISSUE

navigating to sitemap still gives 404 page not found. I cant even see the path being logged on vercel.
I tried npm run build locally. Sitemap is working fine except on vercel

@anshumanb any idea. can it be an issue on vercel itself because you can’t even see log of the sitemap being acess.
The build logs shows sitemap but acessing gives 404. All env variables and endpoints seems to work correctly.

It’s worth trying this:

I’m having the same issue and need a solution that does involve adding code as I don’t have access to that either.

here is the repo: GitHub - Oumeir-Rengony/ulama-moris · GitHub

the runtime logs don’t show any error. It does not even detect if i tried to visit /sitemap
below you can see logs have sitemap while the deployment resources does not show sitemap

Deployment Logs

Route (app)

┌ ƒ /

├ ○ /_not-found

├ ○ /about

├ ƒ /api/download

├ ● /audio/[slug]

│ ├ /audio/how-to-become-close-to-allah

│ ├ /audio/why-dont-we-see-the-promise-of-allah

│ ├ /audio/hashar-hisaab

│ └ [+71 more paths]

├ ƒ /audio/-/opengraph-image

├ ○ /robots.txt

└ ƒ /sitemap.xml

Deployment Resources (Functions)

/_global-error

/_not-found

/about

/api/download

/audio/[slug]

/audio/[slug]/opengraph-image
  .
  .
  .

/index

if you do a search, sitemap wont exists

@pawlean @anshumanb any suggestions ?

Hey, @oumeir-rengony!

I dug into the repo and the deployed site a bit more, and this no longer looks like a dynamic-data-fetch problem.

What I found:

  • app/sitemap.ts exists and Vercel is recognizing it during build as ƒ /sitemap.xml
  • robots.txt is working fine in production
  • but https://www.ulama-moris.org/sitemap.xml is being served as Vercel’s /404
  • there’s no obvious middleware, vercel.json, rewrite, redirect, or route conflict in the repo that would explain only sitemap.xml failing
  • the project was also upgraded from next 14.2.35 to next 16.2.0 on April 15, 2026, which makes a framework regression feel plausible

Because of that, my strongest recommendation is to avoid the metadata file route for this case and replace:

app/sitemap.ts

with:

app/sitemap.xml/route.ts

and return the XML manually from a GET() handler.

Why I think this is the safest workaround:

  • it keeps the sitemap dynamic
  • it bypasses the Next metadata-route pipeline entirely
  • it gives you a normal route handler, which is much easier to debug on Vercel

So in short: I don’t think the main issue is the dynamic fetch itself anymore. I think the issue is specifically with how app/sitemap.ts is being served in production for this app/version combination.

What that would look like:

Create app/sitemap.xml/route.ts, like:

import { getBayaanSlug } from '@/services/bayaans/bayaan.service';

export const dynamic = 'force-dynamic';

function escapeXml(value: string) {
  return value
    .replace(/&/g, '&amp;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}

export async function GET() {
  const domain = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.ulama-moris.org';

  try {
    const audioList = await getBayaanSlug();

    const urls = [
      {
        loc: domain,
        lastModified: new Date().toISOString(),
        changeFrequency: 'monthly',
        priority: '1.0',
      },
      ...((audioList?.items || []).map((audio: any) => ({
        loc: `${domain}/audio/${audio?.slug}`,
        lastModified: audio?.sys?.publishedAt || new Date().toISOString(),
        changeFrequency: 'yearly',
        priority: '0.8',
      }))),
    ];

    const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
  .map(
    (url) => `  <url>
    <loc>${escapeXml(url.loc)}</loc>
    <lastmod>${url.lastModified}</lastmod>
    <changefreq>${url.changeFrequency}</changefreq>
    <priority>${url.priority}</priority>
  </url>`
  )
  .join('\n')}
</urlset>`;

    return new Response(xml, {
      headers: {
        'Content-Type': 'application/xml; charset=utf-8',
        'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
      },
    });
  } catch (error) {
    console.error('Failed to generate sitemap.xml:', error);

    const fallbackXml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>${escapeXml(domain)}</loc>
    <lastmod>${new Date().toISOString()}</lastmod>
    <changefreq>monthly</changefreq>
    <priority>1.0</priority>
  </url>
</urlset>`;

    return new Response(fallbackXml, {
      headers: {
        'Content-Type': 'application/xml; charset=utf-8',
      },
    });
  }
}

And remove or rename app/sitemap.ts so there isn’t any conflict.

Hey, @iggy888-6163! This seems like a separate issue. Feel free to create a new post in v0 :slight_smile:

The same issue. i do think this is a regression with nextjs itself. for now im manually adding a script to add the sitemap after the build.