Blog

RA

Rod Alexanderson

Desarrollador Web

Creando documentación técnica en español para desarrolladores de Latinoamérica.

Más sobre mí →

Suscríbete al Newsletter

Recibe los nuevos artículos directamente en tu email.

Cómo crear un sitemap automático en NextJS 15 con App Router

Publicado el 29 de septiembre, 2025 • 12 min de lectura

Un sitemap.xml es esencial para el SEO de tu sitio web. Le dice a Google y otros motores de búsqueda qué páginas tienes, cuándo se actualizaron y qué tan importantes son.

En esta guía completa aprenderás a crear un sitemap completamente automático en NextJS 15 que se actualiza solo cuando agregas nuevas páginas.

¿Qué es un sitemap y por qué lo necesitas?

Un sitemap es un archivo XML que lista todas las URLs públicas de tu sitio web junto con metadata útil para motores de búsqueda.

Ejemplo de sitemap básico:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://tudominio.com/</loc>
    <lastmod>2025-09-29</lastmod>
    <changefreq>weekly</changefreq>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>https://tudominio.com/about</loc>
    <lastmod>2025-09-20</lastmod>
    <changefreq>monthly</changefreq>
    <priority>0.8</priority>
  </url>
</urlset>

¿Por qué es importante?

  1. Indexación más rápida: Google descubre tus páginas nuevas más rápido
  2. Mejor SEO: Los motores entienden la estructura de tu sitio
  3. Control: Decides qué páginas son más importantes
  4. Analytics: Ves en Google Search Console qué páginas están indexadas
Impacto en SEO

Sitios con sitemap bien configurado pueden ver sus páginas indexadas hasta 70% más rápido que sin sitemap.

Cómo funcionan los sitemaps en NextJS 15

NextJS 15 tiene soporte nativo para sitemaps con el App Router. No necesitas librerías externas ni configuración compleja.

El sistema

NextJS reconoce automáticamente un archivo llamado sitemap.ts (o .js) en la carpeta app/ y lo transforma en un sitemap.xml accesible en /sitemap.xml.

app/
├── sitemap.ts      ← Defines tu sitemap aquí
├── page.tsx
└── about/
    └── page.tsx

Cuando un usuario (o Google) visita /sitemap.xml, NextJS ejecuta tu archivo sitemap.ts y genera el XML automáticamente.

ℹ️

No necesitas crear un archivo .xml manualmente. NextJS lo genera por ti basándose en tu código TypeScript/JavaScript.

Creando tu primer sitemap

Vamos a crear un sitemap paso a paso, desde lo más básico hasta uno completamente dinámico.

Sitemap estático básico

1

Crea el archivo app/sitemap.ts

En la raíz de tu carpeta app/

2

Agrega el código básico

import type { MetadataRoute } from 'next'

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://tudominio.com',
      lastModified: new Date(),
      changeFrequency: 'yearly',
      priority: 1,
    },
    {
      url: 'https://tudominio.com/about',
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.8,
    },
    {
      url: 'https://tudominio.com/blog',
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 0.5,
    },
  ]
}
3

Verifica /sitemap.xml

Visita http://localhost:3000/sitemap.xml

Verás tu sitemap generado automáticamente en formato XML

⚠️
Cambia el dominio

Recuerda reemplazar 'https://tudominio.com' con tu dominio real en producción.

Entendiendo los campos del sitemap

Cada URL en tu sitemap puede tener estos campos:

url (Requerido)

La URL completa de la página:

url: 'https://tudominio.com/blog/mi-post'

