Skip to content

Construye un blog dinámico con MDX, Contentlayer y Next.js 13

¿Quieres llevar tu blog al siguiente nivel? En este artículo, aprenderás cómo crear un blog interactivo utilizando MDX y Next.js 13. Descubre cómo combinar el poder del formato Markdown y la flexibilidad de los componentes de React para construir una experiencia de blogging excepcional.

Con MDX, podrás integrar fácilmente contenido escrito en Markdown con componentes de React, lo que te permitirá agregar interactividad y personalización a tus publicaciones. Olvídate de los blogs estáticos y descubre cómo crear un diseño dinámico y atractivo con esta poderosa combinación de tecnologías.

En este artículo, te proporcionaremos consejos prácticos, ejemplos de código y guías paso a paso para que puedas aprovechar al máximo MDX y Next.js 13 en tu proyecto de desarrollo web. Aprenderás cómo estructurar tu blog, cómo añadir componentes interactivos, y cómo crear una experiencia de usuario única y envolvente.

No importa si eres un principiante o tienes experiencia en desarrollo web, este artículo te brindará los conocimientos necesarios para crear un blog interactivo y cautivador. Prepárate para llevar tu contenido al siguiente nivel y crear una experiencia de blogging memorable utilizando MDX y Next.js 13. ¡Comienza hoy mismo y destaca en el mundo del blogging!

⭐ Videos Premium ⭐

Esta sección es parte del curso en Udemy. Si quieres acceder a ella, puedes comprar el curso en Udemy: React + Firebase by bluuweb.

Markdown

Markdown es un lenguaje de marcado ligero que permite agregar formato a un texto plano. Es muy usado en sitios web para agregar contenido a páginas web. Es muy fácil de aprender y usar.

MDX

MDX permite extender Markdown con código JS y JSX. Gracias a esto permite usar componentes personalizados de React o cualquier componente descargado de npm para agregar más contenido que normalmente no sería posible con simple Markdown.

Contentlayer

Contentlayer es una librería que permite agregar contenido a un sitio web usando Markdown y MDX. Es muy fácil de usar y permite agregar contenido a un sitio web sin necesidad de usar una base de datos.

  • contentlayer.dev
  • Solo usa JS/TS: No es necesario aprender un nuevo lenguaje de consulta o documentos API complicados para leer. Importe y manipule su contenido como datos directamente con los métodos de JavaScript que conoce y ama.
  • Confianza de código incorporada: Genera tipos TypeScript y valida el contenido.
  • Build Faster: Contentlayer + Next.js brinda tiempos de compilación más rápidos que Next.js solo o frente a otros marcos, como Gatsby.

Setup

bash
npx create-next-app@latest .
sh
npm install contentlayer next-contentlayer

next.config.js

ts
const { withContentlayer } = require("next-contentlayer");

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    appDir: true,
  },
};

module.exports = withContentlayer(nextConfig);

tsconfig.json

ts
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"],
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".contentlayer/generated"],
  "exclude": ["node_modules"]
}

contentlayer.config.js

Esta configuración especifica un solo tipo de documento llamado Post. Se espera que estos documentos sean .mdx archivos que viven dentro de un directorio posts en su proyecto. Y los objetos de datos generados a partir de estos archivos tendrán las siguientes propiedades:

  • title, description, date
  • body: un objeto que contiene el rawcontenido del archivo Markdown y la htmlcadena convertida. (Esto está integrado en Contentlayer de forma predeterminada y no es necesario definirlo).
  • url: una cadena que toma el nombre del archivo (sin la extensión) y lo antepone /posts/, definiendo así la ruta en la que ese contenido estará disponible en su sitio.
ts
// contentlayer.config.js

import { defineDocumentType, makeSource } from "contentlayer/source-files";

export const Post = defineDocumentType(() => ({
  name: "Post",
  filePathPattern: `**/*.mdx`,
  contentType: "mdx",
  fields: {
    title: {
      type: "string",
      description: "The title of the post",
      required: true,
    },
    description: {
      type: "string",
      description: "The description of the post",
      required: true,
    },
    date: {
      type: "date",
      description: "The date of the post",
      required: true,
    },
  },
  computedFields: {
    url: {
      type: "string",
      resolve: (post) => `/posts/${post._raw.flattenedPath}`,
    },
  },
}));

export default makeSource({
  contentDirPath: "posts",
  documentTypes: [Post],
});

