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.
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:
bashnpx 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:
bashnpx 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:
bashnpm 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:
typescriptimport 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))",
: {
: ,
: ,
},
: {
: ,
: ,
},
: {
: ,
: ,
},
: {
: ,
: ,
},
: {
: ,
: ,
},
: {
: ,
: ,
},
: {
: ,
: ,
},
},
: {
: ,
: ,
: ,
},
: {
: {
: { : },
: { : },
},
: {
: { : },
: { : },
},
},
: {
: ,
: ,
},
},
},
: [(), ()],
: [
{
: ,
},
],
} satisfies ;
config;
5. Set Up MDX Support
Update the next.config.mjs:
javascriptimport createMDX from "@next/mdx";
/** @type {import('next').NextConfig} */
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:
typescriptimport 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"; // Adjust the import path as needed
import { Button } from "@/components/ui/button";
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
YouTube,
pre: Pre, // Use the custom Pre component
code: (props) => {
const { className, children } = props;
if (className) {
return <Code {...props} />;
}
return <InlineCode>{children}</InlineCode>;
},
h1: (props) => ,
: ,
: ,
: ,
: ,
: ,
: ,
: ,
: ,
: ,
: ,
: (
),
: ,
};
}
Here are the custom components used in this example:
YouTube: A custom component for embedding YouTube videos
typescriptimport 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"
/>
);
};
;
Code: A custom component for rendering code blocks
typescript"use client";
import React, { useRef, useState } from "react";
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const Code = (props: any) => {
const [copied, setCopied] = useState(false);
const codeRef = useRef<HTMLElement>(null);
// Extract the language from the className
const className = props.className || "";
const matches = className.match(/language-(?<lang>.*)/);
const language = matches?.groups?.lang || "";
// Handle copy functionality
const handleCopy = () => {
if (codeRef.current) {
const codeText = codeRef.current.innerText;
navigator.clipboard.writeText(codeText).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000); // Reset after 2 seconds
});
}
};
return (
<div className="code-block gap-0 rounded-lg text-white pb-6">
<div =>
{language}
{copied ? "Copied!" : "Copy"}
{props.children}
);
};
;
InlineCode: A custom component for rendering inline code blocks
typescript"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:
typescriptimport 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.
🎉 Getting Started: Begin your development with this Next.js 14
starter template. Its a foundation for creating modern web
applications.
Code on Github
);
}
8. Create Dynamic MDX Page Component
Create a page.tsx file in app/blog/[slug]:
typescriptimport 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 ();
}
{ metadata } = ();
{
slug,
metadata,
};
} (error) {
.(, error);
();
}
}
() {
files = fs.(path.(, ));
params = files.( ({
: filename.(, ),
}));
params;
}
() {
{ slug } = params;
post = (params);
= ( ());
formattedDate = (
(post..),
);
(
);
}
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:
mdxexport 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@tailwind base;
@tailwind components;
@tailwind utilities;
/* Import the Dracula theme for Highlight.js */
@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 ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
}
{
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
}
}
base {
* {
border-border;
}
{
bg-background text-foreground;
}
}
pre {
: ;
: auto;
: break-word;
}
{
: pre-wrap;
: break-word;
}
11. Run Your Project
Run your project in development mode:
bashnpm run dev
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.