Theme UI is great until you turn on a real Content Security Policy and half your styling disappears.

I’ve hit this a few times with React apps that looked perfectly fine in development, then blew up the moment a stricter CSP landed in production. Theme UI makes styling ergonomic, but it also leans on runtime style injection. That’s exactly where people get tripped up.

If you’re locking down a Theme UI app, the biggest mistakes usually come from treating CSP like a static checklist instead of something that has to match how your app actually renders styles and scripts.

Here are the mistakes I see most often, and the fixes that usually work.

Mistake #1: Blocking Theme UI’s injected styles by removing unsafe-inline

This is the classic one.

A team adds a CSP like this:

Content-Security-Policy: default-src 'self'; style-src 'self'; script-src 'self'

Then Theme UI stops applying styles correctly because Emotion, which Theme UI uses under the hood, injects styles into <style> tags at runtime.

Without allowing those styles, your app may render unstyled or partially styled.

Why it happens

Theme UI relies on Emotion’s style insertion. CSP treats inline styles and dynamically created <style> blocks as restricted unless you explicitly allow them.

A real-world CSP often looks more like this:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-Y2M2ZTJmMTYtOWMzOS00OTVkLWIwYjMtOTcxOTg5ZWVjMWU1' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'

Notice the style-src 'unsafe-inline'. That’s not there by accident. It’s often the practical compromise people make for CSS-in-JS.

Fix

If you’re using Theme UI in the default Emotion setup, the simplest fix is:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'

Is unsafe-inline for styles ideal? No. Is it common for Emotion-based apps? Absolutely.

If you want background on style-src, the official docs are worth checking: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/style-src

Mistake #2: Assuming script nonces also fix style injection

I see this misunderstanding a lot: “We added a nonce to script-src, so Theme UI should work now.”

Nope.

A nonce on scripts only affects scripts. It does nothing for styles.

This CSP is valid for scripts:

script-src 'self' 'nonce-rAnd0m123'

But Theme UI still needs style permissions. If your app injects styles and your policy says:

style-src 'self'

you’ll still break the UI.

Fix

Handle scripts and styles separately.

A practical baseline for a Theme UI app might look like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

That gets you a lot farther than trying to force one nonce strategy onto everything.

Mistake #3: Forgetting SSR style handling

Theme UI apps that use server-side rendering can fail in weird ways under CSP. You might render styles on the server, then Emotion rehydrates on the client and inserts more styles. If your CSP doesn’t allow that client-side insertion, things drift.

This gets especially messy when the initial page looks fine, but route changes or hydration cause style failures later.

Fix

Be explicit about how Emotion is used in SSR, and test both:

  • first page load
  • hydration
  • client-side navigation

If you’re building with React and Emotion cache, configure it deliberately:

/** @jsxImportSource theme-ui */
import { CacheProvider } from '@emotion/react'
import createCache from '@emotion/cache'
import { ThemeUIProvider } from 'theme-ui'
import theme from './theme'

const cache = createCache({
  key: 'theme-ui',
})

export default function App() {
  return (
    <CacheProvider value={cache}>
      <ThemeUIProvider theme={theme}>
        <div sx={{ color: 'primary' }}>Hello</div>
      </ThemeUIProvider>
    </CacheProvider>
  )
}

This alone doesn’t solve CSP, but it makes the styling path more predictable.

If you try to tighten CSP aggressively, make sure your SSR setup isn’t masking style injection problems until after hydration.

Mistake #4: Using default-src as if it covers everything

Developers love to set one directive and assume the browser fills in the rest safely.

Something like:

Content-Security-Policy: default-src 'self'

That’s a starting point, not a finished policy.

For Theme UI apps, style-src matters directly. If you use analytics, APIs, web fonts, embedded consent tools, or preview frames, those need their own directives too.

The real header example from headertest.com shows this clearly. It doesn’t rely on default-src alone. It explicitly defines:

  • script-src
  • style-src
  • img-src
  • font-src
  • connect-src
  • frame-src
  • frame-ancestors
  • base-uri
  • form-action
  • object-src

That’s the right mindset.

Fix

Start with default-src 'self', then define the rest intentionally:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

If you want deeper directive explanations, this is a decent reference: https://csp-guide.com

Mistake #5: Forgetting connect-src for app behavior and telemetry

Theme UI itself won’t usually need remote connections, but your app will.

People lock down scripts and styles, then wonder why API calls, analytics, or websocket features fail.

From the real header above, connect-src includes a bunch of domains:

connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;

That’s not noise. That’s the stuff the app actually talks to.

Fix

Open only the endpoints you really use:

connect-src 'self' https://api.example.com wss://realtime.example.com

Then add analytics or consent tooling only if needed:

connect-src 'self' https://api.example.com https://www.google-analytics.com

Don’t cargo-cult someone else’s connect-src. It gets bloated fast.

Mistake #6: Forgetting img-src data: when Theme UI uses inline image data

Theme UI components or design systems often end up using inline SVGs, base64 placeholders, or generated image data. If your policy is too strict, those break.

This bites people when avatars, placeholders, icons, or blurred previews suddenly vanish.

Fix

If your app uses data URLs for images, allow them:

img-src 'self' data: https:

That matches the real-world header example and is a pretty common setup.

If you know you never use data: images, don’t allow it. But check first. I’ve seen teams remove data: and accidentally break half their UI polish.

Mistake #7: Copy-pasting strict-dynamic without understanding it

strict-dynamic can be very useful for script loading, especially when trusted nonced scripts load other scripts. But developers copy it into policies because they saw it in a scanner result or another site’s headers.

For Theme UI, this usually has nothing to do with the actual problem. Theme UI’s pain point is almost always styles, not dynamic script trust chains.

Fix

Use strict-dynamic only if your script loading model actually benefits from it.

Example:

script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic';

That can make sense if your bootstrapped app script loads additional trusted scripts. If you don’t know why it’s there, remove it until you do.

The official CSP docs are better than random blog folklore: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src

Mistake #8: Skipping Report-Only while tightening policy

This is how teams break production.

They go from permissive to strict in one jump, deploy it, and suddenly Theme UI styles, analytics, embeds, or API calls start failing.

Fix

Roll out with Content-Security-Policy-Report-Only first:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}';
  style-src 'self';

If Theme UI depends on runtime style injection, the browser will report violations instead of breaking the page. Then you can confirm what really needs to be allowed.

Once you’ve verified the actual behavior, move to enforcement:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.example.com;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

A sane CSP starting point for Theme UI

If I were shipping a Theme UI app today and wanted a practical baseline, I’d start here:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.example.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

Then I’d add only what the app proves it needs:

  • analytics domains in script-src and connect-src
  • consent platform domains in script-src, style-src, frame-src, connect-src
  • CDN image or font hosts in img-src or font-src

That’s the real trick with CSP and Theme UI: stop trying to make it look theoretically pure, and make it accurately reflect how the app works.

If your styles are injected at runtime, pretending otherwise just gives you a broken UI and a false sense of security.