When you fetch content from a headless CMS, the content usually arrives as HTML, Markdown, or rich-text JSON that eventually becomes HTML on your page.
That is exactly what you want from a CMS. Your editors write content in one place, your frontend renders it somewhere else, and your site stays flexible.
But there is one small problem: raw CMS content often looks terrible when you render it directly especially if you use tailwind which strips the default browser styles.
You might get clean markup like this:
<h2>Getting started</h2>
<p>This is a paragraph from your CMS.</p>
<ul>
<li>First item</li>
<li>Second item</li>
</ul>Then you render it in your app and realize headings, paragraphs, lists, blockquotes, tables, code, and images all need proper spacing and typography.

If you are using Tailwind CSS, the easiest way to solve this is with the official typography plugin.
Why CMS content needs a wrapper
Tailwind is intentionally low-level. It gives you utility classes, but it does not automatically style every h2, p, ul, or blockquote on your site. That is usually a good thing because it keeps your design predictable.
CMS content is different. You often do not control every element inside the content. The HTML comes from an editor, a Markdown parser, or an API response.
For example, a post from Marble can include paragraphs, headings, lists, images, code blocks, tables, embeds, and captions. You could manually write CSS for every possible element, but that gets tedious quickly.
The better pattern is to wrap the rendered content in a typography container.
Install Tailwind Typography
First, install the plugin:
npm install -D @tailwindcss/typographyIf you are using Tailwind CSS v4, add the plugin in your CSS file:
@import "tailwindcss";
@plugin "@tailwindcss/typography";If you are using Tailwind CSS v3, add it to your Tailwind config instead:
module.exports = {
theme: {
extend: {},
},
plugins: [require("@tailwindcss/typography")],
};The plugin gives you a prose class that applies sensible default styles to long-form HTML.
Render your CMS content with prose
Once the plugin is installed, wrap your CMS content with prose.
In a Next.js app, that might look like this:
export function PostContent({ html }: { html: string }) {
return (
<article
className="prose prose-zinc max-w-none"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}That one class handles a lot of the basic typography work for you:
Heading sizes and spacing.
Paragraph line height.
List indentation.
Blockquote styling.
Table borders.
Inline code styling.
Image and caption spacing.
The max-w-none class is optional, but useful if your layout already controls the content width. By default, prose adds a readable max width.

Use dark mode when needed
If your site supports dark mode, add dark:prose-invert:
<article
className="prose prose-zinc dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: html }}
/>This flips the typography colors so headings, paragraphs, borders, captions, and code blocks remain readable on dark backgrounds.
Customize individual elements
The typography plugin also supports element modifiers. This is where it becomes more useful than a generic CSS reset.
You can style specific elements inside your CMS content while still keeping the outer wrapper simple:
<article
className="prose prose-zinc max-w-none
prose-a:text-blue-600
prose-a:no-underline
hover:prose-a:underline
prose-img:rounded-xl
prose-pre:bg-zinc-950
prose-code:before:content-none
prose-code:after:content-none"
dangerouslySetInnerHTML={{ __html: html }}
/>That lets you keep your content readable while still matching your site design.
Create a reusable content component
Instead of repeating a long class list on every page, create a reusable component for CMS-rendered content.
import { cn } from "@/lib/utils";
export function CMSContent({
html,
className,
}: {
html: string;
className?: string;
}) {
return (
<article
className={cn(
"prose prose-zinc dark:prose-invert max-w-none",
"prose-headings:scroll-mt-24 prose-headings:font-semibold",
"prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline",
"prose-img:rounded-xl prose-figcaption:text-center",
"prose-pre:rounded-xl prose-pre:border",
className
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}Now your blog post page can stay clean:
export default async function BlogPostPage({ params }) {
const post = await getPost(params.slug);
return (
<main className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-4xl font-semibold tracking-tight">
{post.title}
</h1>
<CMSContent html={post.content} className="mt-10" />
</main>
);
}Customize prose with design tokens
If your site uses CSS variables for colors, you can map the typography plugin to your design tokens.
For example, Marble's website uses a custom typography theme so CMS-rendered content follows the same foreground, muted, border, and code colors as the rest of the site:
import type { Config } from "tailwindcss";
export default {
theme: {
extend: {
typography: () => ({
marble: {
css: {
"--tw-prose-bold": "var(--foreground)",
"--tw-prose-bullets": "var(--muted-foreground)",
"--tw-prose-captions": "var(--muted-foreground)",
"--tw-prose-code": "var(--foreground)",
"--tw-prose-code-bg": "var(--muted)",
"--tw-prose-th-borders": "var(--border)",
"--tw-prose-td-borders": "var(--border)",
"code:not(pre code)": {
color: "var(--tw-prose-code)",
backgroundColor: "var(--tw-prose-code-bg)",
borderRadius: "0.375rem",
paddingInline: "0.275rem",
fontSize: "0.875rem",
fontWeight: "600",
},
},
},
}),
},
},
} satisfies Config;Then use it like this:
<article
className="prose prose-marble max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>This is a good middle ground: your CMS content gets sensible defaults, but it still feels like the rest of your product.
Fix nested paragraph spacing in lists
Depending on your CMS or rich-text editor, list items may contain nested paragraphs:
<ul>
<li><p>First item</p></li>
<li><p>Second item</p></li>
</ul>That is valid HTML, but it can create extra spacing inside lists. One small utility can smooth it out:
@utility prose {
& li p {
margin: 0;
}
}You can also solve this with regular CSS if you prefer:
.prose li p {
margin: 0;
}Be careful with raw HTML
If your CMS returns HTML, React requires dangerouslySetInnerHTML to render it. That name is intentionally scary. Only render HTML you trust or HTML that has been sanitized by your CMS or your own server.
A good CMS should sanitize unsafe tags and attributes before storing or returning content. Still, your frontend should treat remote HTML as a boundary and avoid mixing untrusted user input into it.
What about code blocks?
The typography plugin gives code blocks basic styling, but it does not add syntax highlighting by itself.
If your CMS content includes technical posts, you probably want a syntax highlighter like Shiki. We wrote a separate guide on how to apply syntax highlighting to content from a headless CMS.
Wrapping up
The simplest way to style CMS content with Tailwind is:
Install
@tailwindcss/typography.Wrap your rendered content in
prose.Add
dark:prose-invertif your site supports dark mode.Use prose modifiers for images, links, code, tables, and headings.
Create a reusable content component so every CMS-powered page looks consistent.
That small wrapper turns plain CMS HTML into something readable, predictable, and much closer to the rest of your site design.
If you are using Marble, the API returns clean post content that works well with this pattern. Fetch the post, pass the HTML into your content component, and let Tailwind handle the long-form typography.