Cake

Building a Next.js blog in 2024_

Let's start the new year by rewriting our sites from scratch again 😎

more code stories

Great 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.


bash
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


bash
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.


bash
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.


javascript
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:


typescript
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...


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...


text
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...


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.


random thought...

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:


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.


mdxjs
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.


typescript
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}
Shoutout to

Lee Rob

for the basis of this

Writing 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.


typescript
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).


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:


text
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.

A graph showing how layout and inheritance templates work

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.


jsx
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...


jsx
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...


jsx
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.


jsx
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 🕶️

  1. 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.

  2. 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

    The Vercel dashboard with the "Add New..." button expanded

  3. 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.

  4. 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.

one sec...