Rendering CKEditor content in Next.js
CKEditor gives you rich text editing, but raw HTML from the editor is unsafe and unstyled—a recipe for broken layouts and XSS vulnerabilities. In this post, I'll walk you through rendering CKEditor content in Next.js with sanitization, typography styling, element upgrades, and syntax highlighting, turning editor output into production-ready pages.
The problem with raw editor HTML
When users save content in CKEditor, you get back unescaped HTML. Drop that directly into your Next.js page and you're asking for trouble: malicious scripts, broken links, oversized images, and prose that looks nothing like your design system.
I learned this the hard way. A client's CRM was storing editor HTML, and when we rendered it server-side without sanitization, a single paste from a compromised email template broke security. We needed a pipeline: sanitize, style, enhance, and highlight.
Sanitize first — Allow headings, lists, tables, code blocks and images only
Start with DOMPurify (or isomorphic-dompurify for SSR). Define a strict allowlist of tags and attributes.
import DOMPurify from 'isomorphic-dompurify';
const ALLOWED_TAGS = [
'h2', 'h3', 'p', 'ul', 'ol', 'li',
'strong', 'em', 'code', 'blockquote',
'pre', 'figure', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
'img', 'a', 'br'
];
const ALLOWED_ATTR = {
'img': ['src', 'alt', 'width', 'height'],
'a': ['href', 'title'],
'pre': ['class'],
'code': ['class'],
'figure': ['class']
};
export function sanitizeEditorHTML(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
KEEP_CONTENT: true
});
}This strips everything else: scripts, event handlers, style attributes, unknown tags. In a trading platform I built, this saved us when a user pasted malicious markup from a phishing email.
Why allowlist instead of blocklist? Allowlists are predictable and maintainable. You know exactly what you're permitting. Blocklists break the moment a new attack vector appears.
Style with typography — Tailwind typography's prose classes style raw HTML nicely
Raw HTML is semantically correct but visually bland. Tailwind's @tailwindcss/typography plugin provides prose classes that style all those HTML elements at once.
Install it:
npm install -D @tailwindcss/typographyAdd to tailwind.config.ts:
import typography from '@tailwindcss/typography';
export default {
plugins: [typography],
theme: {
extend: {}
}
};Now wrap your sanitized HTML in a prose div:
export function EditorContent({ html }: { html: string }) {
const clean = sanitizeEditorHTML(html);
return (
<div>
<div>
</div>
);
}</div>The prose-lg class handles font sizes and spacing. dark:prose-invert flips colors for dark mode. No manual styling needed.
In an e-commerce platform I worked on, this single class gave product descriptions professional typography instantly—headings got larger, lists got proper spacing, blockquotes got left borders and italics.
Upgrade elements — Parse HTML and replace img with next/image and links with next/link
Prose styling is step one, but you can do better. Replace plain <img> with Next.js Image for optimization, and <a> with next/link for client-side navigation.
I use html-react-parser to parse the HTML string into React elements, then intercept and replace them:
import parse, { domToReact, HTMLReactParserOptions } from 'html-react-parser';
import Image from 'next/image';
import Link from 'next/link';
import { ReactNode } from 'react';
export function EditorContent({ html }: { html: string }) {
const clean = sanitizeEditorHTML(html);
const options: HTMLReactParserOptions = {
replace(domNode: any) {
if (domNode.type === 'tag') {
if (domNode.name === 'img' && domNode.attribs.src) {
const { src, alt, width, height } = domNode.attribs;
return (
<img src="{src}" alt="{alt" width="{parseInt(width)" height="{parseInt(height)" />
);
}
if (domNode.name === 'a' && domNode.attribs.href) {
const { href, title } = domNode.attribs;
const isInternal = href.startsWith('/');
if (isInternal) {
return (
{domToReact(domNode.children, options) as ReactNode}
);
}
return (
<a href="{href}" title="{title}" target="_blank" rel="noopener noreferrer">
{domToReact(domNode.children, options) as ReactNode}
</a>
);
}
}
}
};
return (
<div>
{parse(clean, options)}
</div>
);
}This approach gave us huge wins in a SaaS analytics dashboard: images load responsively and are cached by Next.js, internal links navigate without full page reloads, external links open safely in new tabs.
Note: html-react-parser recurses through children, so you don't lose nested elements. The key prop prevents React warnings.
Code highlighting — Highlight pre code blocks on the server with Shiki
If your editor content includes code blocks, ship highlighted HTML instead of relying on client-side JavaScript. Shiki is a server-side syntax highlighter that's fast and accurate.
npm install shikiCreate a server utility:
import { codeToHtml } from 'shiki';
export async function highlightCode(code: string, lang: string = 'tsx'): Promise {
try {
return await codeToHtml(code, {
lang,
theme: 'github-dark'
});
} catch {
// Fallback if language is unsupported
return `<pre><code>${code}Integrate into your sanitization pipeline. After sanitizing, find all <pre><code> blocks and re-render them with highlights:
import { JSDOM } from 'jsdom';
export async function highlightEditorHTML(html: string): Promise {
const clean = sanitizeEditorHTML(html);
const dom = new JSDOM(clean);
const doc = dom.window.document;
const codeBlocks = doc.querySelectorAll('pre code');
for (const block of codeBlocks) {
const lang = (block.className || '').replace('language-', '') || 'tsx';
const code = block.textContent || '';
const highlighted = await highlightCode(code, lang);
const temp = doc.createElement('div');
temp.innerHTML = highlighted;
const preParent = block.parentElement;
if (preParent) {
preParent.replaceWith(...temp.childNodes);
}
}
return dom.serialize();
}Use this in a Server Component:
export default async function ArticlePage({ params }: { params: { id: string } }) {
const article = await db.articles.findById(params.id);
const highlightedHTML = await highlightEditorHTML(article.content);
return (
);
}I did this on a travel booking platform where we displayed booking confirmation templates. Code snippets for API integrations were suddenly readable, with proper line numbers and syntax colors. Shiki's output is already HTML, so it plays nicely with prose classes.
Common mistakes
- Forgetting to sanitize: Using dangerouslySetInnerHTML without DOMPurify is a security hole. Always sanitize first.
- Not setting Image dimensions: Next.js Image requires width and height. If CKEditor strips them, fall back to reasonable defaults or extract from the image file.
- Blocking all external links: Some editors output external links. Allowlist them, just set rel="noopener noreferrer" to prevent window.opener attacks.
- Highlighting on the client: Loading a 200KB syntax highlighter in the browser slows pages down. Highlight server-side with Shiki and ship HTML.
- Prose without max-width: Without max-w-4xl, long lines become hard to read. Prose is designed for constrained widths.
Key takeaways
- Sanitize CKEditor HTML with an allowlist of safe tags and attributes to prevent XSS.
- Use Tailwind's prose classes to style raw HTML semantically without custom CSS.
- Parse and replace img tags with next/image and links with next/link for performance and UX.
- Highlight code blocks server-side with Shiki and ship pre-highlighted HTML.
- Wrap everything in a Server Component to keep sanitization and highlighting off the client.






Comments
Loading comments…