Create a Static MDX Blog with Next.js

2024-05-26
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
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
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
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
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
import 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:

typescript
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"; // 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
typescript
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
"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:

typescript
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&apos;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&apos;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
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", "blogs", `${slug}.mdx`);
    if (!fs.existsSync(mdxPath)) {
      throw new Error(`MDX file for slug ${slug} does not exist`);
    }

    const { metadata } = await import(`@/content/blogs/${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", "blogs"));
  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/blogs/${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
export 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:

bash
npm 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.