Build a Blog with Next.js #2: Post List and Detail Page
Last time we set up the data model, folder structure, and first MDX posts. This time we render real screens — the goal is to finish two pages: a post list at home and a post detail at /posts/[slug].
Post list page #
Let’s render the post list at home (/). Since it’s a Server Component, the fs module is usable as is.
src/app/page.js:
import Link from 'next/link';
import { getAllPosts } from './lib/posts';
export default function HomePage() {
const posts = getAllPosts();
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>Blog</h1>
{posts.length === 0 ? (
<p>No posts yet.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{posts.map(post => (
<li key={post.slug} style={{ marginBottom: '24px', borderBottom: '1px solid #eee', paddingBottom: '16px' }}>
<h2 style={{ margin: 0 }}>
<Link href={`/posts/${post.slug}`}>{post.frontmatter.title}</Link>
</h2>
<small style={{ color: '#888' }}>{post.frontmatter.date}</small>
<p>{post.frontmatter.description}</p>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{(post.frontmatter.tags ?? []).map(tag => (
<span key={tag} style={{ fontSize: '12px', padding: '2px 8px', background: '#f0f0f0', borderRadius: '12px' }}>
{tag}
</span>
))}
</div>
</li>
))}
</ul>
)}
</main>
);
}Save and visit http://localhost:3000 — the posts under posts/ show up as a list (drafts excluded). The first paint appears instantly regardless of network speed — the Server Component prepares the HTML at build/request time and sends it.
Where does console.log go? #
Let’s revisit the mental model the previous series emphasized. Drop a console.log near the top of the file.
import Link from 'next/link';
import { getAllPosts } from './lib/posts';
export default function HomePage() {
const posts = getAllPosts();
console.log('post count:', posts.length);
// ...
}It prints in your dev server terminal, not the browser console. Concrete proof that Server Components run on the server.
Post detail — dynamic route #
A URL like /posts/hello-world should work. In the App Router, you express a dynamic route by naming the folder [param].
src/app/posts/[slug]/page.js:
import { notFound } from 'next/navigation';
import { getPostBySlug, getAllSlugs } from '../../lib/posts';
import { compileMDX } from 'next-mdx-remote/rsc';
export async function generateStaticParams() {
return getAllSlugs().map(slug => ({ slug }));
}
export default async function PostPage({ params }) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post || post.frontmatter.draft) {
notFound();
}
const { content } = await compileMDX({
source: post.content,
});
return (
<article style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>{post.frontmatter.title}</h1>
<small style={{ color: '#888' }}>{post.frontmatter.date}</small>
<p style={{ color: '#555' }}>{post.frontmatter.description}</p>
<hr />
{content}
</article>
);
}Let’s unpack the key parts.
params is a Promise #
const { slug } = await params;Starting with Next.js 15, params is a Promise; you have to await it to get the value. Older tutorials may use params.slug directly, but the modern norm is await params.
compileMDX transforms the body #
const { content } = await compileMDX({
source: post.content,
});compileMDX from next-mdx-remote/rsc turns the Markdown body string into a React component. The resulting content is an actual JSX tree you can render directly.
This conversion happens on the server. Only the rendered HTML is sent to the browser, and the MDX compiler itself isn’t bundled to the client — another Server Component win.
notFound() — when there’s no post #
if (!post || post.frontmatter.draft) {
notFound();
}notFound() is a Next.js helper that immediately stops rendering and shows a 404 page. Even if someone hits a draft URL directly, it 404s — drafts aren’t accidentally exposed.
To customize the 404 page, add src/app/not-found.js (we use the Next.js default for now).
generateStaticParams — static generation at build time #
export async function generateStaticParams() {
return getAllSlugs().map(slug => ({ slug }));
}With this function, Next.js pre-generates a page for every slug at build time. Every post becomes static HTML deployed to a CDN. At runtime, fs isn’t read again, MDX isn’t compiled again, so it’s very fast.
Without this function, the page renders dynamically — fs is read and MDX compiled on every request, which is slightly slower but reflects new posts immediately.
For a blog with infrequent post changes, static generation is almost always the better choice. To add a post, just rebuild (Vercel does it automatically on git push).
generateStaticParams is present and the page doesn’t use dynamic functions (reading cookies, headers, searchParams, etc.), it’s static; otherwise dynamic. For now, just know “this code shape is static.”Verify it works #
Save and check the following.
- http://localhost:3000 — post list ordered by date descending
- Click a post title → goes to the detail page
- Markdown body renders as HTML correctly
- http://localhost:3000/posts/draft-not-shown — 404 page
- http://localhost:3000/posts/nonexistent-post — 404 page
- View page source (right click → View Page Source) — the body is already in HTML (which would be just an empty div with CSR)
Item 6 is striking. SEO-friendly, and search engines can index the content directly.
Markdown extensions — remark-gfm #
Plain Markdown doesn’t support GitHub-style tables, checkboxes, or auto-linking URLs. Let’s add remark-gfm.
npm install remark-gfmPass an option to compileMDX.
import remarkGfm from 'remark-gfm';
// ...
const { content } = await compileMDX({
source: post.content,
options: {
mdxOptions: {
remarkPlugins: [remarkGfm],
},
},
});Now tables like the following render correctly inside posts.
| Name | Role |
|---|---|
| Server Component | Runs on the server |
| Client Component | Runs on the browser too |Checkboxes work too:
- [x] Step 1 done
- [ ] Step 2 (in progress)
- [ ] Step 3Code block syntax highlighting — rehype-pretty-code #
For a tech blog, code block highlighting is practically required.
npm install rehype-pretty-code shikiimport remarkGfm from 'remark-gfm';
import rehypePrettyCode from 'rehype-pretty-code';
// ...
const { content } = await compileMDX({
source: post.content,
options: {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[rehypePrettyCode, { theme: 'github-light' }],
],
},
},
});Code blocks now get color. Specify a language (```js) and it highlights according to that language’s grammar.
To polish further:
- Add filenames/captions above code blocks (
```js title="example.js") - Per-language icons (how this site does it; needs extra CSS)
This series uses just the defaults. Polish is up to you.
Layout — add a header #
Right now every page is just the body without a shared area. Add a simple header in layout.js.
src/app/layout.js:
import Link from 'next/link';
import './globals.css';
export const metadata = {
title: 'My Blog',
description: 'A blog built with Next.js',
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<header style={{ padding: '12px 24px', background: '#222', color: '#fff', display: 'flex', gap: '16px' }}>
<Link href="/" style={{ color: '#fff', textDecoration: 'none', fontWeight: 'bold' }}>Home</Link>
<Link href="/tags" style={{ color: '#fff', textDecoration: 'none' }}>Tags</Link>
<Link href="/search" style={{ color: '#fff', textDecoration: 'none' }}>Search</Link>
</header>
{children}
</body>
</html>
);
}/tags and /search are pages we’ll build in #3. The links are placed up front; clicking them now will 404. We’ll fill them in next.
A small refactor — PostCard component #
The post-card portion of the home page is getting long. Let’s extract it.
src/app/PostCard.jsx:
import Link from 'next/link';
export default function PostCard({ post }) {
return (
<li style={{ marginBottom: '24px', borderBottom: '1px solid #eee', paddingBottom: '16px' }}>
<h2 style={{ margin: 0 }}>
<Link href={`/posts/${post.slug}`}>{post.frontmatter.title}</Link>
</h2>
<small style={{ color: '#888' }}>{post.frontmatter.date}</small>
<p>{post.frontmatter.description}</p>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{(post.frontmatter.tags ?? []).map(tag => (
<Link
key={tag}
href={`/tags/${tag}`}
style={{ fontSize: '12px', padding: '2px 8px', background: '#f0f0f0', borderRadius: '12px', textDecoration: 'none', color: '#333' }}
>
#{tag}
</Link>
))}
</div>
</li>
);
}The home page gets shorter.
import { getAllPosts } from './lib/posts';
import PostCard from './PostCard';
export default function HomePage() {
const posts = getAllPosts();
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>Blog</h1>
{posts.length === 0 ? (
<p>No posts yet.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{posts.map(post => (
<PostCard key={post.slug} post={post} />
))}
</ul>
)}
</main>
);
}Tag clicks already link to /tags/[tag] (the actual page comes in #3).
Verify everything #
To wrap up #2, confirm the following:
- The home page lists the posts correctly
- Each card shows title/date/description/tags
- Markdown body renders nicely on the detail page (including code highlighting)
- Tables (remark-gfm) work
- Drafts don’t appear in the list and 404 on direct access
- The “Home” link in the header always works (the other two come in #3)
Wrap-up #
This post finished the two key blog pages.
- Home — a Server Component reads posts from fs and renders the list
- Post detail — dynamic route +
compileMDXto render the body generateStaticParamsfor build-time static generationnotFound()for missing posts/drafts- Plugins:
remark-gfm,rehype-pretty-code - A small refactor to extract
PostCard
In the next post, “Build a Blog with Next.js #3: Tags and Search,” we’ll fill in the /tags and /search links we placed in the header — adding per-tag post lists and a URL-query-based search feature.