Reglas:

  • ✅ Debe ser URL absoluta (incluir https://)
  • ✅ No debe tener fragmentos (#seccion)
  • ✅ No debe tener query params si son para filtros (?page=2)

lastModified (Opcional pero recomendado)

Cuándo se modificó la página por última vez:

lastModified: new Date('2025-09-29')
// O dinámico:
lastModified: new Date()

Beneficio: Google prioriza re-crawlear páginas que cambiaron recientemente.

changeFrequency (Opcional)

Con qué frecuencia cambia típicamente la página:

changeFrequency: 'weekly'

Opciones válidas:

  • always - Cambia cada vez que se accede
  • hourly - Cada hora
  • daily - Diario
  • weekly - Semanal
  • monthly - Mensual
  • yearly - Anual
  • never - Nunca cambia
💡

Google usa esto como sugerencia, no como comando. No garantiza que crawlee con esa frecuencia.

priority (Opcional)

Importancia relativa de la página (0.0 a 1.0):

priority: 0.8

Guía de prioridades:

  • 1.0 - Homepage, páginas principales
  • 0.8 - Páginas de categoría importantes
  • 0.6 - Páginas de subcategoría
  • 0.5 - Posts de blog, artículos
  • 0.3 - Páginas secundarias
ℹ️

La prioridad es relativa a tu sitio, no al internet. Una página con priority 0.5 en tu sitio puede ser más importante que una con 1.0 en otro sitio.

Sitemap dinámico con páginas reales

Un sitemap estático no es útil para sitios que crecen. Vamos a crear uno que se actualice automáticamente.

Ejemplo: Blog con posts dinámicos

// app/sitemap.ts
import type { MetadataRoute } from 'next'

// Función para obtener todos tus posts
async function getAllPosts() {
  // Esto depende de dónde guardes tus posts
  // Puede ser una base de datos, CMS, o archivos
  const posts = [
    { slug: 'mi-primer-post', date: '2025-09-20' },
    { slug: 'segundo-post', date: '2025-09-25' },
    { slug: 'tercer-post', date: '2025-09-29' },
  ]
  return posts
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://tudominio.com'
  
  // Páginas estáticas
  const staticPages = [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'yearly' as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: 'monthly' as const,
      priority: 0.8,
    },
    {
      url: `${baseUrl}/blog`,
      lastModified: new Date(),
      changeFrequency: 'weekly' as const,
      priority: 0.9,
    },
  ]
  
  // Páginas dinámicas (posts de blog)
  const posts = await getAllPosts()
  const postPages = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: new Date(post.date),
    changeFrequency: 'monthly' as const,
    priority: 0.6,
  }))
  
  // Combinar todas las páginas
  return [...staticPages, ...postPages]
}

Ahora cada vez que agregas un post nuevo, automáticamente aparece en tu sitemap. ¡Sin configuración adicional!

Sitemap completamente automático leyendo el filesystem

Si quieres cero mantenimiento y que el sitemap se genere solo al leer tus carpetas, puedes usar el módulo fs de Node.js.

Este enfoque es perfecto para sitios con estructura basada en carpetas, como blogs o documentación donde cada carpeta es una página.

¿Cuándo usar este método?

✅ Usa automatización completa cuando:

  • Tus páginas están organizadas en carpetas (app/blog/mi-post/)
  • No necesitas control fino sobre prioridades o frecuencias
  • Quieres agregar páginas sin tocar el sitemap
  • Tienes una estructura consistente y predecible

❌ Usa el método manual cuando:

  • Tus páginas vienen de una base de datos o CMS
  • Necesitas lógica compleja para decidir qué incluir
  • Quieres prioridades diferentes para cada página
  • Las páginas no siguen un patrón de carpetas

Implementación completa

// app/sitemap.ts
import type { MetadataRoute } from 'next'
import fs from 'fs'
import path from 'path'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://tudominio.com'
  const currentDate = new Date()

  // Páginas estáticas principales
  const staticPages: MetadataRoute.Sitemap = [
    {
      url: baseUrl,
      lastModified: currentDate,
      changeFrequency: 'weekly',
      priority: 1,
    },
    {
      url: `${baseUrl}/sobre-mi`,
      lastModified: currentDate,
      changeFrequency: 'monthly',
      priority: 0.8,
    },
    {
      url: `${baseUrl}/blog`,
      lastModified: currentDate,
      changeFrequency: 'weekly',
      priority: 0.9,
    },
    {
      url: `${baseUrl}/docs`,
      lastModified: currentDate,
      changeFrequency: 'weekly',
      priority: 0.9,
    },
  ]

  // Generar páginas de blog automáticamente
  const blogPages = await generateBlogPages(baseUrl)

  // Generar páginas de docs automáticamente
  const docsPages = await generateDocsPages(baseUrl)

  return [...staticPages, ...blogPages, ...docsPages]
}

// Función para generar páginas de blog automáticamente
async function generateBlogPages(baseUrl: string): Promise<MetadataRoute.Sitemap> {
  const blogDir = path.join(process.cwd(), 'app', 'blog')
  
  try {
    const entries = fs.readdirSync(blogDir, { withFileTypes: true })
    
    return entries
      .filter((entry) => entry.isDirectory() && entry.name !== '[slug]')
      .map((entry) => {
        const slug = entry.name
        const dirPath = path.join(blogDir, slug)
        const stats = fs.statSync(dirPath)
        
        return {
          url: `${baseUrl}/blog/${slug}`,
          lastModified: stats.mtime, // Fecha real de modificación
          changeFrequency: 'monthly' as const,
          priority: 0.7,
        }
      })
  } catch (error) {
    console.error('Error generando páginas de blog:', error)
    return []
  }
}