Contenido MDX

sh
posts/
├── post-01.mdx
├── post-02.mdx
└── post-03.mdx

posts\post-01.mdx

mdx
---
title: 01 Lorem Ipsum
description: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
date: 2021-12-24
---

Ullamco et nostrud magna commodo nostrud occaecat quis pariatur id ipsum. Ipsum
consequat enim id excepteur consequat nostrud esse esse fugiat dolore.
Reprehenderit occaecat exercitation non cupidatat in eiusmod laborum ex eu
fugiat aute culpa pariatur. Irure elit proident consequat veniam minim ipsum ex
pariatur.

Mollit nisi cillum exercitation minim officia velit laborum non Lorem
adipisicing dolore. Labore commodo consectetur commodo velit adipisicing irure
dolore dolor reprehenderit aliquip. Reprehenderit cillum mollit eiusmod
excepteur elit ipsum aute pariatur in. Cupidatat ex culpa velit culpa ad non
labore exercitation irure laborum.

Posts

El método localeCompare() compara dos cadenas de texto en función de la configuración regional del usuario y devuelve un valor negativo si la primera cadena debe ir antes que la segunda, un valor positivo si la primera cadena debe ir después de la segunda, y cero si son iguales.

Al usar el método localeCompare() con las fechas en orden inverso (b.date.localeCompare(a.date)), se ordenan los objetos de la publicación de manera que la fecha más reciente aparezca primero en el array (orden descendente).

toLocaleDateString

tsx
import { allPosts } from "contentlayer/generated";
import Link from "next/link";

const posts = allPosts.sort((a, b) => b.date.localeCompare(a.date));

const page = () => {
  return (
    <div>
      <h1 className="text-center my-4 text-4xl">Posts</h1>
      <div className="grid gap-4">
        {posts.map((post) => (
          <article key={post._raw.flattenedPath}>
            <h2 className="text-2xl">
              <Link href={post.url}>{post.title}</Link>
            </h2>
            <time>
              {new Date(post.date).toLocaleDateString("es-ES", {
                year: "numeric",
                month: "long",
                day: "numeric",
              })}
            </time>
          </article>
        ))}
      </div>
    </div>
  );
};
export default page;

postContent

TIP

En breve veremos generateStaticParams y generateMetadata

tsx
import { allPosts } from "contentlayer/generated";
import { getMDXComponent } from "next-contentlayer/hooks";

interface Props {
  params: {
    slug: string;
  };
}

export const generateStaticParams = () => {
  return allPosts.map((post) => ({
    slug: post._raw.flattenedPath,
  }));
};

export const generateMetadata = ({ params }: Props) => {
  const post = allPosts.find((p) => p._raw.flattenedPath === params.slug);
  return {
    title: post?.title,
    description: post?.description,
  };
};

const PostLayout = ({ params }: Props) => {
  const post = allPosts.find((p) => p._raw.flattenedPath === params.slug);

  let MDXContent;
  if (!post) {
    return <div>Post not found</div>;
  } else {
    MDXContent = getMDXComponent(post.body.code);
  }

  return (
    <div>
      <h1 className="text-center my-4 text-3xl">{post.title}</h1>
      <time>
        {new Date(post.date).toLocaleDateString("es-ES", {
          year: "numeric",
          month: "long",
          day: "numeric",
        })}
      </time>
      <article>
        <MDXContent />
      </article>
    </div>
  );
};
export default PostLayout;

src\app\globals.css

css
@tailwind base;
@tailwind components;
@tailwind utilities;

p {
  @apply mb-4;
}

not-found

  • 404 not-found next
  • Esto significa que a los usuarios que visiten una URL que su aplicación no maneja se les mostrará la interfaz de usuario exportada por el archivo app/not-found.tsx.

src\app\not-found.tsx

tsx
import Link from "next/link";

const NotFound = () => {
  return (
    <div className="grid gap-4 h-screen place-content-center">
      <h1 className="text-center text-3xl">404</h1>
      <Link
        href="/"
        className="bg-black text-white py-2 px-4 rounded-md hover:bg-slate-700"
      >
        Volver al inicio
      </Link>
    </div>
  );
};
export default NotFound;

notFound() (servidor)

  • notfound
  • La invocación de la función notFound() genera un error NEXT_NOT_FOUND y finaliza la representación del segmento de ruta en el que se generó.

TIP

