Fredy Acuna
  • Posts
  • Projects
  • Tools
  • Contact
LinkedInXGitHubMedium

© 2026 Fredy Acuna

Back to posts
Why Your Next.js Sitemap Is Empty (and the Native Fix)

Why Your Next.js Sitemap Is Empty (and the Native Fix)

Fredy Acuna / October 30, 2026 / 7 min read

The Symptom: A Sitemap With Zero URLs

I opened https://www.fredhii.com/sitemap.xml expecting a few dozen URLs and got this instead:

<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
</sitemapindex>

An empty <sitemapindex>. No pages, no posts, no projects. The build passed, the deploy passed, and next-sitemap ran in postbuild without a single warning. Search engines were getting a sitemap that told them nothing.

This post explains why that happened, how to confirm it on your own project, and the fix I shipped: drop next-sitemap and use the sitemap and robots files that Next.js already supports natively.


What You'll Learn

  • Why next-sitemap can silently emit an empty sitemap on a dynamic site
  • How to read .next/prerender-manifest.json to confirm the cause
  • How to write a native app/sitemap.ts with hreflang alternates
  • Why your canonical host (www vs non-www) matters for the sitemap
  • A Vercel gotcha with NEXT_PUBLIC_* variables

Why Every Page Was Dynamic

My site is bilingual. The UI language comes from a getLocale() helper that reads request data:

export async function getLocale(): Promise<Locale> {
  const h = await headers()
  // ...
  const cookieStore = await cookies()
  const cookieVal = cookieStore.get(LOCALE_COOKIE)?.value
  if (cookieVal === 'es' || cookieVal === 'en') return cookieVal

  const accept = h.get('accept-language') ?? ''
  return accept.toLowerCase().startsWith('es') ? 'es' : 'en'
}

The root layout calls it to set <html lang>:

const locale = await getLocale()
// ...
<html lang={locale} suppressHydrationWarning>

Calling cookies() or headers() opts a route into dynamic rendering. Because this happens in the root layout, every single route on the site renders on request. In the build output, every page shows up with the ƒ (dynamic) marker instead of ○ (static).

That is a perfectly valid choice for a site that personalizes by cookie. The problem is what it does to tools that rely on build artifacts.


How next-sitemap Finds Your URLs

next-sitemap does not crawl your app. It reads the files Next.js writes into .next/ after the build. In its source (url-set-builder.js, function createUrlSet), the URL set comes from two places:

  1. The pages listed in the build manifest
  2. The routes listed in the prerender manifest

On my Next.js 16 build, this is what those files contained:

  • .next/prerender-manifest.json listed only /_global-error and /favicon.ico
  • The build manifest pages listed only /_app

Those are internal entries, not real pages. Nothing was prerendered because nothing could be, so there was nothing left to put in the sitemap. No error, just an empty index.

You can check your own project in one command after next build:

cat .next/prerender-manifest.json | jq '.routes | keys'

If your real pages are missing from that list, next-sitemap cannot see them either.


The Fix: A Native app/sitemap.ts

Next.js has supported a sitemap.ts file in the app directory for a while. It is a route that runs your code, so it does not care whether pages are static or dynamic. You build the URL list from what you actually know: your route table and your content.

I already had a map of bilingual routes in lib/locale-routes.ts:

export const PAGE_PAIRS: Record<string, LocalePair> = {
  '/': { es: '/', en: '/' },
  '/posts': { es: '/publicaciones', en: '/posts' },
  '/publicaciones': { es: '/publicaciones', en: '/posts' },
  '/projects': { es: '/proyectos', en: '/projects' },
  '/proyectos': { es: '/proyectos', en: '/projects' },
  // contact, privacy, tools...
}

The new app/sitemap.ts combines that map with posts and projects read from MDX:

import type { MetadataRoute } from 'next'
import { siteConfig } from '@/lib/config'
import { PAGE_PAIRS } from '@/lib/locale-routes'
import { getPosts } from '@/lib/posts'
import { getProjects } from '@/lib/projects'

// Built from routes + content rather than Next's prerender manifest: pages
// read the locale cookie, so they render dynamically and never appear there.

