Build a static blog with Next.js 14, App Router, and MDX

July 11, 2024

Tested with

  • Ubuntu Linux 24.04 LTS

  • Node.js v20.15.0 LTS

  • Next.js v14.2.4

The complete code of this post is available on GitHub.

What we are going to develop

We want to develop a Next.js 14 solution for a static blog with these features:

  • support to write posts using Markdown and MDX;

  • basic SEO;

Setup the development environment

Install Node.js and Next.js. At the Next.js prompt we reply with these choices:

What is your project named? static-blog-nextjs-app-router-mdx Would you like to use TypeScript? No Would you like to use ESLint? Yes Would you like to use Tailwind CSS? No Would you like to use `src/` directory? Yes Would you like to use App Router? (recommended) Yes Would you like to customize the default import alias (@/*)? No

To test that everything is ready, we run

npm run dev

and opening http://localhost:3000 we should see this page

nextjs welcome

Writing posts using Markdown and MDX

To use Markdown and MDX to write our posts we have to

  1. install some additional libraries

    npm install @next/mdx @mdx-js/loader @mdx-js/react
  2. create the src/mdx-components.js file with this content

    // File: src/mdx-components.js const useMDXComponents = (components) => { return { ...components, }; }; export { useMDXComponents };
  3. Let's create src/lib/constants.mjs with constants that we are going to use across the whole solution

    // File: src/lib/constants.mjs export const CONFIG_PAGE_EXTENSIONS = ["js", "jsx", "mdx"]; export const SITE_PUBLISHER = "Elia Contini"; export const SITE_DESCRIPTION = "A blog built with Next.js 14, App Router and MDX"; export const SITE_TITLE = "Next.js 14 Static Blog";
  4. Edit next.config.mjs to use MDX

    // File: next.config.mjs // nextjs import WithMDX from "@next/mdx"; // custom import { CONFIG_PAGE_EXTENSIONS } from "./src/lib/constants.mjs"; const withMDX = WithMDX(); /** @type {import('next').NextConfig} */ const nextConfig = { pageExtensions: CONFIG_PAGE_EXTENSIONS, }; export default withMDX(nextConfig);

And now we have to write our first post. Let's create the src/app/blog/my-first-post/page.mdx file with this content

{/* File: src/app/blog/my-first-post/page.mdx */} import Image from "next/image"; import placeholder from "./placeholder.png"; export const metadata = { authors: [ { name: "Elia Contini", }, ], datePublish: "2024-07-10T10:00:00Z", description: "Description of Welcome to my MDX page!", keywords: ["nextjs", "app router", "mdx"], title: "Welcome to my MDX page!", }; # {metadata.title} This is some **bold** and _italics_ text. This is a list in markdown: - One - Two - Three <Image src={placeholder} alt="Just a placeholder image" />

You can find the placeholder.png image in the repository.

If we configured everything fine, opening http://localhost:3000/blog/my-first-post we should see a page like this

my first post

Defining the layout and some metadata for the blog

  1. To give a minimum of style to the blog we have to edit src/app/globals.css and replace the content with

    /* File: src/app/globals.css */ :root { --font-family: sans-serif; --font-size: 100%; } body { background-color: #fff; color: #000; font-family: var(--font-family); font-size: var(--font-size); } footer { text-align: center; } header { align-items: center; display: flex; } nav { margin-left: 16px; } nav ul { list-style: none; margin: 0; padding: 0; } nav li { display: inline-block; padding: 8px; }
  2. And now we define a basic layout for our pages. Edit src/app/layout.js and replace the content with

    // File: src/app/layout.js // nextjs import Link from "next/link"; // custom import { SITE_DESCRIPTION, SITE_PUBLISHER, SITE_TITLE } from "@/lib/constants"; import "./globals.css"; export const metadata = { description: SITE_DESCRIPTION, title: SITE_TITLE, }; const PageLayout = ({ children }) => { return ( <html lang="en"> <body> <header> <h1>{SITE_TITLE}</h1> <nav> <ul> <li> <Link href="/">Home</Link> </li> <li> <Link href="/blog">Blog</Link> </li> </ul> </nav> </header> <main>{children}</main> <footer> &copy; {new Date().getFullYear()} {SITE_PUBLISHER} </footer> </body> </html> ); }; export default PageLayout;
  3. Last but not the least, we edit src/app/page.js to bring to the life the homepage replacing the content with

    // File: src/app/page.js // custom import { SITE_DESCRIPTION, SITE_TITLE } from "@/lib/constants"; const Home = () => { return ( <> <h2>Welcome to {SITE_TITLE}</h2> <p>{SITE_DESCRIPTION}</p> </> ); }; export default Home;

At the end of these steps navigating to http://localhost:3000 we should see a page like this

homepage

An API to list posts descending

It is time to face the tricky part: listing blog posts. It took a while to get things working because all examples I found use the old Pages Router or do not cover MDX cases.

I created a file called src/lib/api/Posts.js where I centralized all operations to read the file system and retrieve posts metadata. This is the code

// File: src/lib/api/Posts.js // nodejs import fs from "fs"; import path from "path"; const POSTS_FOLDER = path.join(process.cwd(), "src", "app", "blog"); const Posts = () => { return { get: async () => { const files = fs.readdirSync(POSTS_FOLDER); const slugs = files.filter((file) => { return fs.lstatSync(path.join(POSTS_FOLDER, file)).isDirectory(); }); const posts = []; for (const slug of slugs) { // // IMPORTANT // // import() does NOT work using path.join() output // const { metadata } = await import(`./../app/blog/${slug}/page.mdx`); const post = { metadata, slug, }; posts.push(post); } // sort posts by descending publish date posts.sort((postA, postB) => { const dateA = postA.metadata.datePublish; const dateB = postB.metadata.datePublish; if (dateA > dateB) { return -1; } if (dateA < dateB) { return 1; } return 0; }); return posts; }, }; }; export default Posts;

First the script reads the folder src/app/blog, then it filters files and keeps folders (folder names are the slugs of the posts).

Once all the slugs are collected, for each slug it dynamically imports page.mdx and extracts metadata.

At the end the script sorts the post descending using metadata.datePublish.

Finally I created src/lib/api/index.js that encapsulate the Posts API

// File: src/lib/api/index.js // custom import Posts from "./Posts"; const Api = () => { return { posts: Posts(), }; }; export default Api;

Using the API

We have the API so we can create the page to list all posts of the blog. Let's create src/app/blog/page.js

// File: src/app/blog/page.js // custom import Api from "@/lib/api"; import Posts from "@/components/Posts"; const api = Api(); export const metadata = { description: "The blog about everything", title: "Blog", }; const Blog = async () => { const posts = await api.posts.get(); return <Posts posts={posts} />; }; export default Blog;

You can find the components I developed in the repository.

Conclusions

This is my first experience with Next.js and to be honest I expected that it would be easier to make a simple static blog.

Now that I have this base I have to understand how to

  • add all metadata (social cards, etc.) and generate the sitemap;

  • generate the RSS feed (probably I can reuse the Posts API);

  • paginate the list of posts.

References


Did you find this post useful? What about Buy Me A Coffee

A photo of Elia Contini
Written by
Elia Contini
Sardinian UX engineer and a Front-end web architect based in Switzerland. Marathoner, traveller, wannabe nature photographer.