notFound() no requiere que lo use return, debido al uso del tipo never TypeScript.

never: En un tipo de retorno, esto significa que la función lanza una excepción o finaliza la ejecución del programa.

src\app\posts[slug]\page.tsx

tsx
import { notFound } from "next/navigation";
tsx
const PostLayout = ({ params }: Props) => {
  const post = allPosts.find((p) => p._raw.flattenedPath === params.slug);

  let MDXContent;

  if (!post) {
    notFound();
  } else {
    MDXContent = useMDXComponent(post.body.code);
  }

  return (
    <div>
      <h1 className="text-center my-4 text-3xl">{post.title}</h1>
      <time>
        {new Date(post.date).toLocaleDateString("es-ES", {
          year: "numeric",
          month: "long",
          day: "numeric",
        })}
      </time>
      <MDXContent />
    </div>
  );
};
export default PostLayout;

generateStaticParams

  • api-reference/generate-static-params
  • Generar rutas de forma estática en el momento de la compilación en lugar de a pedido en el momento de la solicitud.
  • Durante next build, generateStaticParams se ejecuta antes de que se generen los Layout o Páginas correspondientes.
  • generateStaticParams debe devolver un array de objetos donde cada objeto representa los segmentos dinámicos poblados de una sola ruta.
Ruta de ejemplogenerateStaticParams
/product/[id]{ id: string }[]
/products/[category]/[product]{ category: string, product: string }[]
/products/[...slug]{ slug: string[] }[]

src\app\posts[slug]\page.tsx

tsx
export const generateStaticParams = async () =>
  allPosts.map((post) => ({ slug: post._raw.flattenedPath }));

static export

static-export

Para habilitar una exportación estática, cambie el modo de salida dentro next.config.js:

ts
const { withContentlayer } = require("next-contentlayer");

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    appDir: true,
  },
  output: "export",
};

module.exports = withContentlayer(nextConfig);

Después de ejecutar next build, Next.js generará una carpeta out que contiene los activos HTML/CSS/JS para su aplicación.

rehype-pretty-code

sh
npm install rehype-pretty-code shiki

contentlayer.config.ts

ts
import { defineDocumentType, makeSource } from "contentlayer/source-files";
import rehypePrettyCode from "rehype-pretty-code";

export const Post = defineDocumentType(() => ({
  name: "Post",
  filePathPattern: `**/*.mdx`,
  contentType: "mdx",
  fields: {
    title: {
      type: "string",
      description: "The title of the post",
      required: true,
    },
    description: {
      type: "string",
      description: "The description of the post",
      required: true,
    },
    date: {
      type: "date",
      description: "The date of the post",
      required: true,
    },
  },
  computedFields: {
    url: {
      type: "string",
      resolve: (post) => `/posts/${post._raw.flattenedPath}`,
    },
  },
}));

const rehypeoptions = {
  // Use one of Shiki's packaged themes
  theme: "one-dark-pro",
  // Set to true to keep the background color
  keepBackground: true,
  onVisitLine(node: any) {
    if (node.children.length === 0) {
      node.children = [{ type: "text", value: " " }];
    }
  },
  onVisitHighlightedLine(node: any) {
    node.properties.className.push("highlighted");
  },
  onVisitHighlightedWord(node: any, id: any) {
    node.properties.className = ["word"];
  },
};

export default makeSource({
  contentDirPath: "posts",
  documentTypes: [Post],
  mdx: {
    rehypePlugins: [[rehypePrettyCode, rehypeoptions]],
  },
});

src\app\globals.css

css
@tailwind base;
@tailwind components;
@tailwind utilities;

p {
  @apply mb-4;
}

pre > code {
  @apply grid;
}

pre {
  @apply p-4 rounded my-4;
}

article pre code {
  @apply bg-transparent text-sm border-none;
}

article code {
  @apply bg-gray-200 px-2 rounded text-sm border;
}

Paginación (servidor)

src\components\PostsList.tsx

tsx
import { Post } from "contentlayer/generated";
import PostItem from "./PostItem";

interface Props {
  posts: Post[];
}

const PostsList = ({ posts }: Props) => {
  return (
    <>
      {posts.map((post) => (
        <PostItem
          key={post._raw.flattenedPath}
          post={post}
        />
      ))}
    </>
  );
};
export default PostsList;

src\components\PostItem.tsx

