Saltearse al contenido

Tutorial: Primeros pasos con Nextjs — 9. Montando un post

00:00 / 00:00

9. Montando un post

Ahora mismo, tenemos la lista de posts, pero si pulsamos en uno de ellos, nos da un error de 404 porque no tenemos la página creada, vamos a por ello. Para desarrollar la página en la que mostramos un post vamos a reaprovechar mucho de lo que ya hemos hecho en la página de listado. Y te estarás preguntando: ¿Y esto por qué, si aquí estoy sólo cargando un post? Esto es así por como trabaja Nextjs: En tiempo de compilación, se leen todos los posts y se genera cada página con su detalle, por lo que el proceso es muy similar al de la página de listado.

Lo que sí, para generar una página por post, en el nombre de la página le indicaremos que tire de un parámetro "[slug]", ese slug es el nombre del post, con lo que si tenemos uno con el nombre "mi-primer-post" la página generada será "mi-primer-post.html", y crearemos una por cada post disponible.

Vamos a por ello, vamos a crear la carpeta "posts" y dentro, otra llamada "[slug]", y creamos el fichero page.tsx. Con esto estamos declarando la ruta dinámica para cada post del estilo posts/mi-primer-post.

Como queremos que el HTML de todos los posts se genere en tiempo de compilación, vamos a utilizar la función generateStaticParams de Nextjs para generar los slugs de cada uno de los posts.

  • Añadimos los imports necesarios.
  • Exportamos la función generateStaticParams que llama a getPosts y genera un array de objetos con el slug de cada post.

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

import { getPosts } from "../../../api";

export async function generateStaticParams() {
  const posts = await getPosts();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

Ahora vamos a crear el componente para la página con un layout provisional para ver que funciona:

  • Empezamos añadiendo la interfaz de las Props con el parámetro slug que nos llega por params.
  • Vamos añadiendo el componente Post que recibe las props.
  • En las últimas versiones de Nextjs los params llegan de forma asíncrona, por lo que hay que añadir un await para poder acceder a ellos.
  • Y aquí, volvemos a pedir todos los posts para poder recuperar los datos de cada uno. Aunque parece que estamos llamando demasiadas veces al mismo endpoint, Nextjs lo optimiza y sólo hace una llamada a la API porque la va a ejecutar en tiempo de compilación. Si decides renderizar tu página como SSR u otro tipo de renderizado, podrías optar por utilizar el método getContent de un Post filtrando por el slug directamente:
export async function getPost(slug: string): Promise<Post> {
  return await client.getContent<Post>({
    contentType: "Post",
    "fields.slug": slug,
  });
}
  • En nuestro caso, para hacerlo más sencillo, optamos por la primera opción de recuperar todos los posts.
  • Por último, filtramos el post que queremos mostrar.
  • Eso si, vamos a añadir un ! al final del find para indicar que siempre vamos a encontrar un post, ya que si no, TypeScript nos daría un error de que puede ser undefined.

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

...

+ interface Props {
+   params: Promise<{ slug: string }>;
+ }

+ export default async function Post(props: Props) {
+   const { params } = props;
+   const { slug } = await params;
+   const posts = await getPosts();
+   const post = posts.find((post) => post.slug === slug)!;

+   return (
+     <article>
+       <h1>{post.title}</h1>
+       <p>
+         <strong>Date:</strong> {post.date}
+       </p>
+       <p>
+         <strong>Author:</strong> {post.author}
+       </p>
+       <img src={post.image.url} alt={post.title} width="800" />
+       <p>
+         <strong>Summary:</strong> {post.summary}
+       </p>
+       <div>
+         <strong>Content:</strong>
+         <p>{post.content}</p>
+       </div>
+     </article>
+   );
+ }

Y ya que estamos, vamos a ponerlo bonito con unos estilos:

./src/app/posts/[slug]/page.module.css

.background::before {
  content: "";
  position: absolute;
  z-index: -1;
  top: 0;
  left: 0;
  width: 100%;
  height: 360px;
  background-image: var(--postImage);
  background-size: cover;
  background-repeat: no-repeat;
  background-position: top center;
}

.article {
  max-width: 800px;
  margin-right: auto;
  margin-left: auto;
  margin-top: var(--space-2xl);
  padding: var(--space-lg);
  background-color: var(--pure-white);
  display: flex;
  flex-direction: column;
  gap: var(--space-lg);
}

.article__header {
  text-align: center;
}
.article__title {
  font-size: var(--fs-3xl);
  font-family: var(--ff-title);
}

.article__content {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);

  & h2 {
    margin-top: var(--space-sm);
  }
  & ul {
    padding-left: var(--space-md);
  }
  & img {
    width: 100%;
    object-fit: cover;
    aspect-ratio: 21/9;
    border-radius: var(--border-radius);
  }

  & code {
    font-size: var(--fs-xs);
    border-radius: var(--border-radius);
    padding: var(--space-xs);
  }
}