// Función para generar páginas de docs automáticamente
async function generateDocsPages(baseUrl: string): Promise<MetadataRoute.Sitemap> {
  const docsDir = path.join(process.cwd(), 'app', 'docs')
  const pages: MetadataRoute.Sitemap = []

  function scanDirectory(dir: string, basePath: string = '/docs') {
    try {
      const entries = fs.readdirSync(dir, { withFileTypes: true })

      for (const entry of entries) {
        const fullPath = path.join(dir, entry.name)
        
        if (entry.isDirectory()) {
          const urlPath = `${basePath}/${entry.name}`
          const stats = fs.statSync(fullPath)
          
          pages.push({
            url: `${baseUrl}${urlPath}`,
            lastModified: stats.mtime,
            changeFrequency: 'weekly',
            priority: basePath === '/docs' ? 0.9 : 0.7,
          })

          // Escanear recursivamente subcarpetas
          scanDirectory(fullPath, urlPath)
        }
      }
    } catch (error) {
      console.error('Error escaneando directorio:', dir, error)
    }
  }

  scanDirectory(docsDir)
  return pages
}

¿Cómo funciona?

  1. generateBlogPages():

    • Lee todas las carpetas dentro de app/blog/
    • Excluye la carpeta dinámica [slug]
    • Convierte cada carpeta en una URL
    • Usa la fecha de modificación real del filesystem
  2. generateDocsPages():

    • Escanea recursivamente toda la estructura de app/docs/
    • Genera URLs para cada carpeta encontrada
    • Mayor prioridad para niveles superiores
  3. Ventajas:

    • ✅ Cero mantenimiento manual
    • ✅ Fechas de modificación reales
    • ✅ Se adapta automáticamente a cambios
    • ✅ Funciona con estructuras anidadas
Completamente automático

Con este enfoque, solo creas una nueva carpeta de blog o docs y automáticamente aparece en tu sitemap. ¡Literalmente cero mantenimiento!

Combinando ambos enfoques

Puedes combinar páginas automáticas con manuales según necesites:

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://tudominio.com'

  // Páginas principales (manual para control fino)
  const staticPages = [/* ... */]

  // Blog automático (filesystem)
  const blogPages = await generateBlogPages(baseUrl)

  // Productos desde base de datos (manual)
  const products = await db.product.findMany()
  const productPages = products.map((product) => ({
    url: `${baseUrl}/productos/${product.slug}`,
    lastModified: product.updatedAt,
    changeFrequency: 'weekly' as const,
    priority: 0.8,
  }))

  return [...staticPages, ...blogPages, ...productPages]
}

Ejemplo real: Sitio de documentación

Para un sitio de documentación técnica (como el que estamos construyendo):