tsx
import Link from "next/link";
import { format, parseISO } from "date-fns";
import { Post } from "contentlayer/generated";

interface Props {
  post: Post;
}

const PostItem = ({ post }: Props) => {
  return (
    <article className="shadow-md rounded p-4">
      <Link
        href={post.url}
        className="block text-xl"
      >
        {post.title}
      </Link>
      <time
        dateTime={post.date}
        className="text-sm text-slate-600"
      >
        {format(parseISO(post.date), "dd-MM-yyyy")}
      </time>
    </article>
  );
};
export default PostItem;

src\utils\postsPaginationUtils.ts

En el siguiente código, offset representa el índice de inicio desde el cual se deben extraer los elementos del array posts para mostrarlos en la página actual.

La variable currentPage es la página actual y postsPerPage es la cantidad de elementos que se deben mostrar en cada página.

El cálculo (currentPage - 1) * postsPerPage determina cuántos elementos se deben "saltar" antes de comenzar a mostrar los elementos en la página actual.

Por ejemplo, si currentPage es 1 y postsPerPage es 5, el offset será 0, lo que significa que no se salta ningún elemento y se muestra la primera página de elementos.

Si currentPage es 2 y postsPerPage es 5, el offset será 5, lo que significa que se saltan los primeros 5 elementos del array y se muestra la segunda página de elementos.

La función slice se utiliza para extraer una parte del array posts comenzando desde el offset y terminando en offset + postsPerPage. Estos elementos serán los que se muestren en la página actual.

src\utils\postsPaginationUtils.ts

tsx
import { allPosts } from "contentlayer/generated";

const posts = allPosts.sort((a, b) => b.date.localeCompare(a.date));

const totalPosts = posts.length;
const postsPerPage = 2;
export const totalPages = Math.ceil(totalPosts / postsPerPage);

export const getPaginatedPosts = (currentPage: number = 1) => {
  if (currentPage > totalPages || currentPage < 1) {
    throw new Error("Invalid page number");
  }

  const offset = (currentPage - 1) * postsPerPage;
  return posts.slice(offset, offset + postsPerPage);
};

src\app\pages[page]\page.tsx

En el siguiente código se utiliza Array.from para crear un array de números que representan las páginas disponibles.

src\app\pages[page]\page.tsx

tsx
import { notFound } from "next/navigation";

import { totalPages, getPaginatedPosts } from "@/utils/postsPaginationUtils";

import Pagination from "@/components/Pagination";
import PostsList from "@/components/PostsList";

export const generateStaticParams = async () => {
  return Array.from({ length: totalPages }, (_, i) => ({
    page: (i + 1).toString(),
  }));
};

const PageLayout = ({ params }: { params: { page: string } }) => {
  const { page } = params;

  let limitedPosts;
  try {
    if (!/^\d+$/.test(page)) {
      throw new Error("Invalid page number");
    }
    limitedPosts = getPaginatedPosts(parseInt(page));
  } catch (error) {
    notFound();
  }

  return (
    <div>
      <h1 className="text-center my-4 text-4xl">Posts</h1>
      <div className="grid gap-4">
        <PostsList posts={limitedPosts} />

        <Pagination
          totalPages={totalPages}
          currentPage={page ? parseInt(page) : 1}
        />
      </div>
    </div>
  );
};
export default PageLayout;

src\components\Pagination.tsx

tsx
import Link from "next/link";

interface Props {
  totalPages: number;
  currentPage?: number;
}

const Pagination = ({ totalPages, currentPage = 1 }: Props) => {
  return (
    <div className="flex gap-6">
      {currentPage === 1 ? (
        <button
          disabled
          className="cursor-not-allowed"
        >
          Prev
        </button>
      ) : (
        <Link href={`/pages/${currentPage - 1}`}>Prev</Link>
      )}

      {Array.from({ length: totalPages }).map((_, i) => (
        <Link
          key={i}
          href={`/pages/${i + 1}`}
          className={`${
            currentPage === i + 1
              ? "bg-blue-500 rounded-full w-8 text-center text-white"
              : "bg-gray-200 rounded-full w-8 text-center text-gray-500"
          }`}
        >
          {i + 1}
        </Link>
      ))}
      {currentPage === totalPages ? (
        <button
          disabled
          className="cursor-not-allowed"
        >
          Next
        </button>
      ) : (
        <Link href={`/pages/${currentPage + 1}`}>Next</Link>
      )}
    </div>
  );
};
export default Pagination;

