Get Started NextjsTutorial — 9. Displaying a post
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
generateStaticParamsthat callsgetPostsand 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
Propsinterface with theslugparam fromparams. - Add the
Postcomponent that receivesprops. - In the latest Next.js versions,
paramsare asynchronous, so we useawait. - 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
getContentmethod of aPost, 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 thefindto indicate that we will always find a post, as TypeScript would otherwise throw an error for possibly beingundefined.
./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.jsUpdate the component:
- Import
marked,highlight.js, and the highlight CSS. - Use
markedto convert Markdown to HTML, store it inpostContent. - 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!