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.
- The user submits Markdown.
- Your server converts it to sanitized HTML.
- You save both
body_markdownandbody_html. - 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
markedoutput intodangerouslySetInnerHTML. - 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,httpsandmailto.
For the underlying risks, read Why Markdown is not safe by default. For the endpoint details, see the API docs.