src\app\posts\page.tsx

tsx
import { totalPages, getPaginatedPosts } from "@/utils/postsPaginationUtils";

import Pagination from "@/components/Pagination";
import PostsList from "@/components/PostsList";

const Posts = () => {
  const limitedPosts = getPaginatedPosts();

  return (
    <div>
      <h1 className="text-center my-4 text-4xl">Posts</h1>
      <div className="grid gap-4">
        <PostsList posts={limitedPosts} />

        <Pagination totalPages={totalPages} />
      </div>
    </div>
  );
};
export default Posts;

Paginación (cliente)

  • Math.ceil() es una función en JavaScript que redondea un número hacia arriba al entero más cercano. Esta función es útil cuando se necesita obtener un valor entero que sea igual o mayor que el número proporcionado.

src\app\posts\page.tsx

tsx
import PostsList from "@/components/PostsList";

const Posts = () => {
  return (
    <div>
      <h1 className="text-center my-4 text-4xl">Posts</h1>
      <div className="grid gap-4">
        <PostsList />
      </div>
    </div>
  );
};
export default Posts;

src\components\PostsList.tsx

tsx
"use client";

import { compareDesc } from "date-fns";
import { allPosts, Post } from "contentlayer/generated";
import { useSearchParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";

import PostItem from "@/components/PostItem";
import Pagination from "./Pagination";

const sortedPost: Post[] = allPosts.sort((a: Post, b: Post) =>
  compareDesc(new Date(a.date), new Date(b.date))
);

const totalPosts = sortedPost.length;
const postsPerPage = 2;
const totalPages = Math.ceil(totalPosts / postsPerPage);

const PostsList = () => {
  const searchParams = useSearchParams();
  const page = searchParams.get("page");
  const router = useRouter();

  const [posts, setPosts] = useState<Post[]>([]);

  useEffect(() => {
    if (page) {
      if (!parseInt(page) || parseInt(page) > totalPages) {
        router.push("/posts");
        return;
      }
      const start = (parseInt(page) - 1) * postsPerPage;
      const end = start + postsPerPage;
      setPosts(sortedPost.slice(start, end));
    } else {
      setPosts(sortedPost.slice(0, postsPerPage));
    }
  }, [page]);

  return (
    <>
      {posts.map((post: Post) => (
        <PostItem
          key={post._raw.flattenedPath}
          post={post}
        />
      ))}
      <Pagination
        totalPages={totalPages}
        currentPage={page ? parseInt(page) : 1}
      />
    </>
  );
};
export default PostsList;

src\components\Pagination.tsx

tsx
import Link from "next/link";

interface Props {
  totalPages: number;
  currentPage?: number;
}

const Pagination = ({ totalPages, currentPage = 1 }: Props) => {
  return (
    <div className="flex gap-6">
      {currentPage === 1 ? (
        <button
          disabled
          className="cursor-not-allowed"
        >
          Prev
        </button>
      ) : (
        <Link
          href={{
            pathname: "/posts",
            query: { page: currentPage - 1 },
          }}
        >
          Prev
        </Link>
      )}

      {Array.from({ length: totalPages }).map((_, i) => (
        <Link
          key={i}
          href={{
            pathname: "/posts",
            query: { page: i + 1 },
          }}
          className={`${
            currentPage === i + 1
              ? "bg-blue-500 rounded-full w-8 text-center text-white"
              : "bg-gray-200 rounded-full w-8 text-center text-gray-500"
          }`}
        >
          {i + 1}
        </Link>
      ))}
      {currentPage === totalPages ? (
        <button
          disabled
          className="cursor-not-allowed"
        >
          Next
        </button>
      ) : (
        <Link
          href={{
            pathname: "/posts",
            query: { page: currentPage + 1 },
          }}
        >
          Next
        </Link>
      )}
    </div>
  );
};
export default Pagination;

Fonts

  • Optimizing Fonts: optimizará automáticamente sus fuentes (incluidas las fuentes personalizadas) y eliminará las solicitudes de red externa para mejorar la privacidad y el rendimiento.
tsx
import { Commissioner } from "next/font/google";
const commissioner = Commissioner({ subsets: ["latin"], display: "swap" });

subsets: ["latin"]: Google fonts reduce automáticamente el tamaño de la fuente y la subconjuga para que solo se cargue el alfabeto que necesita (en este caso cargamos latin). Esto reduce el tamaño de la fuente y el tiempo de carga de la página.

display: "swap" significa que la fuente se cargará en segundo plano y se aplicará a la página una vez que esté disponible.

Layout

src\app\layout.tsx

tsx
import "./globals.css";
import Avatar from "@/assets/images/avatar.png";

import { Commissioner } from "next/font/google";
import Link from "next/link";
const commissioner = Commissioner({ subsets: ["latin"], display: "swap" });

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="es">
      <body className={`${commissioner.className} bg-gray-900`}>
        <main className="grid min-h-screen items-center py-20">
          <div className="mx-auto rounded-md bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500 p-1 sm:w-[500px] md:w-[700px]">
            <div className="rounded bg-gray-100 p-8">
              <Link href="/">
                <img
                  src={Avatar.src}
                  alt="Imagen avatar de presentación"
                  className="mx-auto -mt-20 mb-8 rounded-full "
                  width={100}
                  height={100}
                />
              </Link>
              {children}
            </div>
          </div>
        </main>
      </body>
    </html>
  );
}