Y los utilizamos:

  • Añadimos el import.
  • Formateamos la fecha del post.

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

import { getPosts } from "../../../api";
+ import classes from "./page.module.css";

...

export default async function Post(props: Props) {
  const { params } = props;
  const { slug } = await params;
  const posts = await getPosts();
  const post = posts.find((post) => post.slug === slug)!;

+ const postDate = new Date(post.date).toLocaleDateString('en-US', {
+   year: 'numeric',
+   month: 'long',
+   day: 'numeric',
+ });

  return (
-   <article>
-     <h1>{post.title}</h1>
-     <p>
-       <strong>Date:</strong> {post.date}
-     </p>
-     <p>
-       <strong>Author:</strong> {post.author}
-     </p>
-     <img src={post.image.url} alt={post.title} width="800" />
-     <p>
-       <strong>Summary:</strong> {post.summary}
-     </p>
-     <div>
-       <strong>Content:</strong>
-       <p>{post.content}</p>
-     </div>
-   </article>
+   <div
+     className={`container ${classes.background}`}
+     style={
+       { "--postImage": `url(${post.image.url})` } as React.CSSProperties
+     }
+   >
+     <article className={classes.article}>
+       <header className={classes.article__header}>
+         <p>
+           <time dateTime={post.date}>{postDate}</time>
+         </p>
+         <h1 className={classes.article__title}>{post.title}</h1>
+       </header>
+       <div className={classes.article__content}>{post.content}</div>
+       <footer>
+         <p>
+           Written by <strong>{post.author}</strong>
+         </p>
+       </footer>
+     </article>
+   </div>
  );
}

Ahora le toca el turno de nuevo a la librería de marked para transformar el código markdown. Y ya que estamos, vamos a instalar otra librería que haga syntax highlighting para cuando tengamos código en el Markdown.

npm install highlight.js

Actualizamos el componente:

  • Importamos marked, highlight.js y una hoja de estilos para el highlighting.
  • Transformamos el contenido del post con marked y lo guardamos en una variable postContent.
  • Y lo utilizamos en el div.

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

+ import { marked } from 'marked';
+ import hljs from 'highlight.js';
+ import "highlight.js/styles/github-dark.css";
import { getPosts } from "../../../api";
import classes from "./page.module.css";

...

export default async function Post(props: Props) {
  const { params } = props;
  const { slug } = await params;
  const posts = await getPosts();
  const post = posts.find((post) => post.slug === slug)!;
+ const renderer = new marked.Renderer();
+ renderer.code = ({ text, lang }) => {
+   const validLang = hljs.getLanguage(lang || 'plaintext') ? lang || 'plaintext' : 'plaintext';
+   const highlighted = hljs.highlight(text, { language: validLang }).value;
+   return `<pre><code class="hljs ${validLang}">${highlighted}</code></pre>`;
+ };

+ marked.setOptions({ renderer });
+ const postContent = marked(post.content || '');
  const postDate = new Date(post.date).toLocaleDateString("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric",
  });

  return (
    <div
      className={`container ${classes.background}`}
      style={
        { "--postImage": `url(${post.image.url})` } as React.CSSProperties
      }
    >
      <article className={classes.article}>
        <header className={classes.article__header}>
          <p>
            <time dateTime={post.date}>{postDate}</time>
          </p>
          <h1 className={classes.article__title}>{post.title}</h1>
        </header>
-       <div className={classes.article__content}>{post.content}</div>
+       <div className={classes.article__content} dangerouslySetInnerHTML={{ __html: postContent }} />
        <footer>
          <p>
            Written by <strong>{post.author}</strong>
          </p>
        </footer>
      </article>
    </div>
  );
}

¡Y ya lo tenemos!