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.