Home

src\app\page.tsx

tsx
import ButtonLink from "@/components/ButtonLink";

export const metadata = {
  title: "Home page - Create Next App",
  description: "Generated by create next app",
};

const Home = () => {
  return (
    <>
      <h1 className="text-center text-3xl font-bold uppercase">
        Jesús Quintana
      </h1>
      <p className="mb-8 text-center text-gray-700">Backend Developer</p>
      <p className="mb-8 text-center">
        Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolor omnis
        impedit eum quas reprehenderit, blanditiis eveniet iure aliquam fugit
        minus, illum quos soluta. Architecto quasi perspiciatis eos rerum vero
        non?
      </p>

      <div className="text-center">
        <ButtonLink href="/blog">Ir al blog</ButtonLink>
      </div>
    </>
  );
};
export default Home;

src\components\ButtonLink.tsx

tsx
import Link from "next/link";

interface Props {
  href: string;
  children: React.ReactNode;
}

const ButtonLink = ({ href, children }: Props) => {
  return (
    <Link
      href={href}
      className="rounded bg-gradient-to-r from-pink-500 to-yellow-500 px-4 py-2 text-white hover:from-green-400 hover:to-blue-500"
    >
      {children}
    </Link>
  );
};
export default ButtonLink;

not-found

src\app\not-found.tsx

tsx
import ButtonBack from "@/components/ButtonBack";

const NotFound = () => {
  return (
    <div className="grid gap-4">
      <h1 className="text-center text-2xl">404</h1>
      <div className="text-center">
        <ButtonBack />
      </div>
    </div>
  );
};
export default NotFound;

ButtonBack

src\components\ButtonBack.tsx

tsx
"use client";

import { useRouter } from "next/navigation";

const ButtonBack = () => {
  const router = useRouter();

  return (
    <button
      onClick={() => router.back()}
      type="button"
      className="rounded bg-gradient-to-r from-pink-500 to-yellow-500 px-4 py-2 text-white hover:from-green-400 hover:to-blue-500"
    >
      Volver
    </button>
  );
};
export default ButtonBack;

app\blog\page.tsx

src\app\blog\page.tsx

tsx
import { allPosts } from "contentlayer/generated";
import PostsLists from "@/components/PostsLists";
import PostsPagination from "@/components/PostsPagination";
import { getPagination } from "@/utils/pagination";

export const metadata = {
  title: "Lista de todos los post",
  description: "Description posts - Generated by create next app",
};

const Posts = () => {
  const { currentItems, totalPages } = getPagination(allPosts);

  return (
    <div className="grid gap-4">
      <PostsLists posts={currentItems} />
      {totalPages > 1 && <PostsPagination totalPages={totalPages} />}
    </div>
  );
};
export default Posts;

utils/pagination.ts

  • <T> es una notación de TypeScript para representar un tipo genérico. Los tipos genéricos permiten escribir funciones y clases que trabajan con varios tipos de datos sin tener que especificar un tipo concreto en su definición. Esto hace que el código sea más reutilizable y flexible.
  • TypeScript infiere automáticamente el tipo concreto basado en el tipo de los elementos del array items que le pasas como argumento.
ts
const isNumeric = (str: string) => /^\d+$/.test(str);

