Build a static blog with Next.js 14, App Router, and MDX
July 11, 2024Tested 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
Writing posts using Markdown and MDX
To use Markdown and MDX to write our posts we have to
-
install some additional libraries
npm install @next/mdx @mdx-js/loader @mdx-js/react
-
create the
src/mdx-components.js
file with this content// File: src/mdx-components.js const useMDXComponents = (components) => { return { ...components, }; }; export { useMDXComponents };
-
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";
-
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
Defining the layout and some metadata for the blog
-
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; }
-
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> © {new Date().getFullYear()} {SITE_PUBLISHER} </footer> </body> </html> ); }; export default PageLayout;
-
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
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
-
Next.js Markdown and MDX
-
Next.js Metadata
-
Dynamic import with import()