Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 4 min read

Strict-CSP brand theming and build budgets in Astro (no inline styles, fail the build at 50 KB of JS)

A few days ago I wrote about the config-driven Astro + Tailwind 4 template I use for small-business sites. Gautam K left a great comment under it. Roughly: "Your brand colours are injected with an inline style on <html>,

A few days ago I wrote about the config-driven Astro + Tailwind 4 template I use for small-business sites. Gautam K left a great comment under it. Roughly: "Your brand colours are injected with an inline style on <html>, which breaks under a strict CSP. Emit them as a stylesheet from an endpoint instead, and put a performance budget in the build." In a follow-up he added a caching tip: if the file name isn't hashed, keep its cache short.

He was right on both counts, so v1.1 does exactly that. Here's the whole change. It's small, and you can copy it into any Astro site.

The problem: theming via style=""

v1.0 read the colours from src/data/site.ts and did this in the layout:

---
const t = site.theme;
const brandVars = `--brand-primary:${t.primary};--brand-accent:${t.accent};`;
---
<html lang="en" style={brandVars}>

It's convenient, but a policy like style-src 'self' (no 'unsafe-inline') blocks inline style attributes. You either weaken your CSP or lose your brand colours.

Fix 1: emit /brand.css from an Astro endpoint

Astro endpoints don't have to return HTML or JSON. In a static build, src/pages/brand.css.ts becomes a real file, dist/brand.css, generated from the same config:

// src/pages/brand.css.ts
import type { APIRoute } from 'astro';
import { site } from '../data/site';

const t = site.theme;
// Only allow plain colour tokens, so a typo in the config can't inject CSS.
const safe = (v: string, key: string) => {
  if (!/^[#a-zA-Z0-9(),.%\s/-]+$/.test(v)) throw new Error(`site.theme.${key} is not a valid CSS colour: ${v}`);
  return v.trim();
};

export const GET: APIRoute = () => new Response(
  `:root {
  --brand-primary: ${safe(t.primary, 'primary')};
  --brand-primary-dark: ${safe(t.primaryDark, 'primaryDark')};
  --brand-accent: ${safe(t.accent, 'accent')};
  --brand-dark: ${safe(t.dark, 'dark')};
}
`,
  { headers: { 'Content-Type': 'text/css; charset=utf-8' } },
);

The layout links it like any other stylesheet:

<link rel="stylesheet" href="/brand.css" />

Tailwind 4 still sees the colours as utilities, because @theme inline only references the variables:

@theme inline {
  --color-primary: var(--brand-primary);
  --color-accent: var(--brand-accent);
}

bg-primary, text-accent and the rest keep working. Change a colour in site.ts, rebuild, done.

I also moved the last inline style (a decorative hero gradient) into a .hero-glow class, and told Astro and Vite never to inline anything:

// astro.config.mjs
export default defineConfig({
  build: { inlineStylesheets: 'never' },
  vite: { build: { assetsInlineLimit: 0 } },
});

Small component scripts now ship as /_astro/*.js files instead of inline <script> blocks, so script-src 'self' works as well.

Fix 2: cache headers (Gautam's follow-up)

/brand.css has a fixed name, so a long cache would keep showing old colours after a rebrand. Give it a short cache and keep the hashed assets immutable:

# netlify.toml
[[headers]]
  for = "/brand.css"
  [headers.values]
    Cache-Control = "public, max-age=300, must-revalidate"

[[headers]]
  for = "/_astro/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

The template ships the same rules in vercel.json, plus a strict policy you can start from:

default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:;
font-src 'self'; frame-src https://www.google.com; base-uri 'self'; object-src 'none'

(frame-src is there for the optional Google Maps embed on the contact page.)

Gautam's alternative works too: import the generated CSS from the layout so Vite bundles it under a hashed name (with a small virtual-module plugin, for example). That way it gets the long immutable cache. I kept the endpoint because the config stays the single source of truth and there's no plugin to maintain. With a 5-minute cache, the cost is one cheap revalidation.

Fix 3: a build budget that actually fails the build

Lighthouse scores drift one "small" dependency at a time. So npm run build now runs a check after astro build:

"build": "astro build && node scripts/check-budget.mjs"

scripts/check-budget.mjs walks dist/ and exits with code 1 when:

  • total JavaScript is over 50 KB (raw bytes, which is stricter than gzip)
  • total font files are over 150 KB
  • any HTML page contains an inline <script> (JSON-LD is allowed), a <style> block, a style="" attribute or an on…= handler. Those would break the CSP above.

Output on the template today:

Build budget (dist/, raw bytes)
βœ“ JS         0.6 KB / 50 KB  (1 file)
βœ“ Fonts      0.0 KB / 150 KB  (0 files)
βœ“ CSP    no inline scripts, styles or handlers

When something goes over, it prints the five biggest offenders so you know what to cut. Limits are env vars (BUDGET_JS_KB=80 BUDGET_FONTS_KB=200 npm run build), so raising one is a deliberate act, not an accident. And because it's just npm run build, Netlify, Vercel and Cloudflare Pages all fail the deploy for free.

Did it work?

I served dist/ locally with the strict CSP header above and loaded every page in headless Chrome. There were no CSP violations, and the brand colours rendered correctly from /brand.css.

Get it

Thanks again to Gautam K for the review. Comments like that make the template better for everyone. If you have a better way to do CSP-safe theming in Astro, I'd love to hear it below.

I used AI assistance while writing the code and this post; I reviewed and tested everything myself.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.