Getting Started with MDX CMS

January 2, 2026 by Pointer Team

This project uses MDX files as a simple, file-based content management system. No database required!

What is MDX?

MDX is Markdown with the power of JSX. You can write regular Markdown and use React components within it.

greeting.tsx
interface GreetingProps {
  name: string;
  age?: number;
}

export function Greeting({ name, age }: GreetingProps) {
  return (
    <div>
      <h1>Hello, {name}!</h1>
      {age && <p>You are {age} years old.</p>}
    </div>
  );
}
greeting.jsx
export function Greeting({ name, age }) {
  return (
    <div>
      <h1>Hello, {name}!</h1>
      {age && <p>You are {age} years old.</p>}
    </div>
  );
}

You can also use tabs for package manager commands:

npm install next react react-dom
pnpm add next react react-dom
yarn add next react react-dom

And here's another example with the same groupId to show synced tabs:

npm run dev
pnpm dev
yarn dev
app/blog/posts/my-post.mdx
---
title: My Blog Post
publishedAt: 2026-01-02
summary: A brief description
---

# Your content here
app/lib/rehype-shiki.ts
import type { Element, Root, Text } from "hast";
import { fromHtml } from "hast-util-from-html";
import { visit } from "unist-util-visit";
import { COPY_ICON_SVG, getFileIconSvg } from "@/app/lib/file-icons";
import { highlightCode } from "@/app/lib/shiki";

const LINE_NUMBERS_REGEX = /\blineNumbers(?:=(\d+))?\b/; 
const FILENAME_REGEX = /\bfilename=["']([^"']+)["']/;

export function rehypeShiki() { 
  return async function transformer(tree: Root) {
    const codeBlocks: Array<{
      node: Element;
      parent: Element | Root;
      index: number;
      code: any; 
      code: string; 
      lang: string;
      showLineNumbers: boolean;
      startLineNumber: number;
      filename: string | null;
    }> = [];

    visit(tree, "element", (node: Element, index, parent) => {
      if (
        node.tagName === "pre" &&
        node.children[0]?.type === "element" &&
        (node.children[0] as Element).tagName === "code"
      ) {
        const codeElement = node.children[0] as Element;
        const className = codeElement.properties?.className;

        let lang = "plaintext";
        if (Array.isArray(className)) {
          const langClass = className.find(
            (c) => typeof c === "string" && c.startsWith("language-")
          );
          if (langClass && typeof langClass === "string") {
            lang = langClass.replace("language-", "");
          }
        }

        const meta =
          (codeElement.data as Record<string, unknown>)?.meta ||
          codeElement.properties?.metastring ||
          "";
        const lineNumbersMatch = String(meta).match(LINE_NUMBERS_REGEX);
        const hasLineNumbers = lineNumbersMatch !== null;
        const startLineNumber = lineNumbersMatch?.[1]
          ? Number.parseInt(lineNumbersMatch[1], 10)
          : 1;

        const filenameMatch = String(meta).match(FILENAME_REGEX);
        const filename = filenameMatch?.[1] || null;

        const code = getTextContent(codeElement);

        if (parent && typeof index === "number") {
          codeBlocks.push({
            node,
            parent,
            index,
            code,
            lang,
            showLineNumbers: hasLineNumbers,
            startLineNumber,
            filename,
          });
        }
      }
    });

    await Promise.all(
      codeBlocks.map(
        async ({
          parent,
          index,
          code,
          lang,
          showLineNumbers,
          startLineNumber,
          filename,
        }) => {
          const trimmedCode = code.trim();
          const highlightedHtml = await highlightCode({
            code: trimmedCode,
            lang,
            showLineNumbers,
            startLineNumber,
          });

          const hastTree = fromHtml(highlightedHtml, { fragment: true });
          const preElement = hastTree.children[0] as Element;

          const wrapperChildren: Element[] = [];

          if (filename) {
            const headerElement: Element = {
              type: "element",
              tagName: "div",
              properties: {
                className: ["code-block-header"],
              },
              children: [
                {
                  type: "element",
                  tagName: "div",
                  properties: {
                    className: ["code-block-header-filename"],
                    "data-filename": filename,
                  },
                  children: [
                    {
                      type: "element",
                      tagName: "span",
                      properties: {
                        className: ["code-block-icon"],
                      },
                      children: fromHtml(getFileIconSvg(filename), {
                        fragment: true,
                      }).children as Element[],
                    },
                    {
                      type: "element",
                      tagName: "span",
                      properties: {},
                      children: [{ type: "text", value: filename }],
                    },
                  ],
                },
                {
                  type: "element",
                  tagName: "button",
                  properties: {
                    type: "button",
                    className: ["copy-button"],
                    "aria-label": "Copy code",
                  },
                  children: fromHtml(COPY_ICON_SVG, {
                    fragment: true,
                  }).children as Element[],
                },
              ],
            };
            wrapperChildren.push(headerElement);
          }

          if (preElement) {
            wrapperChildren.push(preElement);
          }

          if (!filename) {
            const copyButtonContainer: Element = {
              type: "element",
              tagName: "div",
              properties: {
                className: ["copy-button-container"],
              },
              children: [
                {
                  type: "element",
                  tagName: "button",
                  properties: {
                    type: "button",
                    className: ["copy-button"],
                    "aria-label": "Copy code",
                  },
                  children: fromHtml(COPY_ICON_SVG, {
                    fragment: true,
                  }).children as Element[],
                },
              ],
            };
            wrapperChildren.push(copyButtonContainer);
          }

          const wrapper: Element = {
            type: "element",
            tagName: "div",
            properties: {
              className: filename
                ? ["shiki-wrapper", "has-header"]
                : ["shiki-wrapper"],
              "data-language": lang,
              "data-line-numbers": String(showLineNumbers),
              "data-code": encodeURIComponent(trimmedCode),
              ...(filename && { "data-filename": filename }),
            },
            children: wrapperChildren,
          };

          if (Array.isArray(parent.children)) {
            parent.children[index] = wrapper;
          }
        }
      )
    );
  };
}

function getTextContent(node: Element | Text): string {
  if (node.type === "text") {
    return node.value;
  }
  if ("children" in node && Array.isArray(node.children)) {
    return node.children
      .map((child) => getTextContent(child as Element | Text))
      .join("");
  }
  return "";
}
content/pages/example.mdx
# Regular Markdown heading

And you can use components:

<Button variant="primary">Click me</Button>

Creating Pages

Creating a new page is as simple as creating a new .mdx file:

  1. Create the file: content/pages/my-page.mdx
  2. Add frontmatter with metadata
  3. Write your content using Markdown and components
  4. Done! Visit /my-page

Blog Posts

Blog posts work similarly, but are stored in app/blog/posts/:

app/blog/posts/my-post.mdx
---
title: My Blog Post
publishedAt: 2026-01-02
summary: A brief description
---

# Your content here

Next Steps

  • Check out the documentation
  • Explore example pages in content/pages/
  • Read more blog posts

Pro tip: All MDX files support syntax highlighting, custom components, and regular Markdown features!

Filed under: Product

Author: Pointer Team