Building a Next.js blog in 2024_
Let's start the new year by rewriting our sites from scratch again 😎
more code storiesGreat news, it's finally 2024, which means we've got an excuse to throw away last year's site and code it again from scratch.
In this post I'm going to show you how to build a basic Next.js blog with MDX, SEO, and a custom layout, with instructions at the end to deploy it to Vercel.
If you're not interested in the how, and you'd like to get something online now, I've open sourced a more complete version 👉here👈 with many additional features than we run through in this guide.
Why go with NextJS
Real quick let me shill for Next.js a minute.
At the moment, Next is my go-to tool for a pre-rendered React site. Though I tried many other popular options I wanted to go with something I was more familiar with, so I could put something together properly for my own personal site.
Speed, SEO, and the opportunity to try more of RSC's were primary drivers. Though the first two can definitely be achieved with other tooling (mimetype.io is still in Gatsby and doing great with organic search), RSC's are still pretty nichely implemented in other frameworks being so new themselves.
Next does a great job at the above with just enough magic built in that you should achieve those coveted green lighthouse metrics even if you have sloppy content just by using the provided tooling.
Also, it's popular. You know what they say about finding solutions to your problems online more readily just by picking tooling other people use. I mean, this tutorial exists, right?
Setting up your project
Let's start a blank project using the create-next-app package.
1# NPM: Create a new project
2npx create-next-app my-blog
3
4# Or, if you use Bun: Create a new project
5bunx create-next-app my-blog
Follow the prompts to create your new Next project, the default options it gives you are perfect, no need to adjust.
Once finished switch to your project and start the dev server
1# NPM: Start next dev
2cd my-blog && npm run dev
3
4# Or, if you use Bun: Start next dev
5cd my-blog && bun run dev
You should now be able to see a default next site running in your browser when visiting localhost:3000, give it a shot!
Adding articles with MDX
Now we have our project let's get our blog going.
My recommendation is to not go with an external content management system (Contentful, etc.) for your personal blog, it feels like massively overcomplicating a problem (unless you're doing it to get the experience).
You're likely already familiar with Markdown from Github, README's, and Wikipedia, it's a quick and easy way to format your content. MDX then adds the ability to insert React components, like the code blocks, table of contents, etc. on this post.
To get Markdown support into our new next site we'll use the @next/mdx
package
(learn more here),
it will automatically find our MDX files stored in the /app
and /pages
directories and turn them into routes for us.
1# NPM
2npm install --save @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
3
4# Or, Bun
5bun install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
Now let's head to your next.config.mjs
file and tell it to look for .mdx
files.
src/next.config.mjs
1import createMDX from '@next/mdx'
2
3/** @type {import('next').NextConfig} */
4const nextConfig = {
5 pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
6}
7
8const withMDX = createMDX()
9
10export default withMDX(nextConfig)
Now we need to tell MDX how to render components, create a new file at
src/mdx-components.tsx
and give it the basics:
src/mdx-components.tsx
1import type { MDXComponents } from 'mdx/types'
2
3export function useMDXComponents(components: MDXComponents): MDXComponents {
4 return {
5 ...components,
6 }
7}
Finally, let's create your first blog post. Add a new file to
src/app/blog/my-first-post/page.mdx
and throw in the following...
src/app/blog/my-first-post/page.mdx
1export const AComponent = () => {
2 return <strong>JSX!</strong>
3}
4
5# My first post
6
7Hello world, this is my first post.
8
9And, it works with <AComponent />
Great, you've now got the basics of an MDX Next blog down!
Visit localhost:3000/blog/my-first-post
to check it out. :)
Note: If you wanna kill that funky background that's there
by default, head into src/app/globals.css
and remove the background color
from the body selector.
You've probably already figured out we still need to name our blog files
page.mdx
for
Next's App router to find them.
So you'll end up with a structure like this...
1- /app
2 - /blog
3 - /my-first-post
4 - page.mdx
5 - /my-second-post
6 - page.mdx
7 - ...
This is pretty great because it'll let you also collect your resources (images, video, etc.) for each post into a single directory by co-locating your related files and components.
Around about this time you may ask "Hey, what about SEO?", "How can I make it look nice?", and "How do we make a list of our articles?".
Great questions, let's start out with SEO, there's no point writing a blog if it doesn't show up on Bing (lol).
Adding metadata and SEO
By default, you can now export a constant called metadata
from your page.mdx
just like any other
app router route.
Check this out...
src/app/blog/my-first-post/page.mdx
1# My second post
2
3First the worst, second the best...
4
5export const metadata = {
6 title: "My second post",
7 description: "This is my second post",
8 datePublished: "2021-01-01",
9 authors: [{ name: "Me!", url: "https://mysite.org" }],
10 keywords: ["cheesecake", "recipe", "baking", "cooking"],
11 openGraph: {
12 type: "article",
13 publishedTime: "2021-01-01",
14 },
15
16}
Cool, huh? But we're going to need to add a little more. The problem is we need to extract these titles, URLs, and dates for listing our blogs on the homepage, so we need to define this information elsewhere.
One approach would be to abstract our content away into a separate /content
directory, set up a catch-all route under /blog/[...slug]
, and then find the
appropriate blog post when it's accessed.
We could also setup a custom function to scan our src/app/blog
directory
in a server component and parse the metadata out using Frontmatter. Idk
about that tho. Feels kinda, hacky? And Vercel won't let you access those
page.mdx
files with the fs module when deployed anyway.
The approach we'll take though is to add a new metadata.json
in the same
directory as the post with some information about it. This will let us extend
and customise it more per post if we ever want to.
Note: Lee Rob did take an approach similar to the first if you'd like to go that route however!
Add a new metadata.json
file next to your first blog post, we'll paste this in
for now:
src/app/blog/my-first-post/metadata.json
1{
2 "id": "my-first-post",
3 "datePublished": "2024-12-21",
4 "nextMetadata": {
5 "title": "My first blog post",
6 "description": "Into the unknown",
7 "authors": [{"name": "Me!", "url": "https://mysite.org"}],
8 "keywords": ["cheesecake", "recipe", "baking", "cooking"],
9 "openGraph": {
10 "type": "article",
11 "publishedTime": "2024-12-21",
12 }
13 }
14}
The structure of nextMetadata
will have to match the Metadata
type from Next
(see more
here
and in the source code
here),
but we can also add some additional values in the JSON for later if we need
them.
Now back in page.mdx
we can just return this by importing the JSON and then
exporting the nextMetadata object.
src/app/blog/my-first-post/page.mdx
1import metadataJson from './metadata.json'
2
3export const AComponent = () => {
4 return <strong>JSX!</strong>
5}
6
7# My first post
8
9Hello world, this is my first post.
10
11And, it works with <AComponent />
12
13export const metadata = metadataJson.nextMetadata;
Tada! We're now pulling our OG tags and SEO <head>
sugar from our JSON file
that we're going to need in...
Listing our articles
We've got our /blog
directory, and our page.mdx
and metadata.json
files
are sitting within them safe and sound. Now we just need something to show on
our site so users can find them.
First let's add a file to src/lib/blog-utils.ts
and get coding. In it, we'll
define a few functions to scan our src/app/blog
directory for our
metadata.json
files.
src/lib/blog-utils.ts
1import fs from 'fs'
2import path from 'path'
3import { ReactNode } from 'react'
4import { Metadata } from 'next'
5
6// Define our types
7
8// Blog metadata.json structure
9type BlogMetadata = {
10 id: string
11 datePublished: string
12 nextMetadata: Metadata
13}
14
15// How we'll pass around an "MdxBlogPage" object
16export type MdxBlogPage = {
17 id: string
18 title: string
19 slug: string
20 metadata: BlogMetadata
21 content: ReactNode
22 isPublished: boolean
23}
24
25// Used for reading the metadata.json file
26function parseFile(blogBaseDir: string, fileContent: string) {
27 // Read the metadata from metadata.json
28 let metadata = JSON.parse(
29 fs.readFileSync(path.join(blogBaseDir, 'metadata.json'), 'utf-8'),
30 )
31
32 return { metadata: metadata as BlogMetadata, content: fileContent }
33}
34
35// A recursive function for exploring a directory
36const scanDirectoriesForBlogs = (dir: string): string[] => {
37 let files: string[] = []
38
39 fs.readdirSync(dir, { withFileTypes: true }).forEach((dirent) => {
40 if (dirent.isDirectory()) {
41 files = [
42 ...files,
43 ...scanDirectoriesForBlogs(path.join(dir, dirent.name)),
44 ]
45 } else if (dirent.name.includes('metadata.json')) {
46 files.push(path.join(dir, dirent.name))
47 }
48 })
49
50 return files
51}
52
53// Loads an MDX file
54const readBlogDirectory = (fileName: string) => {
55 const file = fs.readFileSync(fileName, 'utf-8')
56
57 const s = fileName.split('/')
58 s.pop()
59
60 return parseFile(s.join('/'), file)
61}
62
63// Gets blog data from a list of files discovered from a base directory
64function getBlogData(dir: string): MdxBlogPage[] {
65 let mdxFiles = scanDirectoriesForBlogs(dir)
66
67 return mdxFiles.map((file) => {
68 let { metadata, content } = readBlogDirectory(path.join(file))
69 let slug = file.split('/')[file.split('/').length - 2]
70
71 return {
72 id: metadata.id,
73 title: metadata.nextMetadata.title as string,
74 metadata,
75 slug,
76 content,
77 isPublished: Boolean(
78 metadata.datePublished &&
79 new Date(metadata.datePublished) <= new Date(),
80 ),
81 }
82 })
83}
Lee Rob
for the basis of thisWriting node code inside a React project like this is pretty crazy, huh? It's honestly part of the beauty of RSC's, giving you less of a separation of the code that's driving your data retrieval and the code that's actually displaying it will result in a fantastic developer experience and a more optimised end product for your users.
(Yes, PHP bros, we know. Still not using PHP tho.)
The above code will scan through our src/app/blog
directory, reading the
metadata.json
files and giving us an object with the title, metadata, slug,
content, and a handy isPublished boolean, so we can write and hide drafts that
aren't ready for the prime time yet.
Let's keep going by adding a few little utility functions for ourselves to parse these and give us some options.
src/lib/blog-utils.ts
1// Define options for our get posts function
2type GetPostsOptions = {
3 includeUnpublished?: boolean
4 sortBy?: 'datePublishedDesc' | 'datePublishedAsc'
5 limit?: number
6}
7
8export function getBlogPosts(options?: GetPostsOptions): Array<MdxBlogPage> {
9 let posts = getBlogData(path.join(process.cwd(), 'src/app/blog'))
10
11 // Filter out unpublished first
12 if (!options?.includeUnpublished) {
13 posts = posts.filter(
14 (post) =>
15 post.metadata.datePublished &&
16 new Date(post.metadata.datePublished) < new Date()
17 )
18 }
19
20 // Sort by datePublished
21 if (options?.sortBy === 'datePublishedDesc') {
22 posts = posts
23 .filter((b) => Boolean(b.metadata.datePublished))
24 .sort(
25 (a, b) =>
26 new Date(b.metadata.datePublished).getTime() -
27 new Date(a.metadata.datePublished).getTime()
28 )
29 } else if (options?.sortBy === 'datePublishedAsc') {
30 posts = posts
31 .filter((b) => Boolean(b.metadata.datePublished))
32 .sort(
33 (a, b) =>
34 new Date(a.metadata.datePublished).getTime() -
35 new Date(b.metadata.datePublished).getTime()
36 )
37 }
38
39 // Limit
40 posts.length = options?.limit || posts.length
41
42 return posts
43}
44
45export function getBlogPostBySlug(slug: string): MdxBlogPage | undefined {
46 let posts = getBlogPosts({ includeUnpublished: true })
47 return posts.find((post) => post.slug === slug)
48}
49
50export function getBlogPostById(id: string): MdxBlogPage | undefined {
51 let posts = getBlogPosts({ includeUnpublished: true })
52 return posts.find((post) => post.id === id)
53}
Coooool. Now let's open up our src/app/page.tsx
and do a few little
modifications (replace it).
src/app/page.tsx
1import { getBlogPosts } from '@/lib/blog-utils'
2import Link from 'next/link'
3
4export default function Home() {
5 const blogs = getBlogPosts({
6 includeUnpublished: false,
7 sortBy: 'datePublishedDesc',
8 limit: 10,
9 })
10
11 return (
12 <div>
13 <h1 className="text-lg font-bold">My Blog</h1>
14 <ol>
15 {blogs.map((blog) => (
16 <li key={blog.id} className="underline">
17 <Link href={`/blog/${blog.slug}`}>{blog.title}</Link>
18 </li>
19 ))}
20 </ol>
21 </div>
22 )
23}
Head back to your locahost:3000
and you should now see a list of your blog
posts! 🎉
We've limited it to only the first ten, I'll leave adding a link to a new
/blog
page with the full list as an exercise for the reader, or if you'd
prefer it's in the GitHub project.
I've already pre-populated your metadata.json
with the current date, try
heading back in and setting it to tomorrow and watch it magically disappear from
your homepage. :)
Now that you've got the basics working it's time we add the finishing touches.
Adding layouts and styles
This part's easy-peasy thanks to the app router and our friends Layouts and Templates.
These allow us to nest layouts based on our routes, so we can add compounding layout elements on our pages the deeper we get into our website.
I'll go into this a lot further and the difference between the two in a future post, if you're new to this all however the basics looks like this, imagine you have a folder structure that appears as:
1- /app
2 - /blog
3 - /my-first-post
4 - page.mdx
5 - /my-second-post
6 - page.mdx
7 - page.tsx
8 - template.tsx
9 - page.tsx
10 - layout.tsx
Your root page.tsx
will render itself into your root layout.tsx
, and your
/blog/page.tsx
will render itself into your /blog/template.tsx
which will
then render itself into your root layout.tsx
.
When viewing your /blog/my-first-post
page Next will render the following.
This is very nice for larger websites with things like documentation, etc. But it also means we'll have easy time setting up our styling for our blog.
For now, we'll just set up a simple custom layout, but the 🙌 Github repo 🌈 linked above shows a more complex approach to blog layouts which passes in the metadata and content to the layout component itself.
Let's quickly create a new file at src/app/blog/template.tsx
and throw in the
following.
src/app/blog/template.tsx
1'use client'
2
3import Link from 'next/link'
4import { PropsWithChildren } from 'react'
5
6export default function BlogTemplate({
7 children,
8 ...props
9}: PropsWithChildren) {
10 return (
11 <div
12 className={
13 'flex flex-col gap-6 text-neutral-800 dark:text-neutral-50'
14 }
15 >
16 <article className={'max-w-prose text-lg leading-loose'}>
17 {children}
18 </article>
19 <div className="text-sm ">
20 <Link
21 href={`http://twitter.com/share?text=Check out this article!&url=${window.location.href}`}
22 className="underline"
23 >
24 Share on Twitter
25 </Link>
26 </div>
27 </div>
28 )
29}
This will apply a simple frame only to our blog posts with a share button on the bottom.
But to give our whole website a frame with a header and footer, we'll need to
create src/app/template.mdx
and replace the file with...
src/app/template.mdx
1import Link from 'next/link'
2import { PropsWithChildren } from 'react'
3
4export default function GlobalTemplate({ children }: PropsWithChildren) {
5 return (
6 <div className="flex min-h-screen w-full flex-col gap-6 px-6 py-4 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-50">
7 <header className="flex items-center gap-4 text-lg">
8 <Link href={'/'}>Home</Link>
9 <Link href={'/blog'}>Blog</Link>
10 </header>
11 <main>{children}</main>
12 <footer className="flex justify-between border-t border-neutral-200 py-4 text-sm text-neutral-500">
13 <p>Thanks for visiting 👍</p>
14 <ul className="flex list-none gap-2">
15 <li>Find me on</li>
16 <li>
17 <Link
18 href={'https://x.com/patsnacks'}
19 className="underline"
20 >
21 X
22 </Link>
23 </li>
24 <li>
25 <Link
26 href={'https://github.com/patrickmccallum'}
27 className="underline"
28 >
29 Github
30 </Link>
31 </li>
32 </ul>
33 </footer>
34 </div>
35 )
36}
Tip: The src/app/template.mdx
and src/app/layout.tsx
files are very different. The layout.tsx
file should only contain the
minimal HTML tag you see in it now with some minor edits.
Then to finish giving our new blog some
gorgeous looks ✨
we'll also adjust how our MDX is rendered, by creating a
src/components/typography.tsx
file...
src/components/typography.tsx
1import { PropsWithChildren } from 'react'
2
3export const TypographyH1 = ({ children }: PropsWithChildren) => {
4 return <h1 className="mt-4 text-3xl font-bold">{children}</h1>
5}
6
7export const TypographyH2 = ({ children }: PropsWithChildren) => {
8 return <h2 className="mt-4 text-lg font-bold">{children}</h2>
9}
10
11export const TypographyP = ({ children }: PropsWithChildren) => {
12 return <p className="mt-4 text-base">{children}</p>
13}
...and then implementing them in our src/mdx-components.tsx
file.
src/mdx-components.tsx
1import type { MDXComponents } from 'mdx/types'
2import {
3 TypographyH1,
4 TypographyH2,
5 TypographyP,
6} from './components/typography'
7
8export function useMDXComponents(components: MDXComponents): MDXComponents {
9 return {
10 ...components,
11 h1: TypographyH1,
12 h2: TypographyH2,
13 p: TypographyP,
14 }
15}
Tip: mdx-components.tsx
is how you can tell Next MDX to
use your custom components for rendering your articles elements such as p,
h1, h2, etc.
Check out your new site! It's lookin' prettttty good. 👀 👀 👀 👌
Hack it up, mash it up, add some banners, text, colours, etc. Then when you're ready, let's talk about getting it online for everyone.
Deploying our site
We've got our blog, we've got our layout, and we've got our metadata. Now we just need to deploy it. I'm going to assume you're deploying to Vercel (especially if this is your first time with Next), but if you want to host elsewhere Next also runs on Firebase, Cloudflare, etc.
If you'd like to look into self-hosting your Next site I recommend going for their pre-made docker image for anything more complex than a static site, but the Next.js docs go into a lot more detail on this.
Just be aware that they (Next.js) have a vested interest in you using Vercel, but if you don't expect your site to consume a lot of resources or receive bananas amounts of traffic you won't hit any free tier limits, and their developer experience is also pretty great ngl fr fr ong no cap 🕶️
Create a new project
Start out by putting your project into a new Git repo (GitHub, GitLab, and Bitbucket all supported), then head over to Vercel and sign in with your repo provider.
Add a new project
Click the Add New... button in the top right and select Project.
The Vercel dashboard with the "Add New..." button expanded
Deploy your project
Click the Import Git Repository button and select your repository from the list.
Once selected, click the Deploy button and Vercel will take care of the rest.
Share your site
Once deployed, Vercel will give you a URL to visit your site at. You can also add a custom domain if you'd like.
Now go share your blog with your family, friends, and tech Twitter. And don't forget to send it to me @patsnacks so I can have a read too.
You may want to look into adding a custom domain, so you can have a nicer url than whatever.vercel.app.
If you don't have a domain name yet, you can get one directly from Vercel or any external provider.
I use Porkbun for my domain names (not sponsored), they're cheap, and give a lot of control over managing them, the UI could be better frankly, but nothing can ever live up to what Google Domains used to be in my mind. 🫠
As for your new site... start hacking away and building it out! There's still so much to do, edit your header, footer, add a contact form, a newsletter, a comments section, a search bar, a dark mode, a light mode, tags...
If you wanna see some of this functionality already implemented there's a lot more ready to go in the 🥳 GitHub repo 😍 you can reference if you get stuck, but try implementing these for yourself first if you wanna learn.
Okay, well thanks for reading.