export const getPagination = <T>(
  items: T[],
  itemsPerPage = 2,
  currentPage = "1"
) => {
  if (!isNumeric(currentPage)) {
    throw new Error("Not a number");
  }

  const currentPageInt = parseInt(currentPage);
  const totalItems = items.length;
  const totalPages = Math.ceil(totalItems / itemsPerPage);

  if (currentPageInt > totalPages) {
    throw new Error(`Page ${currentPageInt} does not exist`);
  }

  const offset = (currentPageInt - 1) * itemsPerPage;
  const currentItems = items.slice(offset, offset + itemsPerPage);

  return {
    currentItems,
    totalPages,
  };
};

page[number]\page.tsx

src\app\page[number]\page.tsx

tsx
import { allPosts } from "contentlayer/generated";
import { getPagination } from "@/utils/pagination";

import PostsLists from "@/components/PostsLists";
import PostsPagination from "@/components/PostsPagination";

import { notFound } from "next/navigation";

interface Props {
  params: {
    number: string;
  };
}

export const generateStaticParams = () => {
  return Array.from({ length: allPosts.length }).map((_, index) => ({
    number: `${index + 1}`,
  }));
};

const LayoutPages = ({ params }: Props) => {
  let arrayCurrentPosts;
  let totalPagesNumber;
  try {
    const { currentItems, totalPages } = getPagination(
      allPosts,
      2,
      params.number
    );
    arrayCurrentPosts = currentItems;
    totalPagesNumber = totalPages;
  } catch (error) {
    notFound();
  }

  return (
    <div className="grid gap-4">
      <PostsLists posts={arrayCurrentPosts} />

      {totalPagesNumber > 1 && (
        <PostsPagination
          totalPages={totalPagesNumber}
          currentPage={parseInt(params.number)}
        />
      )}
    </div>
  );
};
export default LayoutPages;

PostLists

src\components\PostLists.tsx

tsx
import { Post } from "contentlayer/generated";
import PostItem from "./PostItem";

interface Props {
  posts: Post[];
}

const PostsLists = ({ posts }: Props) => {
  return (
    <>
      {posts.map((post) => (
        <PostItem
          key={post._raw.flattenedPath}
          post={post}
        />
      ))}
    </>
  );
};
export default PostsLists;

PostItem

src\components\PostItem.tsx

tsx
import { Post } from "contentlayer/generated";
import Link from "next/link";
import ButtonLink from "./ButtonLink";

interface Props {
  post: Post;
}

const PostItem = ({ post }: Props) => {
  return (
    <article className="rounded border p-4">
      <h2 className="bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500 bg-clip-text text-4xl font-bold text-transparent">
        <Link href={post.url}>{post.title}</Link>
      </h2>
      <time>
        {new Date(post.date).toLocaleDateString("es-ES", {
          year: "numeric",
          month: "long",
          day: "numeric",
        })}
      </time>
      <p>{post.description}</p>
      <ButtonLink href={post.url}>Seguir leyendo</ButtonLink>
    </article>
  );
};
export default PostItem;

blog/[slug]

src\app\blog[slug]\page.tsx

tsx
import { notFound } from "next/navigation";
import { allPosts } from "contentlayer/generated";
import { useMDXComponent } from "next-contentlayer/hooks";
import ButtonBack from "@/components/ButtonBack";

interface Props {
  params: {
    slug: string;
  };
}

export const generateStaticParams = () => {
  return allPosts.map((post) => ({ slug: post._raw.flattenedPath }));
};

export const generateMetadata = ({ params }: Props) => {
  const post = allPosts.find((p) => p._raw.flattenedPath === params.slug);

  return {
    title: post?.title,
    description: post?.description,
  };
};

const PostLayout = ({ params }: Props) => {
  const post = allPosts.find((p) => p._raw.flattenedPath === params.slug);

  let MDXContent;

  if (!post) {
    notFound();
  } else {
    MDXContent = useMDXComponent(post.body.code);
  }

  return (
    <>
      <h1 className="text-center text-2xl font-bold uppercase">{post.title}</h1>
      <div className="mb-8 text-center">
        <time className="text-gray-700">
          {new Date(post.date).toLocaleDateString("es-ES", {
            year: "numeric",
            month: "long",
            day: "numeric",
          })}
        </time>
      </div>

      <MDXContent />

      <div className="mt-8 text-center">
        <ButtonBack />
      </div>
    </>
  );
};
export default PostLayout;

Deploy

Próximamente...