TypeScript
Core Concepts

Unhead renders the tags you give it. It escapes what it can, but it does not decide for you what is safe to render. This guide covers where Unhead helps, where it steps back, and what to expect under a Content Security Policy.

Untrusted input

Head data often comes from somewhere else: a CMS, a user profile, an API response. The dangerous case is passing such data as a whole head object, because an attacker who controls the JSON controls the tags:

// profile.head comes from an API the user can edit
useHead(profile.head)
unheadInstance, // { script: [{ src: 'https://evil.example.com/x.js' }] }
// โ†’ arbitrary script, exactly as the attacker wrote it

Unhead renders whatever that object contains. Filtering has to happen first. Pass untrusted input through useHeadSafe(): it keeps an allowlist of tags and attributes, drops executable scripts, and blocks URL schemes such as javascript:.

import { useHeadSafe } from 'unhead'

useHeadSafe(profile.head)
unheadInstance, // script dropped, data-* dropped, javascript: URLs rejected

For metadata your application owns, useHead() and useSeoMeta() are the right tools. Nothing extra is needed.

Escaping

During SSR, Unhead encodes attribute values and escapes title text for you. Inline content in script, style, and noscript tags is a different story: it is serialized raw.

Unhead does escape the closing tag sequence, so content cannot break out of its element. But the code itself runs exactly as written. If you put untrusted JavaScript or CSS there, it executes. Treat textContent and innerHTML on scripts and styles as code you authored yourself. The Inline Style & Scripts guide explains the parsing contexts.

Content Security Policy

Event handlers

On the client, Unhead never writes inline event handlers. Every onload, onerror, and bodyAttrs listener is attached with addEventListener(). A policy with script-src-attr 'none' produces no violations from client-side rendering.

SSR is the one exception. A function handler is serialized as a marker attribute:

<script onload="this.dataset.onloadfired = true"></script>

The marker exists so Unhead can replay a handler that fired before hydration. A strict policy blocks it and logs a violation to the console. That is the full extent of the damage: the resource still loads, and client-side handlers keep working after hydration. See DOM Event Handling.

Inline scripts you author

nonce passes through on script and style tags. Generate a fresh nonce per response, add it to the tags, and send the same value in the Content-Security-Policy header. If you use Nuxt, the Nuxt Security module can generate per-request nonces for you.

useHead({
unheadInstance,   script: [
    { textContent: 'window.dataLayer = window.dataLayer || []', nonce: requestNonce },
  ],
})

Streaming

SSR streaming injects a small inline bootstrap script plus per-chunk update scripts. The framework streaming options expose only mode. The low-level unplugin accepts a nonce, string or per-request function, but it covers just the Vite-injected client bootstrap. Streamed chunk updates carry no nonce. A nonce-only policy needs the manual template-free flow in the Streaming guide: createBootstrapScript() takes your nonce there, and you stamp the update scripts yourself.

Policy meta tag

A <meta http-equiv="content-security-policy"> tag is supported. Unhead sorts it at weight -30, ahead of most other tags. Prefer a header-based policy where you can. The meta form cannot express frame-ancestors or report-only mode.

Third-party scripts

useScript() ships conservative defaults for external scripts:

  • crossorigin="anonymous" and referrerpolicy="no-referrer" for absolute or protocol-relative URLs
  • defer and fetchpriority="low", so third-party code is less likely to hold up your critical resources. Actual scheduling still depends on the browser.

Add integrity to pin a script or stylesheet with Subresource Integrity (SRI). Generate the digest from the exact file you deploy, for example openssl dgst -sha384 -binary sdk.js | openssl base64 -A:

useScript(unheadInstance, 'https://cdn.example.com/sdk.js', {
  integrity: 'sha384-YOUR_BASE64_DIGEST',
})

To load a script only after consent, hold it back with a trigger and call load() when consent is granted:

const analytics = useScript('https://example.com/analytics.js', {
  trigger: 'manual',
})

// later, in your consent callback:
analytics.load()

Interaction triggers work well here too: load on first click or keypress rather than on page load.

Checklist

  • Filter untrusted head data with useHeadSafe()
  • Treat inline textContent and innerHTML as authored code
  • Serve inline scripts with per-response nonces
  • Add integrity hashes where the CDN supports SRI
  • Expect a console violation per SSR handler under script-src-attr 'none'. Replay is the only thing lost.

See Also

Did this page help you?