2024-08-23 Web Development
Create a Static MDXBlog with Next.js By O Wolfson
Here’s a step-by-step guide to creating a Next.js 14 app that generates static MDX pages from documents stored in a local directory. This article dives into the essentials of using MDX with Next.js, starting with the installation of necessary packages like @next/mdx
. It covers configuration steps to make your Next.js application recognize and properly handle .md and .mdx files. Check the code on GitHub for the latest updates.
Code at GitHub:
Deployed at Vercel:
Prerequisites
Node.js installed
Basic knowledge of Next.js and React
1. Create a New Next.js Project
First, create a new Next.js project using the app router, Tailwind CSS, and TypeScript:
bash Copy
npx create-next-app@latest my-app --typescript --tailwind --eslint
2. Install and Configure Shadcn UI
Run the Shadcn UI init command to set up your project:
bash Copy
npx shadcn-ui@latest init
You will be asked a few questions to configure components.json
:
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Do you want to use CSS variables for colors? › no / yes
You may want to enable dark mode in your project. Follow the instructions here to set up dark mode in your project: Shadcn UI Dark Mode for Next.js apps .
3. Install Dependencies
Install the necessary dependencies for MDX support, Tailwind CSS, and other utilities:
bash Copy
npm install @next/mdx @types/mdx gray-matter react-syntax-highlighter remark-gfm styled-components @mdx-js/loader shadcn/ui
npm install -D @types/node @types/react @types/react-dom @types/react-syntax-highlighter eslint eslint-config-next postcss tailwindcss typescript
4. Configure Tailwind CSS
Edit tailwind.config.ts
:
typescript Copy
import type { Config } from "tailwindcss" ;
const config = {
darkMode : ["class" ],
content : [
"./pages/**/*.{ts,tsx}" ,
"./components/**/*.{ts,tsx}" ,
"./app/**/*.{ts,tsx}" ,
"./src/**/*.{ts,tsx}" ,
"./(app|components)/**/*.{ts,tsx,mdx}" ,
"./mdx-components.tsx" ,
],
prefix : "" ,
theme : {
hljs : {
theme : "atom-one-dark" ,
},
container : {
center : true ,
padding : "2rem" ,
screens : {
"2xl" : "1400px" ,
},
},
extend : {
fontFamily : {
sans : [
"var(--font-sans)" ,
...require ("tailwindcss/defaultTheme" ).fontFamily .sans ,
],
},
colors : {
border : "hsl(var(--border))" ,
input : "hsl(var(--input))" ,
ring : "hsl(var(--ring))" ,
background : "hsl(var(--background))" ,
backgroundImage : {
"gradient-radial" : "radial-gradient(var(--tw-gradient-stops))" ,
"gradient-conic" :
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))" ,
},
foreground : "hsl(var(--foreground))" ,
primary : {
DEFAULT : "hsl(var(--primary))" ,
foreground : "hsl(var(--primary-foreground))" ,
},
secondary : {
DEFAULT : "hsl(var(--secondary))" ,
foreground : "hsl(var(--secondary-foreground))" ,
},
destructive : {
DEFAULT : "hsl(var(--destructive))" ,
foreground : "hsl(var(--destructive-foreground))" ,
},
muted : {
DEFAULT : "hsl(var(--muted))" ,
foreground : "hsl(var(--muted-foreground))" ,
},
accent : {
DEFAULT : "hsl(var(--accent))" ,
foreground : "hsl(var(--accent-foreground))" ,
},
popover : {
DEFAULT : "hsl(var(--popover))" ,
foreground : "hsl(var(--popover-foreground))" ,
},
card : {
DEFAULT : "hsl(var(--card))" ,
foreground : "hsl(var(--card-foreground))" ,
},
},
borderRadius : {
lg : "var(--radius)" ,
md : "calc(var(--radius) - 2px)" ,
sm : "calc(var(--radius) - 4px)" ,
},
keyframes : {
"accordion-down" : {
from : { height : "0" },
to : { height : "var(--radix-accordion-content-height)" },
},
"accordion-up" : {
from : { height : "var(--radix-accordion-content-height)" },
to : { height : "0" },
},
},
animation : {
"accordion-down" : "accordion-down 0.2s ease-out" ,
"accordion-up" : "accordion-up 0.2s ease-out" ,
},
},
},
plugins : [require ("tailwindcss-animate" ), require ("tailwind-highlightjs" )],
safelist : [
{
pattern : /hljs+/ ,
},
],
} satisfies Config ;
export default config;
5. Set Up MDX Support
Update the next.config.mjs
:
javascript Copy
import createMDX from "@next/mdx" ;
const nextConfig = {
pageExtensions : ["js" , "jsx" , "md" , "mdx" , "ts" , "tsx" ],
};
const withMDX = createMDX ();
export default withMDX (nextConfig);
6. Create MDX Components File
Create a file mdx-components.tsx
at the project root for custom MDX components:
typescript Copy
import React from "react" ;
import type { MDXComponents } from "mdx/types" ;
import YouTube from "@/components/mdx/youtube" ;
import Code from "@/components/mdx/code" ;
import InlineCode from "@/components/mdx/inline-code" ;
import Pre from "@/components/mdx/pre" ;
import { Button } from "@/components/ui/button" ;
export function useMDXComponents (components: MDXComponents ): MDXComponents {
return {
...components,
YouTube ,
pre : Pre ,
code : (props ) => {
const { className, children } = props;
if (className) {
return <Code {...props } /> ;
}
return <InlineCode > {children}</InlineCode > ;
},
h1 : (props ) => <h1 className ="text-4xl font-black pb-4" {...props } /> ,
h2 : (props ) => <h2 className ="text-3xl font-bold pb-4" {...props } /> ,
h3 : (props ) => <h3 className ="text-2xl font-semibold pb-4 " {...props } /> ,
h4 : (props ) => <h4 className ="text-xl font-medium pb-4" {...props } /> ,
h5 : (props ) => <h5 className ="text-lg font-normal pb-4" {...props } /> ,
h6 : (props ) => <h6 className ="text-base font-light pb-4" {...props } /> ,
p : (props ) => <p className ="text-lg mb-4" {...props } /> ,
li : (props ) => <li className ="pb-1" {...props } /> ,
ul : (props ) => <ul className ="list-disc pl-6 pb-4" {...props } /> ,
ol : (props ) => <ol className ="list-decimal pl-6 pb-4" {...props } /> ,
hr : (props ) => <hr className ="my-4" {...props } /> ,
blockquote : (props ) => (
<blockquote
style ={{ paddingBottom: 0 }}
className ="border-l-4 pl-4 my-4"
{...props }
/>
),
a : (props ) => <a className ="hover:underline font-semibold" {...props } /> ,
};
}
Here are the custom components used in this example:
YouTube
: A custom component for embedding YouTube videos
typescript Copy
import React from "react" ;
interface YouTubeProps {
id : string ;
}
const YouTube : React .FC <YouTubeProps > = ({ id } ) => {
return (
<div className ="pb-4" >
<div
style ={{
position: "relative ",
paddingBottom: "56.25 %", // 16:9 aspect ratio
height: 0 ,
overflow: "hidden ",
maxWidth: "100 %",
background: "#000 ",
}}
>
<iframe
title ="YouTube video"
src ={ `https: //www.youtube.com /embed /${id }`}
style ={{
position: "absolute ",
top: 0 ,
left: 0 ,
width: "100 %",
height: "100 %",
}}
allow ="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div >
</div >
);
};
export default YouTube ;
Code
: A custom component for rendering code blocks
typescript Copy
"use client" ;
import React , { useRef, useState } from "react" ;
const Code = (props: any ) => {
const [copied, setCopied] = useState (false );
const codeRef = useRef<HTMLElement >(null );
const className = props.className || "" ;
const matches = className.match (/language-(?<lang>.*)/ );
const language = matches?.groups ?.lang || "" ;
const handleCopy = ( ) => {
if (codeRef.current ) {
const codeText = codeRef.current .innerText ;
navigator.clipboard .writeText (codeText).then (() => {
setCopied (true );
setTimeout (() => setCopied (false ), 2000 );
});
}
};
return (
<div className ="code-block gap-0 rounded-lg text-white pb-6" >
<div className ="flex justify-between items-center bg-gray-900 py-2 px-4 rounded-t-lg" >
<span className ="text-gray-300" > {language}</span >
<button
type ="button"
className ="text-gray-300 hover:text-white"
onClick ={handleCopy}
>
{copied ? "Copied!" : "Copy"}
</button >
</div >
<pre className ="bg-gray-800 p-4 rounded-b-lg overflow-auto" >
<code
ref ={codeRef}
className ={ `${className } bg-gray-800 `}
style ={{ whiteSpace: "pre-wrap " }}
>
{props.children}
</code >
</pre >
</div >
);
};
export default Code ;
InlineCode
: A custom component for rendering inline code blocks
typescript Copy
"use client" ;
import type React from "react" ;
interface InlineCodeProps {
children : React .ReactNode ;
}
const InlineCode : React .FC <InlineCodeProps > = ({ children } ) => {
return (
<code className ="bg-gray-200 text-gray-900 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 rounded text-base" >
{children}
</code >
);
};
export default InlineCode ;
7. Create the Landing Page
Modify the page.tsx
file in the app
directory for the landing page:
typescript Copy
import type { Metadata } from "next" ;
import Link from "next/link" ;
export async function generateMetadata ( ): Promise <Metadata > {
return {
title : "Next Template" ,
};
}
export default function Home ( ) {
return (
<div className ="max-w-3xl z-10 w-full items-center justify-between" >
<div className ="w-full flex justify-center items-center flex-col gap-6" >
<h1 className ="text-5xl sm:text-6xl font-black pb-6" >
Next.js Template
</h1 >
<div className ="flex flex-col gap-4 text-lg w-full" >
<p >
🚀 Next.js 14 Framework: This is a basic template starter using
Next.js 14. It offers efficient performance and fast page loading.
</p >
<p >
🌟 Shadcn UI Elements: The interface uses Shadcn UI components.
It' s designed to be responsive and user-friendly.
</p >
<p >
📝 MDX Support: Write content using Markdown and embed React
components within it.
</p >
<p >
🎉 Getting Started: Begin your development with this Next.js 14
starter template. It' s a foundation for creating modern web
applications.
</p >
<Link
className ="hover:underline text-lg"
target ="_blank"
href ="https://github.com/owolfdev/next-template-mdx-shad"
>
Code on Github
</Link >
</div >
</div >
</div >
);
}
8. Create Dynamic MDX Page Component
Create a page.tsx
file in app/blog/[slug]
:
typescript Copy
import fs from "node:fs" ;
import path from "node:path" ;
import React from "react" ;
import dynamic from "next/dynamic" ;
import type { Metadata , ResolvingMetadata } from "next" ;
import { format } from "date-fns" ;
type Props = {
params : { slug : string };
};
export async function generateMetadata (
{ params }: Props,
parent: ResolvingMetadata
): Promise <Metadata > {
const post = await getPost (params);
return {
title : post.metadata .title ,
description : post.metadata .description ,
};
}
async function getPost ({ slug }: { slug: string } ) {
try {
const mdxPath = path.join ("content" , "posts" , `${slug} .mdx` );
if (!fs.existsSync (mdxPath)) {
throw new Error (`MDX file for slug ${slug} does not exist` );
}
const { metadata } = await import (`@/content/posts/${slug} .mdx` );
return {
slug,
metadata,
};
} catch (error) {
console .error ("Error fetching post:" , error);
throw new Error (`Unable to fetch the post for slug: ${slug} ` );
}
}
export async function generateStaticParams ( ) {
const files = fs.readdirSync (path.join ("content" , "posts" ));
const params = files.map ((filename ) => ({
slug : filename.replace (".mdx" , "" ),
}));
return params;
}
export default async function Page ({ params }: { params: { slug: string } } ) {
const { slug } = params;
const post = await getPost (params);
const MDXContent = dynamic (() => import (`@/content/posts/${slug} .mdx` ));
const formattedDate = format (
new Date (post.metadata .publishDate ),
"MMMM dd, yyyy"
);
return (
<div className ="max-w-3xl z-10 w-full items-center justify-between" >
<div className ="w-full flex justify-center items-center flex-col gap-6" >
<article className ="prose prose-lg md:prose-lg lg:prose-lg mx-auto min-w-full" >
<div className ="pb-8" >
<p className ="font-semibold text-lg" >
<span className ="text-red-600 pr-1" >
{post.metadata.publishDate}
</span > {" "}
{post.metadata.category}
</p >
</div >
<div className ="pb-10" >
<h1 className ="text-5xl sm:text-6xl font-black capitalize leading-12" >
{post.metadata.title}
</h1 >
</div >
<MDXContent />
</article >
</div >
</div >
);
}
9. Create Example MDX Files
Create a directory mdx
at the root of your project and add some example .mdx
files, e.g., example.mdx
:
mdx Copy
export const metadata = {
title: "Example Page",
publishDate: "2024-05-05",
};
# Example Post
This is an example MDX post.
## Code Example
Here is an example of `inline code` delineated by backticks.
Note that a code block is delineated by triple backticks and language specification.
```javascript
console.log("Hello World");
```
10. Configure Global Styles
Add the following to your globals.css
:
css Copy
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.0/styles/dracula.min.css" );
@layer base {
:root {
--background : 0 0% 100% ;
--foreground : 222.2 84% 4.9% ;
--card : 0 0% 100% ;
--card-foreground : 222.2 84% 4.9% ;
--popover : 0 0% 100% ;
--popover-foreground : 222.2 84% 4.9% ;
--primary : 222.2 47.4% 11.2% ;
--primary-foreground : 210 40% 98% ;
--secondary : 210 40% 96.1% ;
--secondary-foreground : 222.2 47.4% 11.2% ;
--muted : 210 40% 96.1% ;
--muted-foreground : 215.4 16.3% 46.9% ;
--accent : 210 40% 96.1% ;
--accent-foreground : 222.2 47.4% 11.2% ;
--destructive : 0 84.2% 60.2% ;
--destructive-foreground : 210 40% 98% ;
--border : 214.3 31.8% 91.4% ;
--input : 214.3 31.8% 91.4% ;
--ring : 222.2 84% 4.9% ;
--radius : 0.5rem ;
--chart-1 : 12 76% 61% ;
--chart-2 : 173 58% 39% ;
--chart-3 : 197 37% 24% ;
--chart-4 : 43 74% 66% ;
--chart-5 : 27 87% 67% ;
}
.dark {
--background : 222.2 84% 4.9% ;
--foreground : 210 40% 98% ;
--card : 222.2 84% 4.9% ;
--card-foreground : 210 40% 98% ;
--popover : 222.2 84% 4.9% ;
--popover-foreground : 210 40% 98% ;
--primary : 210 40% 98% ;
--primary-foreground : 222.2 47.4% 11.2% ;
--secondary : 217.2 32.6% 17.5% ;
--secondary-foreground : 210 40% 98% ;
--muted : 217.2 32.6% 17.5% ;
--muted-foreground : 215 20.2% 65.1% ;
--accent : 217.2 32.6% 17.5% ;
--accent-foreground : 210 40% 98% ;
--destructive : 0 62.8% 30.6% ;
--destructive-foreground : 210 40% 98% ;
--border : 217.2 32.6% 17.5% ;
--input : 217.2 32.6% 17.5% ;
--ring : 212.7 26.8% 83.9% ;
--chart-1 : 220 70% 50% ;
--chart-2 : 160 60% 45% ;
--chart-3 : 30 80% 55% ;
--chart-4 : 280 65% 60% ;
--chart-5 : 340 75% 55% ;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
.code-block pre {
max-width : 100% ;
overflow-x : auto;
word-break : break-word;
}
.code-block code {
white-space : pre-wrap;
word-break : break-word;
}
11. Run Your Project
Run your project in development mode:
Navigate to http://localhost:3000
to see your landing page with a list of MDX posts. Click on a post to view its content.
By following these steps, you can set up a Next.js 14 project that generates static pages from MDX documents, styled with Tailwind CSS, and enhanced with custom MDX components.