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?
- Indexación más rápida: Google descubre tus páginas nuevas más rápido
- Mejor SEO: Los motores entienden la estructura de tu sitio
- Control: Decides qué páginas son más importantes
- 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
Crea el archivo app/sitemap.ts
En la raíz de tu carpeta app/
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,
},
]
}
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 accedehourly
- Cada horadaily
- Diarioweekly
- Semanalmonthly
- Mensualyearly
- Anualnever
- 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 principales0.8
- Páginas de categoría importantes0.6
- Páginas de subcategoría0.5
- Posts de blog, artículos0.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?
-
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
- Lee todas las carpetas dentro de
-
generateDocsPages()
:- Escanea recursivamente toda la estructura de
app/docs/
- Genera URLs para cada carpeta encontrada
- Mayor prioridad para niveles superiores
- Escanea recursivamente toda la estructura de
-
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
:
Crea app/robots.ts
En la raíz de tu carpeta app/
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',
}
}
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:
Inicia sesión con tu cuenta de Google
Agrega tu sitio web
Si aún no lo has hecho
Ve a Sitemaps
En el menú lateral
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:
- Verifica que el archivo esté en
app/sitemap.ts
(no en otra carpeta) - Reinicia el servidor de desarrollo
- 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:
- Hacer un nuevo deploy
- O usar Incremental Static Regeneration
Conclusión
Crear un sitemap automático en NextJS 15 es sorprendentemente simple:
- Crea
app/sitemap.ts
- Retorna un array de URLs con metadata
- NextJS genera el XML automáticamente
- 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.