How to parse Markdown in Next.js

By

Learn how to parse Markdown in Next.js with marked, DOMPurify, and jsdom in a Server Component, plus the same pipeline inside getStaticProps.

~~~

To parse Markdown in a Next.js page, convert it to HTML with marked on the server, sanitize the result with DOMPurify, and render the HTML string in your component.

I had a field with markdown and I wanted to print it in a Next.js page, so that’s exactly what I did.

I used marked, dompurify and jsdom. Each one has a job. marked converts the Markdown string to HTML. DOMPurify removes anything dangerous from that HTML. jsdom gives DOMPurify a DOM to work with on the server.

By the way, if your markdown documents are long, a table of contents helps. I made a markdown TOC generator that builds one from your headings.

Why sanitize the output?

Markdown can contain raw HTML. If the content comes from users, like the item descriptions in my case, someone can put a <script> tag or an onerror attribute in there. Render that as-is and you have an XSS hole. DOMPurify strips the dangerous parts and keeps the safe markup.

There’s a catch: DOMPurify assumes a browser environment with a window object. On the server we’re in a Node.js environment, where no window exists. That’s what jsdom solves. It creates one.

App Router (preferred)

Do the work in a Server Component (or in a small server-only helper it calls), so marked, DOMPurify and jsdom stay on the server and never end up in the client bundle. In Next.js 15 and later params is a promise, so you await it first:

import prisma from 'lib/prisma'
import { getItem } from 'lib/data.js'
import { marked } from 'marked'
import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'

export default async function ItemPage({ params }) {
  const { id } = await params
  const item = await getItem(prisma, String(id))

  const window = new JSDOM('').window
  const DOMPurify = createDOMPurify(window)
  const description = DOMPurify.sanitize(marked.parse(item.description))

  return <div dangerouslySetInnerHTML={{ __html: description }} />
}

Note the import. Since version 4, marked has no default export, so you import { marked } and call marked.parse(). Older snippets you find online (including the first version of this post) call marked() directly, and that fails with current releases.

If you prefer to load the item from a Route Handler under app/api, the same sanitize step works there, before you return the JSON. See Next.js API routes for the App Router handler layout.

Pages Router (legacy)

On older apps, the same libraries ran inside getStaticProps():

import prisma from 'lib/prisma'
import { getItem, getItems } from 'lib/data.js'
import { marked } from 'marked'
import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'

export default function Item({ item }) {
  return <div dangerouslySetInnerHTML={{ __html: item.description }} />
}

export async function getStaticPaths() {
  const items = await getItems(prisma)

  return {
    paths: items.map(item => ({
      params: {
        id: String(item.id),
      },
    })),
    fallback: false,
  }
}

export async function getStaticProps({ params }) {
  const id = String(params.id)
  const item = await getItem(prisma, id)

  const window = new JSDOM('').window
  const DOMPurify = createDOMPurify(window)
  item.description = DOMPurify.sanitize(marked.parse(item.description))

  return { props: { item } }
}

Doing the work at build time (or in a Server Component) has the same nice side effect: the browser receives ready-made HTML.

Rendering the HTML

The description is now an HTML string. If you render it as {item.description} in JSX, React escapes it, and the user sees the literal tags printed on the page, like <p>Great job</p>. That’s why the component uses dangerouslySetInnerHTML.

The name is scary on purpose, but here we’re covered. The string went through DOMPurify first, and that’s the whole point of this setup.

Tagged: Next.js · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about next: