Creating a Next.js 14 App with MDX
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))",
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
:
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) => <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
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"
allowFullScreen
/>
</div>
</div>
);
};
export default YouTube;
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 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"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.
</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]
:
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", "blog", `${slug}.mdx`);
if (!fs.existsSync(mdxPath)) {
throw new Error(`MDX file for slug ${slug} does not exist`);
}
const { metadata } = await import(`@/content/blog/${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", "blog"));
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/blog/${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
:
mdxexport const metadata = { title: "Example Page", publishDate: "2024-05-05", }; # Example Post This is an example MDX post.
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 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;
}
}
/* Add this to your global CSS file */
.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:
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.