Skip to content

Get Started NextjsTutorial — 9. Displaying a post

00:00 / 00:00

9. Displaying a post

Right now, we have the list of posts, but if we click on one, we get a 404 error because we haven’t created the page yet. Let’s fix that.

To build the post detail page, we can reuse a lot of what we did in the list page. You might be wondering: “Why reuse so much if we're just displaying one post?” That’s because of how Next.js works: At build time, it reads all posts and generates a page for each one with its details, so the process is similar to the listing page.

To generate a page per post, we’ll use a dynamic route [slug], where the slug is derived from the post title. So if we have a post titled "my-first-post", it’ll generate a page like "my-first-post.html".

Let’s get to it. Create a folder called posts, and inside it, another called [slug], and add a page.tsx file. This declares a dynamic route like posts/my-first-post.

To generate static HTML for all posts, we’ll use Next.js’ generateStaticParams to pre-generate slugs.

  • Add the necessary imports.
  • Implement generateStaticParams that calls getPosts and returns an array of slugs.

./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,
  }));
}

Now let’s build the component for the page with a basic layout to confirm it works:

  • Add a Props interface with the slug param from params.
  • Add the Post component that receives props.
  • In the latest Next.js versions, params are asynchronous, so we use await.
  • Fetch all posts to extract their details (Next.js optimizes this, caching the API response). Although it may seem like we're calling the same endpoint too many times, Next.js optimizes this and only makes one API call because it executes it at build time. If you decide to render your page as SSR or another type of rendering, you could use the getContent method of a Post, filtering directly by:
export async function getPost(slug: string): Promise<Post> {
  return await client.getContent<Post>({
    contentType: "Post",
    "fields.slug": slug,
  });
}
  • In our case, to keep it simple, we’ll fetch all posts and filter the one we want to display.
  • Filter the specific post by its slug.
  • Add a ! at the end of the find to indicate that we will always find a post, as TypeScript would otherwise throw an error for possibly being 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>
+   );
+ }

Let’s style it up:

./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);
  }
}

And use those styles:

  • Import the CSS module.
  • Format the post date.

./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>
  );
}

Now it's time to use the marked library again to transform the markdown code. And while we're at it, let's install another library that provides syntax highlighting for code blocks in the Markdown.

npm install highlight.js

Update the component:

  • Import marked, highlight.js, and the highlight CSS.
  • Use marked to convert Markdown to HTML, store it in postContent.
  • Render the HTML with dangerouslySetInnerHTML.

./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>
  );
}

And we’re done!