type Entry = MetadataRoute.Sitemap[number]

const url = (path: string) => `${siteConfig.siteUrl}${path === '/' ? '' : path}`

function languages(es: string, en: string) {
  return { es: url(es), en: url(en), 'x-default': url(en) }
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const staticPaths = [...new Set(Object.keys(PAGE_PAIRS))]
  const staticEntries: Entry[] = staticPaths.map(path => {
    const { es, en } = PAGE_PAIRS[path]
    return {
      url: url(path),
      ...(es !== en ? { alternates: { languages: languages(es, en) } } : {})
    }
  })

  const [posts, projects] = await Promise.all([getPosts(), getProjects()])

  return [
    ...staticEntries,
    ...contentEntries(posts, { es: 'publicaciones', en: 'posts' }),
    ...contentEntries(projects, { es: 'proyectos', en: 'projects' })
  ]
}

contentEntries turns each post or project into an entry and, when the item has a translation (linked through the translationOf frontmatter field), adds the hreflang alternates:

function contentEntries(items: Content[], segment: { es: string; en: string }): Entry[] {
  const bySlug = new Map(items.map(item => [item.slug, item]))
  const pathOf = (item: Content) => `/${segment[item.locale]}/${item.slug}`

  return items.map(item => {
    const pair =
      bySlug.get(item.translationOf ?? '') ??
      items.find(other => other.translationOf === item.slug)
    const entry: Entry = { url: url(pathOf(item)), lastModified: new Date(item.publishedAt) }
    if (pair && pair.locale !== item.locale) {
      const [es, en] = item.locale === 'es' ? [item, pair] : [pair, item]
      entry.alternates = { languages: languages(pathOf(es), pathOf(en)) }
    }
    return entry
  })
}

A nice side effect: the old next-sitemap.config.js had to duplicate the route pairs because it ran as a CommonJS script outside the app. The native file imports PAGE_PAIRS directly, so there is one source of truth.

The robots file is just as small:

import type { MetadataRoute } from 'next'
import { siteConfig } from '@/lib/config'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: '*', allow: '/' },
    sitemap: `${siteConfig.siteUrl}/sitemap.xml`,
    host: siteConfig.siteUrl
  }
}

Then I removed next-sitemap from the dependencies, deleted the postbuild script and the config file. Less code, one less build step.


The Second Problem: The Wrong Host

With URLs finally in the sitemap, I noticed they all pointed to https://fredhii.com. But that domain answers with a 308 redirect to https://www.fredhii.com.

A sitemap full of URLs that redirect is not ideal: you want the sitemap, robots and canonical tags to point at the final URL that returns 200. So I changed the default site URL in lib/config.ts:

const DEFAULT_SITE_URL = 'https://www.fredhii.com'

The Vercel Gotcha

The first deploy after that change still showed the non-www host. The reason: the site URL can be overridden by a NEXT_PUBLIC_SITE_URL environment variable in Vercel, and that variable still had the old value.

Two things to remember about NEXT_PUBLIC_* variables:

  • They take precedence over any default you set in code (if your config reads them first, as mine does)
  • They are inlined at build time, so changing the variable in the dashboard does nothing until you redeploy

After updating the variable and redeploying, the sitemap switched to www.


The Result

The sitemap now lists 37 URLs, all on https://www.fredhii.com, and every one of them returns 200. Bilingual pages and posts carry their es, en and x-default alternates.

You can verify yours quickly:

curl -s https://www.fredhii.com/sitemap.xml | grep -c '<loc>'

Key Takeaways

  • If something in your root layout reads cookies() or headers(), every route is dynamic and build-manifest based tools see nothing
  • next-sitemap builds its URL list from the build and prerender manifests; an empty manifest means an empty sitemap, without an error
  • A native app/sitemap.ts runs code, so you can build the list from routes and content directly
  • Point the sitemap, robots and canonicals at the host that returns 200, not the one that redirects
  • NEXT_PUBLIC_* values are baked in at build time: change them, then redeploy

Subscribe to my newsletter

Get notified when I publish new posts.

We care about your data. Read our privacy policy.