Reader Stacks

Dynamically Inserting <script> and <style> Tags in Angular

Renderer2, not direct DOM manipulation, is the correct way to insert either kind of tag — it's what keeps the operation safe for server-side rendering, where document isn't directly available.

Dynamically Inserting <script> and <style> Tags in Angular

Loading a third-party script (an embed widget, an analytics snippet) or injecting a dynamic stylesheet at runtime, rather than at build time, needs to go through Angular's Renderer2 abstraction rather than directly touching document.

Dynamically inserting a script tag

constructor(@Inject(DOCUMENT) private document: Document, private renderer: Renderer2) {}

loadExternalScript(src: string): Promise {
    return new Promise((resolve, reject) => {
        const script = this.renderer.createElement('script');
        script.src = src;
        script.onload = () => resolve();
        script.onerror = () => reject(new Error(`Failed to load script: ${src}`));

        this.renderer.appendChild(this.document.body, script);
    });
}
ngOnInit(): void {
    this.loadExternalScript('https://widget.example.com/embed.js')
        .then(() => console.log('Widget script loaded'))
        .catch(err => console.error(err));
}

Using Renderer2 and the injected DOCUMENT token, rather than the global document object directly, keeps this code compatible with server-side rendering (Angular Universal) — the raw document object isn't safely available during a server render pass, while the injected token resolves to the correct platform-appropriate implementation.

Preventing a script from being loaded more than once

private loadedScripts = new Set();

loadExternalScript(src: string): Promise {
    if (this.loadedScripts.has(src)) {
        return Promise.resolve();
    }

    return new Promise((resolve, reject) => {
        const script = this.renderer.createElement('script');
        script.src = src;
        script.onload = () => {
            this.loadedScripts.add(src);
            resolve();
        };
        script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
        this.renderer.appendChild(this.document.body, script);
    });
}

Without this tracking set, navigating to and from a component that loads the same external script repeatedly would insert multiple duplicate <script> tags — often harmless for a simple embed, but genuinely problematic for a script that registers global event listeners or initializes a widget that doesn't expect to run more than once.

Dynamically inserting a style tag

insertDynamicStyles(css: string): void {
    const style = this.renderer.createElement('style');
    const text = this.renderer.createText(css);
    this.renderer.appendChild(style, text);
    this.renderer.appendChild(this.document.head, style);
}
ngOnInit(): void {
    this.insertDynamicStyles(`
        .theme-accent { color: ${this.userThemeColor}; }
    `);
}

This is a genuinely useful pattern for a user-configurable theme color or a dynamically generated set of CSS rules that can't reasonably be expressed as static component styles known at build time.

Removing a dynamically inserted tag on component destroy

private scriptElement?: HTMLScriptElement;

ngOnDestroy(): void {
    if (this.scriptElement) {
        this.renderer.removeChild(this.document.body, this.scriptElement);
    }
}

Keeping a reference to the inserted element and removing it in ngOnDestroy() avoids leaving orphaned script or style tags in the document after the component that needed them is gone — worth doing for anything not meant to persist for the entire lifetime of the app.

Why this shouldn't be the default way to load a script

For any script actually known and needed at build time, adding it to angular.json's scripts array (or importing it as a proper npm package) is simpler and more reliable — this dynamic-insertion pattern is specifically for scripts whose need is only known at runtime, such as a widget that's conditionally shown based on a feature flag or user setting.