Tutorial: Primeros pasos con Nextjs — 9. Montando un post
- 1. Creando el proyecto en Content Island
- 2. Definiendo el modelo de datos
- 3. Añadiendo datos
- 4. Creando el proyecto en Nextjs
- 5. Creando la estructura de carpetas
- 6. Conectando Content Island con Nextjs
- 7. Montando la página de About
- 8. Página con lista de posts
- 9. Montando un post
- 10. Desplegando en Vercel
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
generateStaticParamsque llama agetPostsy 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
Propscon el parámetroslugque nos llega porparams. - Vamos añadiendo el componente
Postque recibe lasprops. - En las últimas versiones de Nextjs los
paramsllegan de forma asíncrona, por lo que hay que añadir unawaitpara 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
getContentde unPostfiltrando por elslugdirectamente:
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 delfindpara indicar que siempre vamos a encontrar un post, ya que si no, TypeScript nos daría un error de que puede serundefined.
./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.jsActualizamos el componente:
- Importamos
marked,highlight.jsy una hoja de estilos para el highlighting. - Transformamos el contenido del post con
markedy lo guardamos en una variablepostContent. - 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!