// app/sitemap.ts
import type { MetadataRoute } from 'next'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://rodalexanderson.com'
  const currentDate = new Date()

  // Páginas principales
  const mainPages = [
    {
      url: baseUrl,
      lastModified: currentDate,
      changeFrequency: 'weekly' as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/sobre-mi`,
      lastModified: currentDate,
      changeFrequency: 'monthly' as const,
      priority: 0.8,
    },
    {
      url: `${baseUrl}/blog`,
      lastModified: currentDate,
      changeFrequency: 'weekly' as const,
      priority: 0.9,
    },
    {
      url: `${baseUrl}/docs`,
      lastModified: currentDate,
      changeFrequency: 'weekly' as const,
      priority: 0.9,
    },
  ]

  // Documentación de NextJS
  const docsPages = [
    'v15',
    'v15/instalacion',
    'v15/estructura',
    'v15/routing',
    'v15/rendering/server-components',
    'v15/rendering/client-components',
  ].map((path) => ({
    url: `${baseUrl}/docs/nextjs/${path}`,
    lastModified: currentDate,
    changeFrequency: 'monthly' as const,
    priority: 0.7,
  }))

  return [...mainPages, ...docsPages]
}

Sitemap con datos de base de datos

Si usas Prisma, Drizzle u otro ORM:

// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { db } from '@/lib/database'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://tudominio.com'

  // Obtener posts de la base de datos
  const posts = await db.post.findMany({
    where: { published: true },
    select: {
      slug: true,
      updatedAt: true,
    },
  })

  // Convertir a formato de sitemap
  const postPages = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: 'monthly' as const,
    priority: 0.7,
  }))

  // Páginas estáticas
  const staticPages = [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'weekly' as const,
      priority: 1,
    },
  ]

  return [...staticPages, ...postPages]
}
💡
Performance

Si tienes muchas páginas (1000+), considera usar Sitemap Index para dividir el sitemap en múltiples archivos.

Configurar robots.txt

Después de crear tu sitemap, debes decirle a Google dónde encontrarlo usando robots.txt:

1

Crea app/robots.ts

En la raíz de tu carpeta app/

2

Agrega la configuración

import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: ['/admin/', '/api/'],
    },
    sitemap: 'https://tudominio.com/sitemap.xml',
  }
}
3

Verifica /robots.txt

Visita http://localhost:3000/robots.txt

Esto crea un archivo robots.txt que:

  • ✅ Permite crawlear todo el sitio
  • ✅ Bloquea rutas privadas como /admin/
  • ✅ Apunta a tu sitemap

Verificar tu sitemap

Después de crear tu sitemap, verifica que funciona correctamente:

1. Prueba local

Visita http://localhost:3000/sitemap.xml y verifica que:

  • Muestra XML válido
  • Todas las URLs son correctas
  • Incluye todas tus páginas importantes

2. Validador XML

Usa un validador online:

Copia y pega tu sitemap para verificar que el XML es válido.

3. Google Search Console

Una vez en producción:

1

Ve a Google Search Console

Inicia sesión con tu cuenta de Google

2

Agrega tu sitio web

Si aún no lo has hecho

3

Ve a Sitemaps

En el menú lateral

4

Agrega tu sitemap

Ingresa sitemap.xml y presiona Submit

Google comenzará a crawlear tu sitio usando el sitemap.

ℹ️

Puede tomar de 24 horas a varias semanas para que Google indexe completamente todas tus páginas.

Mejores prácticas

1. Solo incluye páginas canónicas

No incluyas en tu sitemap:

  • URLs con parámetros de query (ejemplo: ?page=2)
  • Páginas duplicadas
  • Páginas que redirigen a otras
  • Páginas privadas que requieren login

2. Usa URLs absolutas

// Correcto
url: 'https://tudominio.com/blog/post'

// Incorrecto
url: '/blog/post'

3. Mantén actualizado lastModified

Usa la fecha real de modificación cuando sea posible:

// Bueno - Fecha real del post
lastModified: post.updatedAt

// Malo - Siempre la fecha actual
lastModified: new Date()

4. No exageres con las prioridades

No pongas priority 1.0 en todas las páginas. Usa la prioridad para indicar importancia relativa.

5. Límite de URLs

Un sitemap puede tener hasta 50,000 URLs y máximo 50MB. Si tienes más, usa Sitemap Index.

Troubleshooting común

Sitemap no se genera

Problema: Visitas /sitemap.xml y obtienes 404.

Soluciones:

  1. Verifica que el archivo esté en app/sitemap.ts (no en otra carpeta)
  2. Reinicia el servidor de desarrollo
  3. Limpia el cache: rm -rf .next && npm run dev

URLs incorrectas en producción

Problema: Las URLs tienen localhost en lugar de tu dominio.

Solución: Usa variables de entorno:

// app/sitemap.ts
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://tudominio.com'

En .env.local:

NEXT_PUBLIC_SITE_URL=http://localhost:3000

En producción (Vercel):

NEXT_PUBLIC_SITE_URL=https://tudominio.com

Sitemap no se actualiza

Problema: Agregaste páginas pero no aparecen en el sitemap.

Solución: El sitemap se genera en build time. En producción, necesitas:

  1. Hacer un nuevo deploy
  2. O usar Incremental Static Regeneration

Conclusión

Crear un sitemap automático en NextJS 15 es sorprendentemente simple:

  1. Crea app/sitemap.ts
  2. Retorna un array de URLs con metadata
  3. NextJS genera el XML automáticamente
  4. Submit a Google Search Console

Tienes dos enfoques principales:

Manual/Híbrido: Control total sobre cada URL, perfecto para bases de datos o CMS
Automático: Lee el filesystem, cero mantenimiento, perfecto para estructuras basadas en carpetas

Beneficios que obtienes:

  • Mejor indexación en Google
  • Páginas nuevas descubiertas más rápido
  • Mejor SEO general
  • Control sobre qué páginas indexar

Elige el enfoque que mejor se adapte a tu proyecto. Incluso puedes combinar ambos según tus necesidades.


Recursos adicionales

¿Implementaste tu sitemap exitosamente? ¡Comparte este artículo con otros desarrolladores!


Soy desarrollador web especializado en NextJS y React. Crea documentación técnica en español para ayudar a la comunidad de desarrolladores en Latinoamérica.