How to render WordPress content with correct styling in a headless Next.js application without breaking your app's own styles.
This guide explains how NextPress renders WordPress content CSS in a headless Next.js application and what you need to set up so the content looks the same as it does on the WordPress backend.
WordPress outputs its own CSS: global styles from theme.json, per-block stylesheets, layout spacing rules, and custom theme styles. When you render WordPress content inside a Next.js application, those styles must:
NextPress solves this with CSS scoping via @scope ([data-rendered]) and a set of components that work together to load, scope, and render WordPress styles.
The Content component wraps all WordPress HTML in <div data-rendered>. This element is the scope root — all WordPress CSS is wrapped in @scope ([data-rendered]) { ... } so it only applies inside this boundary.
<!-- Your app chrome — unaffected by WordPress CSS --> <nav>...</nav> <!-- WordPress content — scoped styles apply here --> <div data-rendered> <div class="is-layout-constrained has-global-padding"> <h1 class="wp-block-heading has-5-xl-font-size">...</h1> <p>...</p> </div> </div> <!-- Your app chrome — unaffected by WordPress CSS --> <footer>...</footer>
| Component | What It Renders | Scoping |
|---|---|---|
| GlobalStyles | Theme.json stylesheet, custom CSS, font faces | Scoped via @scope ([data-rendered]). :root variable-only blocks are extracted and kept global. |
| Stylesheets | Per-page enqueued stylesheets (<link> tags) and their inline additions (before/after data) | Inline CSS is scoped. Linked stylesheets load as external <link> tags (unscoped). |
| WPHead | Combines GlobalStyles + Stylesheets + ImportMap + head scripts in one component | Convenience wrapper; delegates to the above. |
| Content | WordPress HTML content inside [data-rendered] with layout classes | Provides the scope root element. |
WordPress's theme.json defines CSS custom properties (e.g. --wp--preset--font-size--5-xl: var(--text-5xl)) that must be accessible at :root level. When NextPress scopes inline CSS, it:
:root blocks that contain only --variable: value declarations.@layer wrapper (e.g. @layer theme { :root { ... } }) for correct layer ordering.@scope so they remain on :root.This ensures the variable chain resolves: --wp--preset--font-size--5-xl → var(--text-5xl) → 3rem.
WordPress uses :root as a specificity bump in layout rules like:
:root :where(.is-layout-constrained) > * { margin-block-start: 24px; margin-block-end: 0; }
The :root selector gives this rule specificity 0,1,0, which it needs to override block-level margin shorthands. When scoping, NextPress rewrites :root to :scope (not &), which preserves the 0,1,0 specificity inside @scope.
@scope isolates WordPress styles to [data-rendered], but inside the scope NextPress still has to keep theme.json's generic design tokens from blocking author CSS (in either the theme's compiled stylesheet or in per-instance block-supports inline content) that has every reason to override them. The fix is a single CSS cascade layer for theme.json:
@layer wp-theme;
| Source | Path | Wrapping |
|---|---|---|
globalStyles.stylesheet + customCss | GlobalStyles.tsx, AssetUpdater.updateGlobalStyles | @layer wp-theme { @scope ([data-rendered]) { … } } |
Proxied .css files (any assetsByUri handle with a src — wp-block-library, plugin/theme CSS) | proxyByWCR.ts | @scope ([data-rendered]) { … } — unlayered |
before / after inline content on any assetsByUri handle (per-instance core-block-supports, dynamic plugin inline styles) | Stylesheets.tsx, AssetUpdater.updateStylesheets | @scope ([data-rendered]) { … } — unlayered |
App CSS (Tailwind, your globals.css) | n/a | unlayered |
Inline style="…" attributes | n/a | always wins |
CSS Cascade Layers L5 says unlayered author rules beat any layered author rules. So the resulting cascade priority is:
wp-theme (theme.json) < unlayered (proxied .css + Stylesheets inline + app CSS) < inline style="…"
This makes per-instance block-supports CSS (e.g. an editor-set style.spacing.blockGap on a core/columns block) reliably override the matching theme.json default — even when both rules have the same selector specificity and the theme.json rule appears later in the document. It also lets the active theme's compiled stylesheet (.wp-block-button.is-style-cta .wp-block-button__link { background: var(--accent); }, etc.) beat theme.json's generic .wp-element-button rules by normal specificity, the same way it does on the WordPress backend.
Why not layer proxied .css too? An earlier version of NextPress wrapped every proxied .css file in
@layer wp-base(belowwp-theme). That was too aggressive: a theme's specific button-variant rule, no matter how specific its selector, would lose to theme.json's generic button rule, because layer ordering trumps specificity. Leaving proxied .css unlayered restores the cascade users expect from the WordPress backend.
NextPress never branches on specific WP handle names (e.g. core-block-supports, wp-block-library). The layering only looks at which payload field a chunk of CSS came from — globalStyles field, assetsByUri external file, or assetsByUri inline before/after. Any WP setup that exposes those fields gets the correct cascade for free; classic themes, FSE block themes, plugin-only sites, and headless-first installs all work without nextpress needing to know what's installed.
:root (or :host) blocks that contain only --variable: value declarations are extracted before the @layer wrap and emitted at the top level. CSS custom properties are inherited values resolved via the element's ancestor chain, not via the cascade between competing declarations — so they stay global so that var() references inside any layer (or outside any layer) resolve correctly.
The extractor preserves a :root block's original @layer wrapper if it had one (e.g. Tailwind's @layer theme { :root, :host { --vars } }) so the file's internal layer registration order isn't lost.
WordPress's wp_enqueue_global_styles() adds wp_get_global_stylesheet() as inline-after content on the global-styles handle — duplicating the same content that NextPress already exposes through the dedicated globalStyles.stylesheet GraphQL field. If both reached the browser the duplicate would compete with — and on some renders win against — the canonical wp-theme copy via source-order accidents.
To prevent that, NextPress's WP plugin filters the global-styles handle out of the enqueued queue in WP_Assets::flatten_enqueued_assets_list(). The theme.json content still reaches the frontend via globalStyles.stylesheet; the unlayered duplicate just disappears.
Your WordPress theme should be a block theme with a theme.json that defines:
"color": "var(--primary)")"size": "var(--text-5xl)")contentSize, wideSize, useRootPaddingAwareAlignments)core/post-content block using a layout attribute:<!-- templates/page.html --> <!-- wp:template-part {"slug":"header","area":"header"} /--> <!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} --> <main class="wp-block-group"> <!-- wp:post-content {"layout":{"type":"constrained"}} /--> </main> <!-- /wp:group --> <!-- wp:template-part {"slug":"footer","area":"footer"} /-->
The {"layout":{"type":"constrained"}} on post-content is what generates the layout classes that NextPress exposes via the contentCssClasses GraphQL field.
If your theme uses Tailwind CSS, the compiled style.css should define all design tokens in @theme blocks:
/* src/style.css */ @import "tailwindcss"; @theme { --font-family-sans: 'Roboto', system-ui, sans-serif; --font-family-serif: 'Libre Franklin', Georgia, serif; --text-sm: 0.875rem; --text-base: 1rem; --text-lg: 1.125rem; /* ... all size/spacing/color tokens ... */ --color-primary: oklch(0.328 0.068 257.3); --color-primary-foreground: oklch(0.935 0.043 137.9); } :root { --primary: var(--color-primary); --primary-foreground: var(--color-primary-foreground); /* ... semantic mappings ... */ } .dark { --primary: var(--color-primary-light); /* ... dark mode overrides ... */ }
These variables flow through to theme.json presets. When NextPress scopes the theme stylesheet's inline CSS, the @layer theme { :root { ... } } block is extracted and kept at :root level, making the variables available to WordPress's global styles.
Your Next.js routes that render WordPress content should use a CSS file that omits Tailwind's Preflight to avoid overriding WordPress's element-level styles inside [data-rendered].
/* app/wordpress.css */ /* Import Tailwind theme + utilities only — no Preflight */ @import "tailwindcss/theme.css" layer(theme); @import "tailwindcss/utilities.css" layer(utilities); /* Your theme tokens and brand colors */ @import "./brand.css"; /* Scoped normalization: resets for app chrome only */ @layer base { *, ::before, ::after { box-sizing: border-box; border-width: 0; border-style: solid; } /* Resets OUTSIDE [data-rendered] — WordPress controls its own defaults */ :is(h1, h2, h3, h4, h5, h6, p, blockquote, dl, dd, figure, pre):not([data-rendered] *) { margin: 0; } :is(ol, ul, menu):not([data-rendered] *) { list-style: none; margin: 0; padding: 0; } /* Anchor reset outside WordPress content only */ a:not([data-rendered] *) { color: inherit; text-decoration: inherit; } }
Import this in your WordPress layout instead of your regular globals.css:
// app/(wordpress)/layout.tsx import './wordpress.css'; // NOT globals.css export default async function WordPressLayout({ children }) { // ... }
Non-WordPress routes (e.g. account pages, docs) can continue using globals.css with full Tailwind Preflight.
Your WordPress layout should use WPHead and WPFooter to load all assets, and pass contentCssClasses to the Content component:
// app/(wordpress)/layout.tsx import { WPHead, WPFooter } from '@axistaylor/nextpress'; import { AssetUpdater } from '@axistaylor/nextpress/client'; import { headers } from 'next/headers'; export default async function WordPressLayout({ children }) { const uri = (await headers()).get('x-uri') || '/'; const [{ stylesheets, scripts, importMap }, globalStyles] = await Promise.all([ fetchAssets(uri), fetchGlobalStyles(), ]); return ( <html lang="en"> <head> <WPHead stylesheets={stylesheets} scripts={scripts} globalStyles={globalStyles} importMap={importMap} pathname={uri} /> </head> <body> <nav>/* Your app navbar */</nav> <main>{children}</main> <footer>/* Your app footer */</footer> <WPFooter scripts={scripts} pathname={uri} /> <AssetUpdater fetchAssets={fetchAssetsAction} /> </body> </html> ); }
Fetch both content and contentCssClasses and pass them to Content:
// app/(wordpress)/[[...uri]]/page.tsx import { Content } from '@axistaylor/nextpress'; export default async function Page({ params }) { const { uri: segments } = await params; const uri = '/' + (segments?.join('/') || ''); const res = await fetch(GRAPHQL_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: `query ($uri: String!) { nodeByUri(uri: $uri) { ... on Page { content contentCssClasses } ... on Post { content contentCssClasses } } }`, variables: { uri }, }), }); const { data } = await res.json(); const node = data?.nodeByUri; if (!node?.content) return null; return ( <Content content={node.content} contentCssClasses={node.contentCssClasses} /> ); }
WordPress preset font sizes like has-5-xl-font-size depend on a variable chain: --wp--preset--font-size--5-xl → var(--text-5xl) → 3rem. If the heading appears at the wrong size:
--text-5xl is defined in your theme's compiled CSS inside @layer theme { :root { ... } }.--wp--preset--* variables).fontSizes reference the same variable names as your theme CSS.Content appears flush with no gaps between blocks:
contentCssClasses is being fetched and passed to Content. The field should return classes like is-layout-constrained and has-global-padding.core/post-content block has a layout attribute: <!-- wp:post-content {"layout":{"type":"constrained"}} /-->.useRootPaddingAwareAlignments is true in your theme.json settings.Buttons, links, or headings inside WordPress content lose their styling:
wordpress.css) for routes that render WordPress content. See Step 2 above.:not([data-rendered] *) guard on normalization rules ensures resets only apply to your app chrome, not WordPress content.A block's margin shorthand overrides the layout's margin-block-start:
:root to :scope inside @scope to preserve specificity. If you see layout spacing not working, check that scopeStyles.ts uses :scope (not &) for :root rewrites.theme.json output lives in @layer wp-theme; proxied wp-block-library CSS is unlayered. Unlayered author CSS beats any layer, so wp-block-library will win wherever its selector matches and its specificity is at least as high as theme.json's. theme.json uses :where() extensively, which zeros out specificity, so a theme.json rule like :scope :where(.is-layout-flex){gap: var(--spacing-X)} (0,1,0) loses to wp-block-library's plain .wp-block-X.is-layout-flex (0,2,0) regardless of layer ordering.
To make theme.json win in those cases, either tighten the theme.json selector via the WP plugin's style engine settings, or override on a per-block instance via the editor (per-instance core-block-supports rules also land unlayered, and they're emitted with selectors specific enough to beat wp-block-library).
This is what the wp-theme layer is for. If a style.spacing.blockGap (or padding/margin) set on a specific block in the editor isn't overriding the matching theme.json default:
settings.spacing.blockGap (or padding/margin) is true in theme.json — without that flag, the editor doesn't emit per-instance overrides at all.<style> element WITHOUT an @layer wrapper. It should sit inside @scope ([data-rendered]) { … } but not inside any @layer. If it's accidentally inside wp-theme, the cascade won't bump it above the theme.json default.WP_Assets::flatten_enqueued_assets_list() is still skipping the global-styles handle. If that filter is missing, the duplicate unlayered copy of theme.json reaches the browser, undoing the wp-theme/unlayered split.