Blog
Blog con MDX, categorías, RSS y SEO optimizado
Blog
EmpiezaTuSaaS incluye un blog completo con MDX, categorías, tags, autores, RSS feed, JSON-LD y tabla de contenidos. Todo basado en archivos -- sin base de datos.
Stack y estructura
content/blog/ # Articulos en MDX
app/blog/
├── page.tsx # Lista de posts con filtros
├── [slug]/page.tsx # Post individual
├── category/[category]/ # Posts por categoria
├── tag/[tag]/ # Posts por tag
└── author/[author]/ # Posts por autor
app/rss.xml/route.ts # RSS feed
components/blog/
├── post-card.tsx # Tarjeta de post (shadcn Card + Badge)
├── post-header.tsx # Header del post (Avatar + Badge)
├── mdx-components.tsx # Callout, YouTube, CodeComparison
├── table-of-contents.tsx # TOC client-side (IntersectionObserver)
└── share-buttons.tsx # Twitter, LinkedIn, copiar link
components/seo/json-ld.tsx # ArticleJsonLd, BreadcrumbJsonLd
lib/blog.ts # Tipos, categorías y utilidadesDependencias: gray-matter, reading-time, feed, next-mdx-remote, rehype-slug. Todas incluidas en package.json.
El MDX se renderiza con next-mdx-remote/rsc en Server Components. No se usa @next/mdx ni rehype-pretty-code.
Crear posts
Cada post es un archivo .mdx en content/blog/. El nombre del archivo es el slug de la URL.
Frontmatter
---
title: Como integrar Stripe en Next.js
description: Guia paso a paso para cobrar en tu SaaS con Stripe Checkout
date: 2026-02-01
author: EmpiezaTuSaaS
category: tutoriales
tags: [stripe, pagos, nextjs]
image: https://images.unsplash.com/photo-example?w=800
published: true
---
# Como integrar Stripe en Next.js
Tu contenido aqui...| Campo | Tipo | Requerido | Default |
|---|---|---|---|
title | string | Si | "" |
description | string | Si | "" |
date | string | Si | Fecha actual |
author | string | No | "EmpiezaTuSaaS" |
authorImage | string | No | -- |
category | string | No | "tutoriales" |
tags | string[] | No | [] |
image | string | No | -- |
published | boolean | No | true |
El readingTime se calcula automáticamente con el paquete reading-time.
Borradores
Usa published: false para crear borradores. No aparecen en la lista, el sitemap ni Google. La página retorna notFound() si alguien accede a la URL directamente.
Renderizado MDX
<div className="prose prose-zinc dark:prose-invert max-w-none">
<MDXRemote
source={post.content}
components={mdxComponents}
options={{
mdxOptions: { rehypePlugins: [rehypeSlug] },
}}
/>
</div>rehype-slug añade IDs a los headings para que la tabla de contenidos pueda navegar a cada sección.
Componentes MDX disponibles
Usa estos componentes directamente en tus posts sin importarlos:
<Callout type="info">
Información importante para el lector.
</Callout>
<Callout type="tip">
Un consejo practico.
</Callout>
<YouTube id="dQw4w9WgXcQ" title="Tutorial de Next.js" />
<CodeComparison
before="const data = fetch(url).then(r => r.json())"
after="const data = await fetch(url).then(r => r.json())"
/>Tipos de Callout: info, warning, success, error, tip. Todos definidos en components/blog/mdx-components.tsx.
Categorias y tags
Un post pertenece a una categoria y puede tener multiples tags.
Categorias
Definidas en lib/blog.ts como un array exportado:
export const categories: BlogCategory[] = [
{ slug: "tutoriales", name: "Tutoriales", description: "Guias paso a paso", color: "bg-blue-500/10 text-blue-500" },
{ slug: "negocio", name: "Negocio", description: "Estrategias SaaS", color: "bg-green-500/10 text-green-500" },
{ slug: "tecnologia", name: "Tecnologia", description: "Stack tecnologico", color: "bg-purple-500/10 text-purple-500" },
{ slug: "noticias", name: "Noticias", description: "Actualizaciones", color: "bg-orange-500/10 text-orange-500" },
];Para agregar una categoria, añade un objeto al array. Usa el slug exacto en el frontmatter del post.
Tags
Los tags son libres -- se definen en el frontmatter de cada post como un array: tags: [nextjs, stripe, tutorial]. Usa siempre minusculas y guiones. Entre 3-7 tags por post es lo ideal.
Páginas de archivo
Cada categoria, tag y autor tiene su página de archivo con generateStaticParams para SSG:
/blog/category/tutoriales-- Posts filtrados por categoria/blog/tag/nextjs-- Posts filtrados por tag/blog/author/EmpiezaTuSaaS-- Posts filtrados por autor
En el header de cada post, el nombre del autor es un link clickable a su página de archivo.
Utilidades en lib/blog.ts
// Todas las funciones son async
await getAllPosts() // Todos los posts, ordenados por fecha
await getPostBySlug("mi-post") // Post individual
await getPostsByCategory("tutoriales")
await getPostsByTag("nextjs")
await getPostsByAuthor("EmpiezaTuSaaS")
await getAllTags() // Array de tags únicos
await getAllAuthors() // Array de autores con postCount
getCategoryBySlug("tutoriales") // Busca categoria (sync)RSS Feed
Feed RSS disponible en /rss.xml, generado con el paquete feed:
import { Feed } from "feed";
import { getAllPosts } from "@/lib/blog";
import { siteConfig } from "@/config/site";
export async function GET() {
const posts = await getAllPosts();
const publishedPosts = posts.filter((p) => p.published);
const feed = new Feed({
title: `${siteConfig.name} — Blog`,
description: siteConfig.description,
id: siteConfig.url,
link: siteConfig.url,
language: "es",
copyright: `© ${new Date().getFullYear()} ${siteConfig.name}`,
updated: publishedPosts[0] ? new Date(publishedPosts[0].date) : new Date(),
feedLinks: {
rss2: `${siteConfig.url}/rss.xml`,
atom: `${siteConfig.url}/atom.xml`,
json: `${siteConfig.url}/feed.json`,
},
author: {
name: siteConfig.name,
email: siteConfig.supportEmail,
link: siteConfig.url,
},
});
publishedPosts.forEach((post) => {
feed.addItem({
title: post.title,
id: `${siteConfig.url}/blog/${post.slug}`,
link: `${siteConfig.url}/blog/${post.slug}`,
description: post.description,
date: new Date(post.date),
author: [{ name: post.author }],
category: post.tags.map((tag) => ({ name: tag })),
...(post.image && { image: post.image }),
});
});
return new Response(feed.rss2(), {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600, s-maxage=3600",
},
});
}El paquete feed soporta 3 formatos: feed.rss2(), feed.atom1() y feed.json1(). El boilerplate incluye el RSS por defecto. Para Atom o JSON Feed, crea route handlers adicionales en app/atom.xml/route.ts o app/feed.json/route.ts con la misma lógica.
El link al RSS se incluye automáticamente en la metadata de cada post via alternates:
alternates: {
types: {
"application/rss+xml": `${siteConfig.url}/rss.xml`,
},
},Verifica que NEXT_PUBLIC_APP_URL este configurado en producción. El feed usa siteConfig.url que lee de está variable.
SEO del blog
Cada post tiene SEO automatico:
- Metadata dinámico con
generateMetadata(title, description, OG article, Twitter card) - ArticleJsonLd con fecha, autor e imagen (rich snippets en Google)
- Sitemap con todos los posts publicados (prioridad 0.6, frecuencia monthly)
- RSS enlazado via
<link rel="alternate">en el HTML - URLs amigables: el slug es el nombre del archivo
.mdx
content/blog/integrar-stripe-nextjs.mdx → /blog/integrar-stripe-nextjs
content/blog/mejor-auth-para-saas.mdx → /blog/mejor-auth-para-saasChecklist antes de publicar
- Titulo entre 50-60 caracteres, keyword al inicio
- Descripción entre 150-160 caracteres, con call to action
- URL corta y descriptiva (nombre del archivo .mdx)
- Imagen destacada con alt text
- 2-3 links internos a otros posts
published: trueen el frontmatter
Herramientas de verificación
- Google Search Console -- Indexacion y keywords
- Google Rich Results Test -- Datos estructurados
- W3C Feed Validator -- Validar RSS
- Twitter Card Validator -- Preview de cards
Si usas imágenes de dominios externos (Cloudinary, S3), añade los hostnames a images.remotePatterns en next.config.ts.