Building a blog with Next.js 14 and React Server Components
I've been tinkering with this website and the Next.js 13 App Router for a while now,
and it's been a great experience, especially in the last few months. But I've seen some confusion around
how to use the new features and React Server Components, so I wrote up an outline on building this website.
To address the React-sized elephant in the room:
you do not need this fancy setup to build a blog.
HTML and CSS may be a better choice for you, but I'd find little fun in that, and I'm a strong believer in having a website to experiment with.
To set a few expectations, here's what this post won't do:
Act as documentation for Next.js or React
Be a 100% code-complete tutorial. You'll need to fill in some gaps yourself.
First, we'll need to create a new Next.js project. You can launch the setup wizard with npx create-next-app:
npx create-next-app --experimental-app
npx create-next-app --experimental-app
Hit y (or n, I'm not your boss) a few times to complete the wizard.
File structure
The easiest way I've found to thinking about structuring App Router applications is from a top-down approach.
Think of your general path structure, then start with your highest level 'scope' (layout) and work your way down.
In our case, we'll have a home page. Let's also add a projects page and an about page.
We'll cover the blog in the next section.
In general, a page will look like this:
// page.tsx
export default function Page() {
return <>Your content here</>
}
// page.tsx
export default function Page() {
return <>Your content here</>
}
In my case, the three pages we're making all look the same minus their content, so they seem like a strong candidate for sharing a layout.
A Layout will look something like this:
// layout.tsx
export default function Layout({ children }: PropsWithChildren) {
return (
<>
// Your layout content here
{children}
// Or here
</>
)
}
// layout.tsx
export default function Layout({ children }: PropsWithChildren) {
return (
<>
// Your layout content here
{children}
// Or here
</>
)
}
Knowing all of this, I chose to create four files: app/layout.tsx, app/page.tsx, app/projects/page.tsx, and app/about/page.tsx.
The layout will apply to all pages, and each page file will contain it's own content.
I ran into one small issue with this approach: the home page doesn't need a icon, but the other pages do.
It doesn't make much sense to include that in the home page, but all the other pages should have it,
so we'll keep it out of the root layout and create a Route Group with it's own nested layout layout to only apply to the other pages.
First, lets create our app/(subpages)/components directory and create a quick header only for the subpages:
// app/(subpages)components/header.tsx
import Link from 'next/link'
import { HomeIcon } from 'react-feather'
export default function Header() {
return (
<header>
<Link href="/">
<HomeIcon />
</Link>
// If you want to add a Client Component here like a theme switcher, mark that
// component with "use client" and leave the majority of the header as an RSC
</header>
)
}
// app/(subpages)components/header.tsx
import Link from 'next/link'
import { HomeIcon } from 'react-feather'
export default function Header() {
return (
<header>
<Link href="/">
<HomeIcon />
</Link>
// If you want to add a Client Component here like a theme switcher, mark that
// component with "use client" and leave the majority of the header as an RSC
</header>
)
}
And use it in our app/(subpages)/layout.tsx:
// app/(subpages)/layout.tsx
import Header from './components/header'
export default function SubLayout({ children }) {
return (
<>
<Header />
{children}
</>
)
}
// app/(subpages)/layout.tsx
import Header from './components/header'
export default function SubLayout({ children }) {
return (
<>
<Header />
{children}
</>
)
}
With the header in place, you now have a nested layout for subpages and blog posts.
Let's go ahead and create our files and directories:
For our blog posts like this one, we'll want /blog/[slug] to be a page that displays a single blog post, and we want it to have it's own footer to link to other posts.
It sure seems like the footer should live inside the /blog/[slug] layout. The [slug] in the URL is referred to as a Dynamic Segment.
But how do we render markdown files in our blog posts?
Fetching and rendering markdown
The official Next.js documentation has a great guide for using MDX with all your pages.
Sometimes you want to render content from a remote source though, like a CMS.
In my case, for niche specific reasons, I want to keep my markdown separate from the Next.js project, so I'll be using next-mdx-remote and it's experimental React Server Components support.
A lot of the code is the same if you want to apply it to your own project, so just follow along with the code snippets.
Fetching your posts
You need to fetch your posts from somewhere before you can render them.
There's a lot of ways to do this. Here's a simplified but functional version of mine loading them from the file system:
import matter from 'gray-matter'
import path from 'path'
import type { Post } from './types'
import fs from 'fs/promises'
import { cache } from 'react'
// `cache` is a React 18 feature that allows you to cache a function for the lifetime of a request.
// this means getPosts() will only be called once per page build, even though we may call it multiple times
You may notice the components={mdxComponents} prop. This is where we pass in our custom components that we want to use in our markdown files.
For using with Next.js, we probably want to use the official next/link and next/image components to opt into client side routing and image optimization.
This is also where I've defined the components like the file trees in this post.
// You need to do some work here to get the width and height of the image.
// See the details below for my solution.
return <Image {...props} />
},
// any other components you want to use in your markdown
}
How I get the width and height of an image
There's probably a better way to accomplish this (that makes use of the sizes prop of next/image),
but I add the intended image width and height to the image URL as query parameters.
This allows me to get the width and height from the URL and pass it to next/image.
Bright is a new RSC-first syntax highlighter by code-hike.
It performs the highlighting on the server, so only the necessary styles and markup are sent to the client.
It also has first-class support for extensions like line numbers, highlighting, or whatever you decide to build.
Install the bright package and use it in your MDX components like so:
import { Code } from 'bright'
export const mdxComponents: MDXComponents = {
// the `a` and `img` tags from before should remain
pre: Code,
}
import { Code } from 'bright'
export const mdxComponents: MDXComponents = {
// the `a` and `img` tags from before should remain
pre: Code,
}
And that's all you need for great syntax highlighting.
Now that we have MDX setup and equipped with our components, we can render a post.
First, let's import the getPost function and the PostBody component we created earlier.
// app/(subpages)/blog/[slug]/page.tsx
import getPosts from '@lib/get-posts'
import { PostBody } from '@mdx/post-body'
// app/(subpages)/blog/[slug]/page.tsx
import getPosts from '@lib/get-posts'
import { PostBody } from '@mdx/post-body'
Now we just... render the component.
import getPosts, { getPost } from '@lib/get-posts'
import { PostBody } from '@mdx/post-body'
import { notFound } from 'next/navigation'
export default async function PostPage({
params,
}: {
params: {
slug: string
}
}) {
const post = await getPost(params.slug)
// notFound is a Next.js utility
if (!post) return notFound()
// Pass the post contents to MDX
return <PostBody>{post?.body}</PostBody>
}
import getPosts, { getPost } from '@lib/get-posts'
import { PostBody } from '@mdx/post-body'
import { notFound } from 'next/navigation'
export default async function PostPage({
params,
}: {
params: {
slug: string
}
}) {
const post = await getPost(params.slug)
// notFound is a Next.js utility
if (!post) return notFound()
// Pass the post contents to MDX
return <PostBody>{post?.body}</PostBody>
}
We can now render a post; that's pretty cool.
We can optionally choose to build all of our posts at build time,
by adding generateStaticParams to the page:
export async function generateStaticParams() {
const posts = await getPosts()
// The params to pre-render the page with.
// Without this, the page will be rendered at runtime
return posts.map((post) => ({ slug: post.slug }))
}
export async function generateStaticParams() {
const posts = await getPosts()
// The params to pre-render the page with.
// Without this, the page will be rendered at runtime
return posts.map((post) => ({ slug: post.slug }))
}
SEO
The new Metadata API is fantastic, but it's also a major work in progress. Be sure to check the docs for the latest updates.
Metadata API
The new Metadata API has great documentation, so I won't go into too much detail here.
I define the majority of my layout in the root layout and override it as necessary in the leaf pages.
Here's what my root layout's metadata looks like:
// app/layout.tsx
export const metadata = {
title: {
template: '%s | Max Leiter',
default: 'Max Leiter',
},
description: 'Full-stack developer.',
openGraph: {
title: 'Max Leiter',
description: 'Full-stack developer.',
url: 'https://maxleiter.com',
siteName: "Max Leiter's site",
locale: 'en_US',
type: 'website',
// To use your own endpoint, refer to https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation
// Note that an official `app/` solution is coming soon.
lastModified: new Date().toISOString().split('T')[0],
}))
return [...routes, ...blogs]
}
Generating an RSS Feed
While waiting for of an official solution for RSS feeds, I've created a custom solution that works well for me.
I use the marked library to parse the markdown files and then use the rss library to generate the RSS feed.
This means the JSX components for MDX are passed through to the RSS feed,
so I just try and ensure the components are legible even when not renderd.
I use Vercel for deployments (I may be bias), but you can use any static site provider you want with a setup
like this because it supports static export.
Wrapping up
I hope this post was helpful to you.
As a reminder, you can find the source code for this site here.
Please try to not think negatively of me for anything you find (but feel free to offer praise for anything you like).