diff --git a/examples/with-mdx-remote/.gitignore b/examples/with-mdx-remote/.gitignore index 8777267507c0e..5ef6a52078020 100644 --- a/examples/with-mdx-remote/.gitignore +++ b/examples/with-mdx-remote/.gitignore @@ -28,9 +28,10 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +.pnpm-debug.log* -# local env files -.env*.local +# env files (can opt-in for committing if needed) +.env* # vercel .vercel diff --git a/examples/with-mdx-remote/README.md b/examples/with-mdx-remote/README.md index 974cea5b8114f..6474b5177fa4c 100644 --- a/examples/with-mdx-remote/README.md +++ b/examples/with-mdx-remote/README.md @@ -1,20 +1,16 @@ # MDX Remote Example -This example shows how a simple blog might be built using the [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote) library, which allows mdx content to be loaded via `getStaticProps` or `getServerSideProps`. The mdx content is loaded from a local folder, but it could be loaded from a database or anywhere else. - -The example also showcases [next-remote-watch](https://github.com/hashicorp/next-remote-watch), a library that allows next.js to watch files outside the `pages` folder that are not explicitly imported, which enables the mdx content here to trigger a live reload on change. - -Since `next-remote-watch` uses undocumented Next.js APIs, it doesn't replace the default `dev` script for this example. To use it, run `npm run dev:watch` or `yarn dev:watch`. +This example shows how a simple blog might be built using the [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote) library, which allows mdx content to be loaded via [`generateStaticParams`](https://nextjs.org/docs/app/api-reference/functions/generate-static-params). The mdx content is loaded from a local folder, but it could be loaded from a database or anywhere else. ## Deploy your own -Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example): +Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) or preview live with [StackBlitz](https://stackblitz.com/github/vercel/next.js/tree/canary/examples/with-mdx-remote) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-mdx-remote&project-name=with-mdx-remote&repository-name=with-mdx-remote) ## How to use -Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example: +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), [pnpm](https://pnpm.io), or [Bun](https://bun.sh/docs/cli/bunx) to bootstrap the example: ```bash npx create-next-app --example with-mdx-remote with-mdx-remote-app @@ -28,56 +24,39 @@ yarn create next-app --example with-mdx-remote with-mdx-remote-app pnpm create next-app --example with-mdx-remote with-mdx-remote-app ``` -Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). +```bash +bunx create-next-app --example with-mdx-remote with-mdx-remote-app +``` + +Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/app/building-your-application/deploying)). ## Notes ### Conditional custom components -When using `next-mdx-remote`, you can pass custom components to the MDX renderer. However, some pages/MDX files might use components that are used infrequently, or only on a single page. To avoid loading those components on every MDX page, you can use `next/dynamic` to conditionally load them. +When using `next-mdx-remote`, you can pass custom components to the MDX renderer. However, some pages/MDX files might use components that are used infrequently, or only on a single page. To avoid loading those components on every MDX page, you can use [`next/dynamic`](https://nextjs.org/docs/app/building-your-application/optimizing/lazy-loading#nextdynamic) to conditionally load them. -For example, here's how you can change `getStaticProps` to pass a list of component names, checking the names in the page render function to see which components need to be dynamically loaded. - -```js +```typescript import dynamic from "next/dynamic"; -import Test from "../components/test"; +import Test from "@/components/test"; +import { MDXRemote, type MDXRemoteProps } from 'next-mdx-remote/rsc' -const SomeHeavyComponent = dynamic(() => import("SomeHeavyComponent")); +const SomeHeavyComponent = dynamic(() => import("../component/SomeHeavyComponent")); const defaultComponents = { Test }; -export function SomePage({ mdxSource, componentNames }) { +export function CustomMDX(props: MDXRemoteProps) { + const componentNames = [ + / null, }; - return ; -} - -export async function getStaticProps() { - const source = `--- - title: Conditional custom components - --- - - Some **mdx** text, with a default component and a Heavy component - `; - - const { content, data } = matter(source); - - const componentNames = [ - /; } ``` diff --git a/examples/with-mdx-remote/app/[slug]/page.module.css b/examples/with-mdx-remote/app/[slug]/page.module.css new file mode 100644 index 0000000000000..5bac86853222d --- /dev/null +++ b/examples/with-mdx-remote/app/[slug]/page.module.css @@ -0,0 +1,3 @@ +.description { + opacity: 0.6; +} diff --git a/examples/with-mdx-remote/app/[slug]/page.tsx b/examples/with-mdx-remote/app/[slug]/page.tsx new file mode 100644 index 0000000000000..52805d981d06e --- /dev/null +++ b/examples/with-mdx-remote/app/[slug]/page.tsx @@ -0,0 +1,45 @@ +import { notFound } from "next/navigation"; +import { CustomMDX } from "@/components/mdx"; +import { getPosts } from "@/lib/utils"; +import styles from "./page.module.css"; +import Link from "next/link"; + +export function generateStaticParams() { + const posts = getPosts(); + + return posts.map((post) => ({ + slug: post.slug, + })); +} + +export default async function Blog({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const slug = (await params).slug; + const post = getPosts().find((post) => post.slug === slug); + + if (!post) { + notFound(); + } + + return ( + <> +
+ +
+
+

