· 4 min read

Lazy Load Fonts in Next.js with Web Components

Lazy load fonts in Next.js by building a custom web component with TypeScript. Conditional font loading optimizes performance.

Learn to lazy load fonts in Next.js by building a custom web component with TypeScript. Conditionally loading fonts improves performance by preventing massive files from being served to all.

Loading multiple fonts can slow down initial page loads. When building Postnitro’s custom fonts feature, I needed a way to lazily load fonts without impacting performance.

In this post, we’ll use web components in Next.js and TypeScript to optimize font loading.

The Problem: Massive Font Files Hurting Site Speed

In Postnitro.ai, users can upload custom fonts and choose from them in a dropdown. To provide a better user experience, I wanted to show font previews in the dropdown. However, loading all the font files upfront caused painfully slow initial page loads.

My goal was to find a way to dynamically load fonts only when users needed to preview them in the font selection dropdown. This led me to web components.

Why Web Components Are Ideal for Lazy Font Loading

Web components let you build reusable, encapsulated HTML elements with JavaScript logic attached. The browser loads a web component’s code only when it encounters the custom element in the DOM.

This enables us to create a <font-previewer> component that fetches and applies fonts on demand. The font files will load only within the scope of the component.

By conditionally loading fonts through web components, we avoid the performance pitfalls of serving massive font files to every user upfront.

Step-by-Step: Building the Font Previewer Web Component

Here’s how I built the <font-previewer> element to lazily load fonts:

  • Accept font-url and font-family attributes
  • Use the FontFace API to dynamically load the font file
  • Apply the font to the component’s shadow DOM
  • Re-render when attributes change to preview different fonts

Encapsulating the font loading logic in a web component allows us to optimize performance by only loading fonts as needed for the previews in Postnitro.

Here is the complete code for the web component file (FontPreviewer.ts):

// Interface defining properties for the FontPreviewer
interface IFontPreviewer {
    'font-url': string;
    'font-family': string;
}

// Custom Element Definition
class FontPreviewer extends HTMLElement implements IFontPreviewer {

    // List of attributes to observe for changes
    static get observedAttributes() {
        return ['font-url', 'font-family'];
    }

    // Private variables holding the values of the custom element's attributes
    private _fontUrl: string = '';
    private _fontFamily: string = '';

    constructor() {
        super();
        // Attaching a shadow root to the custom element
        this.attachShadow({mode: 'open'});
    }

    // Getter and setter for font-url attribute
    get 'font-url'() {
        return this._fontUrl;
    }

    set 'font-url'(val: string) {
        this._fontUrl = val;
    }

    // Getter and setter for font-family attribute
    get 'font-family'() {
        return this._fontFamily;
    }
    set 'font-family'(val: string) {
        this._fontFamily = val;
    }

    // Lifecycle method called when the custom element is added to the DOM
    connectedCallback() {
        this.render();
    }

    // Lifecycle method called when an observed attribute changes
    attributeChangedCallback(name: string, oldValue: string, newValue: string) {
        if (oldValue !== newValue) {
            // Update the property and re-render the custom element
            (this as any)[name] = newValue;
            this.render();
        }
    }

    // Render method
    render() {
        // Create a new FontFace object and load the font
        const fontFace = new FontFace(this['font-family'], `url(${this['font-url']})`, {
            style: 'normal', unicodeRange: 'U+000-5FF', weight: '400'
        });

        fontFace.load().then((loaded_face) => {
            // Add the loaded font to the document fonts
            document.fonts.add(loaded_face);
            // Fill the shadow root with the custom element's template
            if(this.shadowRoot) {
                this.shadowRoot.innerHTML = `
                <style>
                    ::slotted(*), div {
                        font-family: '${this['font-family']}', sans-serif;
                        font-size: 18px;
                    }
                </style>
                <slot><div>${this['font-family']}</div></slot>
                `;
            }
        }).catch(function(error) {
            // Log any error occurred during the font loading
            console.log("Failed to load font: " + error);
        });
    }
}

// Register the custom element with the browser
if (!window.customElements.get('font-previewer'))
    window.customElements.define('font-previewer', FontPreviewer);

Using the Web Component in a Next.js App

To use the <font-previewer> element in a Next.js app:

1. Import the .ts file:

Importing the web component into your Next.js application might trigger an error: ReferenceError: HTMLElement is not defined.

This stitches from the fact that web components don’t traditionally support SSR. However, this issue can be effectively catered to by importing the component in the following manner:

React.useEffect(() => {  
  import("@/web_components/FontPreviewer.ts").then((FontPreviewer) => {  
        //Do something with FontPreviewer (Optional)
  });  
}, [])

2. Use it in your JSX:

<font-previewer   
	font-url="https://example.com/font.otf"   
	font-family="Custom Font Name">
	Text preview
</font-previewer>

The <font-previewer> element will take care of fetching the font file and applying it to the inner content.

3. Rectify TypeScript Error:

When you use TypeScript with JSX, it tries to check every element you use to guard against typos. This is a problem when you’ve just registered a component that of course doesn’t exist in the normal DOM:

Property 'font-previewer' does not exist on type 'JSX.IntrinsicElements'.ts(2339)

Luckily, the solution to this problem already exists. By means of declaration merging, TypeScript can be extended:

// declarations.d.ts - place it anywhere, this is an ambient module
declare global {
	namespace JSX {  
	    interface IntrinsicElements {  
	        'font-previewer': React.DetailedHTMLProps<  
	            React.HTMLAttributes<HTMLElement>,  
	            HTMLElement  
	  >;  
	    }  
	}
}

Key Takeaways

  • Web components enable lazy font loading to optimize Next.js performance
  • The <font-previewer> element encapsulates font logic away from the app
  • Conditional loading prevents serving massive fonts to every user
  • Reusable web components create efficient, optimized UI widgets

Let me know if you need any other clarifications or have additional feedback!

Seerat Awan

Tech Enthusiast | Frontend Developer

@seeratawan01

Subscribe to my newsletter to get the latest updates on my blog.

Back to Blog