Third-party scripts
The <Script> component — declared loading strategies (afterInteractive, lazyOnload), process-wide dedupe, remount-safe onLoad, behavior per interactive mode, and the CSP story for client-injected tags.
<Script> loads third-party scripts declaratively instead of hand-rolled
useEffect + createElement('script') blocks — with a decided answer for
every mode the page can be in.
import { Script } from '@voltro/web'
// Analytics after hydration (the default strategy):
<Script
src="https://eu.i.posthog.com/static/array.js"
onLoad={() => {
// The hand-written PostHog browser snippet — @voltro/plugin-posthog is a
// SERVER-side track sink and ships no browser snippet; this is the
// client half, wired the way PostHog's docs describe.
const w = window as { posthog?: { init: (key: string, opts: { api_host: string }) => void } }
w.posthog?.init('phc_your_project_key', { api_host: 'https://eu.i.posthog.com' })
}}
/>
// GTM bootstrap — the inline variant (id is REQUIRED: it is the dedupe key):
<Script id="gtm-init">{`window.dataLayer = window.dataLayer || []`}</Script>
// A chat widget nobody needs before the browser is idle:
<Script src="https://widget.example.com/loader.js" strategy="lazyOnload" />Strategies
afterInteractive(default) — injected after this component mounts, i.e. after hydration. Never render-blocking; the shell head stays clean.lazyOnload— waits for browser idle (requestIdleCallback, with asetTimeoutfallback for Safari).
There is no beforeInteractive. The honest alternative for a
must-run-first script (a consent manager) is a literal <script> tag in the
shell head — a <link rel="preload"> is not an answer: it fetches but never
executes. And no worker strategy (the Partytown class is its own decision).
Dedupe + remount semantics
Scripts deduplicate process-wide by src (external) or id (inline) —
the registry is global, so two <Script> tags for one widget produce one
request and one execution, even across an islands page's separate bundle.
A script is never unloaded. Navigate away and back and the script does
not re-execute and nothing is re-fetched — but onLoad fires again,
answered from the registry (the classic next/script bug where a remounted
component's onLoad never fires is pinned by test here). onError behaves
the same for a failed load.
Behavior per interactive mode — decided, not accidental
full— as described above.none— zero-JS means zero: the app bundle never ships, so a<Script>can never inject. The build warns by name instead of silently doing nothing.islands— outside an island nothing mounts, so a<Script>in the page's static part never fires; the build warns. Inside an island it runs when that island hydrates — avisibleisland's script loads when it scrolls into view, which is often exactly the lazy behavior you want.
CSP
<Script> injects client-side, so the per-request nonce your middleware
mints (CSP nonces) is stamped into
server-rendered tags — not into tags created in the browser. The component's
answer:
- An explicit
nonceprop always wins. - Otherwise the injector propagates the document's own nonce (read off an existing nonce'd script element). On an SSR page under a nonce'd CSP the injected tag therefore carries the request's nonce automatically.
- On a
staticpage there is no per-request nonce path at all — the documented options are'strict-dynamic'(scripts injected by an allowed/nonce'd bootstrap are permitted, which is exactly this shape) or a hash-based policy.
The inline variant is covered by the same rules — under a strict CSP an
inline snippet needs the nonce or 'strict-dynamic' like any other injected
script.