{post.metadata.title}

+ {post.metadata.description && ( +

{post.metadata.description}

+ )} +
+ +
+
+ + ); +} diff --git a/examples/with-mdx-remote/app/globals.css b/examples/with-mdx-remote/app/globals.css new file mode 100644 index 0000000000000..0214b44bbb9b2 --- /dev/null +++ b/examples/with-mdx-remote/app/globals.css @@ -0,0 +1,59 @@ +:root { + --background: #ffffff; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +html, +body { + font: 100%/1.5 system-ui; + max-width: 100vw; + overflow-x: hidden; +} + +body { + color: var(--foreground); + background: var(--background); + font-family: Arial, Helvetica, sans-serif; + max-width: 36rem; + margin: 0 auto; + padding: 1.5rem; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +a { + color: inherit; + text-decoration-thickness: 2px; +} + +a:hover { + color: royalblue; + text-decoration-color: currentcolor; +} + +p { + margin-bottom: 1.5rem; +} + +code { + font-family: Menlo; +} + +@media (prefers-color-scheme: dark) { + html { + color-scheme: dark; + } +} diff --git a/examples/with-mdx-remote/app/layout.tsx b/examples/with-mdx-remote/app/layout.tsx new file mode 100644 index 0000000000000..c006f823a3406 --- /dev/null +++ b/examples/with-mdx-remote/app/layout.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "MDX Remote App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/examples/with-mdx-remote/app/page.tsx b/examples/with-mdx-remote/app/page.tsx new file mode 100644 index 0000000000000..4088039e87b66 --- /dev/null +++ b/examples/with-mdx-remote/app/page.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import { getPosts } from "@/lib/utils"; + +export default function Home() { + const posts = getPosts(); + + return ( +
+

Home Page

+

+ Click the link below to navigate to a page generated by{" "} + next-mdx-remote. +

+
    + {posts.map((post) => ( +
  • + {post.metadata.title} +
  • + ))} +
+
+ ); +} diff --git a/examples/with-mdx-remote/app/posts/example-post.mdx b/examples/with-mdx-remote/app/posts/example-post.mdx new file mode 100644 index 0000000000000..fb4538a54208b --- /dev/null +++ b/examples/with-mdx-remote/app/posts/example-post.mdx @@ -0,0 +1,12 @@ +--- +title: Example Post +description: This frontmatter description will appear below the title +--- + +This is an example post, with with a [link](https://nextjs.org) and a React component: + + + +Links are rendered using a custom component passed to `next-mdx-remote`. + +Go back [home](/). diff --git a/examples/with-mdx-remote/app/posts/hello-world.mdx b/examples/with-mdx-remote/app/posts/hello-world.mdx new file mode 100644 index 0000000000000..c5963cdb70f3f --- /dev/null +++ b/examples/with-mdx-remote/app/posts/hello-world.mdx @@ -0,0 +1,5 @@ +--- +title: Hello World +--- + +This is an example post. There's another one [here](/example-post). diff --git a/examples/with-mdx-remote/components/CustomLink.js b/examples/with-mdx-remote/components/CustomLink.js deleted file mode 100644 index e119f276b7d26..0000000000000 --- a/examples/with-mdx-remote/components/CustomLink.js +++ /dev/null @@ -1,16 +0,0 @@ -import Link from "next/link"; - -export default function CustomLink({ as, href, ...otherProps }) { - return ( - <> - - - - - - ); -} diff --git a/examples/with-mdx-remote/components/Layout.js b/examples/with-mdx-remote/components/Layout.js deleted file mode 100644 index c8a89906150ce..0000000000000 --- a/examples/with-mdx-remote/components/Layout.js +++ /dev/null @@ -1,49 +0,0 @@ -export default function Layout({ children }) { - return ( - <> -
{children}
- - - - ); -} diff --git a/examples/with-mdx-remote/components/TestComponent.js b/examples/with-mdx-remote/components/TestComponent.js deleted file mode 100644 index 98f38a505684c..0000000000000 --- a/examples/with-mdx-remote/components/TestComponent.js +++ /dev/null @@ -1,16 +0,0 @@ -export default function TestComponent({ name = "world" }) { - return ( - <> -
Hello, {name}!
- - - ); -} diff --git a/examples/with-mdx-remote/components/greet.module.css b/examples/with-mdx-remote/components/greet.module.css new file mode 100644 index 0000000000000..06fa2739a2418 --- /dev/null +++ b/examples/with-mdx-remote/components/greet.module.css @@ -0,0 +1,7 @@ +.div { + background-color: #111; + border-radius: 0.5em; + color: #fff; + margin-bottom: 1.5em; + padding: 0.5em 0.75em; +} diff --git a/examples/with-mdx-remote/components/greet.tsx b/examples/with-mdx-remote/components/greet.tsx new file mode 100644 index 0000000000000..b4552dd50f165 --- /dev/null +++ b/examples/with-mdx-remote/components/greet.tsx @@ -0,0 +1,5 @@ +import styles from "./greet.module.css"; + +export function Greet({ name = "world" }: { name: string }) { + return
Hello, {name}!
; +} diff --git a/examples/with-mdx-remote/components/mdx.module.css b/examples/with-mdx-remote/components/mdx.module.css new file mode 100644 index 0000000000000..59d89a5de215b --- /dev/null +++ b/examples/with-mdx-remote/components/mdx.module.css @@ -0,0 +1,4 @@ +.a { + color: tomato; + text-decoration-color: rgba(0, 0, 0, 0.4); +} diff --git a/examples/with-mdx-remote/components/mdx.tsx b/examples/with-mdx-remote/components/mdx.tsx new file mode 100644 index 0000000000000..a062621599e34 --- /dev/null +++ b/examples/with-mdx-remote/components/mdx.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import Link from "next/link"; +import { MDXRemote, type MDXRemoteProps } from "next-mdx-remote/rsc"; +import { Greet } from "./greet"; +import styles from "./mdx.module.css"; + +function CustomLink({ + href, + children, + ...props +}: React.LinkHTMLAttributes) { + if (href?.startsWith("/")) { + return ( + + {children} + + ); + } + + if (href?.startsWith("#")) { + return ( +
+ {children} + + ); + } + + return ( + + {children} + + ); +} + +export function CustomMDX(props: MDXRemoteProps) { + const components = { + a: CustomLink, + Greet: Greet, + }; + + return ( + + ); +} diff --git a/examples/with-mdx-remote/lib/utils.ts b/examples/with-mdx-remote/lib/utils.ts new file mode 100644 index 0000000000000..b1ff53aa0cae1 --- /dev/null +++ b/examples/with-mdx-remote/lib/utils.ts @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import path from "node:path"; + +type Metadata = { + title: string; + description?: string; +}; + +function parseFrontmatter(fileContent: string) { + const frontmatterRegex = /---\s*([\s\S]*?)\s*---/; + const content = fileContent.replace(frontmatterRegex, "").trim(); + const match = frontmatterRegex.exec(fileContent); + const frontMatterBlock = match![1]; + const frontMatterLines = frontMatterBlock.trim().split("\n"); + const metadata: Partial = {}; + + frontMatterLines.forEach((line) => { + const [key, ...valueArr] = line.split(": "); + let value = valueArr.join(": ").trim(); + value = value.replace(/^['"](.*)['"]$/, "$1"); // Remove quotes + metadata[key.trim() as keyof Metadata] = value; + }); + + return { metadata: metadata as Metadata, content: content }; +} + +function getMDXFiles(dir: fs.PathLike) { + return fs.readdirSync(dir).filter((file) => path.extname(file) === ".mdx"); +} + +function readMDXFile(filePath: fs.PathOrFileDescriptor) { + const rawContent = fs.readFileSync(filePath, "utf-8"); + return parseFrontmatter(rawContent); +} + +function getMDXData(dir: fs.PathLike) { + const mdxFiles = getMDXFiles(dir); + return mdxFiles.map((file) => { + const { metadata, content } = readMDXFile(path.join(dir.toString(), file)); + const slug = path.basename(file, path.extname(file)); + + return { + metadata, + slug, + content, + }; + }); +} + +export function getPosts() { + return getMDXData(path.join(process.cwd(), "app", "posts")); +} diff --git a/examples/with-mdx-remote/package.json b/examples/with-mdx-remote/package.json index 7682f95c68690..e273d6bb17d06 100644 --- a/examples/with-mdx-remote/package.json +++ b/examples/with-mdx-remote/package.json @@ -1,17 +1,20 @@ { "private": true, "scripts": { - "dev": "next", - "dev:watch": "next-remote-watch ./posts", + "dev": "next dev --turbopack", "build": "next build", "start": "next start" }, "dependencies": { - "gray-matter": "^4.0.2", "next": "latest", - "next-mdx-remote": "^3.0.1", - "next-remote-watch": "1.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "next-mdx-remote": "^5.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "typescript": "^5.7.2" } } diff --git a/examples/with-mdx-remote/pages/index.js b/examples/with-mdx-remote/pages/index.js deleted file mode 100644 index 75947328a187e..0000000000000 --- a/examples/with-mdx-remote/pages/index.js +++ /dev/null @@ -1,45 +0,0 @@ -import fs from "fs"; -import matter from "gray-matter"; -import Link from "next/link"; -import path from "path"; -import Layout from "../components/Layout"; -import { postFilePaths, POSTS_PATH } from "../utils/mdxUtils"; - -export default function Index({ posts }) { - return ( - -

Home Page

-

- Click the link below to navigate to a page generated by{" "} - next-mdx-remote. -

-
    - {posts.map((post) => ( -
  • - - {post.data.title} - -
  • - ))} -
-
- ); -} - -export function getStaticProps() { - const posts = postFilePaths.map((filePath) => { - const source = fs.readFileSync(path.join(POSTS_PATH, filePath)); - const { content, data } = matter(source); - - return { - content, - data, - filePath, - }; - }); - - return { props: { posts } }; -} diff --git a/examples/with-mdx-remote/pages/posts/[slug].js b/examples/with-mdx-remote/pages/posts/[slug].js deleted file mode 100644 index 712e27255752f..0000000000000 --- a/examples/with-mdx-remote/pages/posts/[slug].js +++ /dev/null @@ -1,96 +0,0 @@ -import fs from "fs"; -import matter from "gray-matter"; -import { MDXRemote } from "next-mdx-remote"; -import { serialize } from "next-mdx-remote/serialize"; -import dynamic from "next/dynamic"; -import Head from "next/head"; -import Link from "next/link"; -import path from "path"; -import CustomLink from "../../components/CustomLink"; -import Layout from "../../components/Layout"; -import { postFilePaths, POSTS_PATH } from "../../utils/mdxUtils"; - -// Custom components/renderers to pass to MDX. -// Since the MDX files aren't loaded by webpack, they have no knowledge of how -// to handle import statements. Instead, you must include components in scope -// here. -const components = { - a: CustomLink, - // It also works with dynamically-imported components, which is especially - // useful for conditionally loading components for certain routes. - // See the notes in README.md for more details. - TestComponent: dynamic(() => import("../../components/TestComponent")), - Head, -}; - -export default function PostPage({ source, frontMatter }) { - return ( - -
- -
-
-

{frontMatter.title}

- {frontMatter.description && ( -

{frontMatter.description}

- )} -
-
- -
- - -
- ); -} - -export const getStaticProps = async ({ params }) => { - const postFilePath = path.join(POSTS_PATH, `${params.slug}.mdx`); - const source = fs.readFileSync(postFilePath); - - const { content, data } = matter(source); - - const mdxSource = await serialize(content, { - // Optionally pass remark/rehype plugins - mdxOptions: { - remarkPlugins: [], - rehypePlugins: [], - }, - scope: data, - }); - - return { - props: { - source: mdxSource, - frontMatter: data, - }, - }; -}; - -export const getStaticPaths = async () => { - const paths = postFilePaths - // Remove file extensions for page paths - .map((path) => path.replace(/\.mdx?$/, "")) - // Map the path into the static paths object required by Next.js - .map((slug) => ({ params: { slug } })); - - return { - paths, - fallback: false, - }; -}; diff --git a/examples/with-mdx-remote/posts/example-post.mdx b/examples/with-mdx-remote/posts/example-post.mdx deleted file mode 100644 index e1b6bad048b0b..0000000000000 --- a/examples/with-mdx-remote/posts/example-post.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Example Post -description: This frontmatter description will appear below the title ---- - -This is an example post, with a [link](https://nextjs.org) and a React component: - - - -The title and description are pulled from the MDX file and processed using `gray-matter`. Additionally, links are rendered using a custom component passed to `next-mdx-remote`. - -Go back [home](/). diff --git a/examples/with-mdx-remote/posts/hello-world.mdx b/examples/with-mdx-remote/posts/hello-world.mdx deleted file mode 100644 index c4c1e23a77414..0000000000000 --- a/examples/with-mdx-remote/posts/hello-world.mdx +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Hello World ---- - -This is an example post. There's another one [here](/posts/example-post). diff --git a/examples/with-mdx-remote/tsconfig.json b/examples/with-mdx-remote/tsconfig.json new file mode 100644 index 0000000000000..d8b93235f205e --- /dev/null +++ b/examples/with-mdx-remote/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/examples/with-mdx-remote/utils/mdxUtils.js b/examples/with-mdx-remote/utils/mdxUtils.js deleted file mode 100644 index de8db693d6521..0000000000000 --- a/examples/with-mdx-remote/utils/mdxUtils.js +++ /dev/null @@ -1,11 +0,0 @@ -import fs from "fs"; -import path from "path"; - -// POSTS_PATH is useful when you want to get the path to a specific file -export const POSTS_PATH = path.join(process.cwd(), "posts"); - -// postFilePaths is the list of all mdx files inside the POSTS_PATH directory -export const postFilePaths = fs - .readdirSync(POSTS_PATH) - // Only include md(x) files - .filter((path) => /\.mdx?$/.test(path));