EmpiezaTuSaaS
Funcionalidades

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 utilidades

Dependencias: 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

content/blog/integrar-stripe-nextjs.mdx
---
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...
CampoTipoRequeridoDefault
titlestringSi""
descriptionstringSi""
datestringSiFecha actual
authorstringNo"EmpiezaTuSaaS"
authorImagestringNo--
categorystringNo"tutoriales"
tagsstring[]No[]
imagestringNo--
publishedbooleanNotrue

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

app/blog/[slug]/page.tsx
<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:

content/blog/mi-post.mdx
<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:

lib/blog.ts
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:

app/rss.xml/route.ts
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-saas

Checklist 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: true en el frontmatter

Herramientas de verificación

Si usas imágenes de dominios externos (Cloudinary, S3), añade los hostnames a images.remotePatterns en next.config.ts.

On this page