TexToolsAI

How to Render User Markdown Safely in Next.js

By VeloForge Labs · · 3 min read

You have a form where users write Markdown, and you want to show it back to other users in a Next.js app. The naive version is a few lines of code and a serious security hole. This guide shows the safe version.

The unsafe version

import { marked } from "marked";

export default async function Comment({ text }: { text: string }) {
  const html = await marked.parse(text);
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

React escapes everything by default. dangerouslySetInnerHTML switches that protection off, and marked does not sanitize its output. A comment containing <img src=x onerror=...> will run script in every reader's browser. The name of the prop is a hint, and it means it.

Convert and sanitize on write

The best pattern for user content is to do the expensive and risky work once, when the content is saved, and store the cleaned HTML next to the original Markdown.

  1. The user submits Markdown.
  2. Your server converts it to sanitized HTML.
  3. You save both body_markdown and body_html.
  4. Pages render the stored HTML.

Keeping the Markdown means you can re-render later if your rules change. Keeping the HTML means reads are fast and cannot be slowed by a conversion step.

Option A: do it in a Route Handler or Server Action

In a Server Action, call a sanitizing converter before saving. With the TexToolsAI API that is one request:

"use server";

export async function saveComment(markdown: string) {
  const res = await fetch("https://texttoolsai.org/api/convert", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: "Bearer " + process.env.TEXTOOLSAI_API_KEY,
    },
    body: JSON.stringify({ markdown }),
  });
  const data = await res.json();
  if (!res.ok || !data.success) throw new Error(data.error ?? "Conversion failed");

  // Save markdown and data.html to your database here.
}

Because this runs on the server, the API key never reaches the browser. Do not call the API from a Client Component with your real key.

Option B: do it in-process

If you would rather not depend on a network call, install marked and sanitize-html and run both inside the Server Action. Parse first, then sanitize, and use an explicit allow-list:

import { marked } from "marked";
import sanitizeHtml from "sanitize-html";

export async function renderMarkdown(markdown: string) {
  const rawHtml = await marked.parse(markdown);
  return sanitizeHtml(rawHtml, {
    allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img", "h1", "h2"]),
    allowedAttributes: { a: ["href", "title"], img: ["src", "alt"] },
    allowedSchemes: ["http", "https", "mailto"],
  });
}

See sanitize-html vs DOMPurify if you are deciding between libraries.

Rendering the stored HTML

Once the HTML is sanitized, dangerouslySetInnerHTML is acceptable, because the dangerous content is already gone:

export function CommentBody({ html }: { html: string }) {
  return <div className="comment-body" dangerouslySetInnerHTML={{ __html: html }} />;
}

Add one more habit: only ever pass HTML that came from your sanitizing function into that prop. Make it hard to pass anything else, for example by keeping the sanitized value in a column named body_html_sanitized.

Styling

Sanitized HTML arrives as plain tags without your design system's classes. Style the container with descendant selectors, such as .comment-body h2, .comment-body a and .comment-body table. Tailwind's typography plugin does the same job with a single prose class.

Checklist

  • Never put raw marked output into dangerouslySetInnerHTML.
  • Sanitize with an allow-list after converting.
  • Convert on write, store both versions.
  • Keep API keys in server-only environment variables.
  • Limit link schemes to http, https and mailto.

For the underlying risks, read Why Markdown is not safe by default. For the endpoint details, see the API docs.