Ibadehin Mojeed I'm an advocate of project-based learning. I also write technical content around web development.

Next.js font optimization: Adding custom and Google fonts

12 min read 3523

Next Js Font Optimization Adding Custom And Google Fonts

How we load fonts in our web project can make or break the user’s experience. With system fonts, we are guaranteed that text will render at the earliest possible time. But we may not desire these font types, and more importantly, they are not consistent between operating systems.

Web fonts, on the other hand, allow us to maintain a consistent look. Adding web fonts can be a simple task. However, it can also become tedious if we care about performance optimization.

One issue associated with web fonts that are hosted with font delivery services like Google Fonts is the cost of external network requests. While we can greatly reduce this incurred cost by self-hosting the fonts, we also have the issue of layout shift to deal with.

Next.js v13 introduced a font system called @next/font to help abstract the complexity of optimizing fonts. This lesson covers how to use this font system to add custom and Google fonts in a Next.js project as well as to optimize the font loading experience.

Jump ahead:

See the demo project here. You can interact with the project to see how page elements render different fonts. Here is the project source code.

Now, let’s dive in.

Adding fonts in Next.js

Adding web fonts like Google Fonts in a Next.js application can be as simple as embedding <link> tags generated from the font delivery service into the Head component of the pages/_document.js file.

Create your project

If you don’t have a Next.js project, start by creating one with the following command:

npx [email protected] nextjs-fonts 
cd nextjs-fonts

Then, run your build process with npm run dev.

Now, add the following Home component in the pages/index.js file:

export default function Home() {
  return (
    <div>
      <h1>Home page</h1>
    </div>
  );
}

Let’s implement a variable font called “Dancing Script” from Google Fonts by adding <link> tags provided by the font delivery service into the Head component of the pages/_document.js file like so:

<Head>
  {/* preconnect scripts... */}
  <link
    href="https://fonts.googleapis.com/css2?family=Dancing+Script:[email protected]&display=swap"
    rel="stylesheet"
  />
</Head>

After that, we will update the font family in the styles/globals.css:

body {
  /* ... */
  font-family: 'Dancing Script', cursive;
}

The output looks like so:

Browser Opened To Localhost In Light Mode With Text In Dancing Script Font Reading Home Page

That is easy, right? Let’s see the problem with this implementation in the next section.

The font rendering process

Ideally, when a web font is applied to a web document, the browser will start downloading the font after parsing the HTML. This happens right before the text is rendered.

While we expect the font to load and render quickly, that is not always the case. It may take some extra seconds for the fonts to load, especially when it involves an external request, as is the case with Google Fonts.

See the request URL below:

Browser Developer Pane Open In Dark Mode With Red Arrow Pointing To External Request Url For Font Rendering

So, pending the completion of the downloading font, the browser falls back to using the system font.



As we have seen with Google Fonts, the display=swap parameter in the URL will trigger a font-display: swap that instructs the browser to use a fallback font. After the web fonts has completely downloaded, it then swaps fonts.

That switching moment can cause a shift in the text layout when the font is changed. This effect is called a flash of unstyled text, otherwise called FOUT.

There are three strategies we could use to improve the loading experience:

  1. Eliminate the external network request by serving the fonts locally from our domain
  2. Use font-display: optional to prevent layout shift while also preloading the fonts
  3. Use the CSS size-adjust property to reduce layout shifts when switching between fonts

Though we can manually implement these optimization strategies, we will take a look at how Next.js 13 makes the process easier with the @next/font system.

How the @next/font system helps with font optimization in Next.js

The @next/font system is one of many powerful features introduced in Next.js 13. This font system significantly simplifies font optimization. It automatically self-hosts any Google Fonts by including them in deployment alongside other web components like HTML and CSS files.

Since no external request is involved by the browser, it results in a faster load time. It implements a strategy that prevents layout shift or significantly reduces the impact using the CSS size-adjust property.

Install @next/font

The @next/font comes as a separate package in Next.js so it doesn’t inflate the core. Let’s install it in our project:

npm install @next/font

Please ensure that you’re using Next.js v13 or newer.

Adding Google Fonts with @next/font

Web fonts, including those from Google Fonts, can either be variable fonts or non-variable fonts. A variable font has the advantage of integrating many variations of a typeface into a single file. Next.js recommends this type of font over the non-variable type. So, let’s start with that.

Adding font on a single page

Dancing Script from Google Fonts is a type of variable font. If you added this font to your project earlier when we went over the <link> tags method, make sure you remove it before proceeding.

Now, let’s load Dancing Script using the @next/font system. In the pages/index.js file, let’s import the font from @next/font/google as a function and create a new instance of it:

import { Dancing_Script } from '@next/font/google';
const dancingScript = Dancing_Script();
console.log(dancingScript);

export default function Home() {
  return (
    // ...
  );
}

In the code, we assigned the font instance to a dancingScript variable and logged it to see what it returns. If we save the file and reload the page, we should see the returned object in the browser developer tools panel as well as the terminal:

Browser Developer Tools Panel Showing Returned Object For Dancing Script Variable

The font loader returns an object containing options that we can use to apply the font style.

Applying font styles

As seen above, the returned object contains the className property and a style object that we can use to apply the font. Using the className, we can apply a font to an element like so:

export default function Home() {
  return (
    <div>
      <h1 className={dancingScript.className}>Home page</h1>
    </div>
  );
}

We can save the file and restart the dev server.

If we check the frontend, we should see the font applied. However, due to how Google Chrome behaves, a fallback font may apply instead.

For optimization purposes, every font has a font-display property applied to it. By default, the value applied to fonts from the @next/font system is font-display:optional. This means if the font is not available within a short period, a fallback font will be used and no swapping will occur.

We could specify a value font-display:swap to switch to the font as soon as it finishes loading, like so:

const dancingScript = Dancing_Script({ display: 'swap' });

This should work. However, in some cases, we might notice a slight shift.

For that reason, we will instead use the default display: optional property while also preloading the font so we can omit the shift. This method produces the most effective result.

To preload the font, we must subset it to the required characters — for instance, latin. So, let’s add a subset via the subsets option:

const dancingScript = Dancing_Script({ subsets: ['latin'] });

This option will strip unrequired languages from the web font and ensures that a preload link for the required one is added in the <head> element. If we save the file, we should see the injected link:

Head Element In Html File With Red Arrow Pointing To Injected Preload Link For Dancing Script Font

The preload hints to the browser that it should schedule the font for early download, and Next.js will ensure the resource is available to be used.

With this addition, the font should now also apply in the Chrome browser. The image below also shows that the request is within our infrastructure and not from a delivery service:

Browser Developer Tools Panel With Red Arrow Pointing To Updated Request Url Showing Request Now Comes From Within Infrastructure

Bear in mind that sometimes, the default display: optional in @next/font can cause issues in the Chrome browser. Fonts applied may revert to fallback after navigating away until we manually refresh the chrome tab. We will discuss this issue later in this lesson and learn how to overcome it.

Adding multiple fonts

Adding multiple Next.js fonts using @next/font is as simple as defining multiple font instances and applying the auto-generated classes to the document element:

import { Dancing_Script, Oswald } from '@next/font/google';

const dancingScript = Dancing_Script({ subsets: ['latin'] });
const oswald = Oswald({ subsets: ['latin'] });

export default function Home() {
  return (
    <div className={oswald.className}>
      <h1 className={dancingScript.className}>Home page</h1>
      <p>This paragraph uses another font</p>
    </div>
  );
}

In the above code, we imported an Oswald variable font from Google and applied it to the wrapper element to make it available for the children elements. The paragraph within the wrapper will inherit the font. Meanwhile, the font on the heading element will override the wrapper font.

See the output below:

Browser Open To Localhost With Title Text In Dancing Script Font Reading Home Page Above Paragraph Text In Oswald Font. Developer Tools Panel Is Open With Red Arrow Pointing To Overridden Wrapper Font In H1

Using the style prop

We can also use the style option returned from the font loader to apply font via the style attribute. The following code uses the style option to apply a font to the wrapper element:

export default function Home() {
  return (
    <div style={oswald.style}>
      <h1 className={dancingScript.className}>Home page</h1>
      <p>This paragraph uses another font</p>
    </div>
  );
}

The above code will give us the same result as before.

Non-variable fonts

Unlike their variable counterparts, a non-variable font will require a weight option. The weight can be a string or an array of values.

An example of a non-variable font is Merriweather, which is also available through Google Fonts. Let’s start by importing it:

import {
  // ...
  Merriweather,
} from '@next/font/google';

Then, add a required weight option to the font instance and apply the generated class to the document element:

const merriweather = Merriweather({
  weight: ['300', '400', '700'],
  style: ['normal', 'italic'],
  subsets: ['latin'],
});

export default function Home() {
  return (
    // ...
    <p className={merriweather.className}>
      This text uses a non variable font
    </p>
    // ...
  );
}

We’ve also added some optional style values in the font instance. Save the file and the font should apply.

Adding a global font style

To apply a font throughout the entire web document, we will import the font in the pages/_app.js file and pass the generated class to the Component wrapper element, like so:

import { Raleway } from '@next/font/google';
const raleway = Raleway({ subsets: ['latin'] });

function MyApp({ Component, pageProps }) {
  return (
    <div className={raleway.className}>
      <Component {...pageProps} />
    </div>
  );
}

export default MyApp;

In the code above, we imported a Raleway font, which is also a variable font. So, adding a weight option in the configuration is optional.

Injecting fonts in the <head>

Another way to apply font across the document is by injecting the font in the <head>. In the pages/_app.js file, we can use the <style> tag before the Component to apply a global font:

function MyApp({ Component, pageProps }) {
  return (
    <>
      <style jsx global>{`
        html {
          font-family: ${raleway.style.fontFamily};
        }
      `}</style>
      <Component {...pageProps} />
    </>
  );
}

export default MyApp;

Remember, we have access to the font family from the style object.

Declaring global font with CSS variables syntax

The syntax for CSS variables provides the flexibility of declaring multiple fonts in a stylesheet. The following code imports two different fonts in the pages/_app.js file, which we then used to declare global CSS variables:

import { Raleway, IBM_Plex_Sans } from '@next/font/google';

const raleway = Raleway({ subsets: ['latin'] });
const ibmSans = IBM_Plex_Sans({
  weight: '700',
  subsets: ['latin'],
});

function MyApp({ Component, pageProps }) {
  return (
    <>
      <style jsx global>{`
        :root {
          --raleway-font: ${raleway.style.fontFamily};
          --ibmSans-font: ${ibmSans.style.fontFamily};
        }
      `}</style>
      <Component {...pageProps} />
    </>
  );
}

export default MyApp;

The :root pseudo-class in the code will select the root element — i.e., the <html>.

Now, we can apply the CSS variables in a CSS file. The below code applies the variables in a styles/globals.css file:

body {
  font-family: var(--raleway-font);
}

h1, h2, h3 {
  font-family: var(--ibmSans-font);
}

The variable key option

While creating a font instance, we can also add a variable key that lets us declare a CSS variable. This option lets us easily use fonts with Tailwind CSS and CSS modules without using the global <style> syntax.

The following code declares a CSS variable called --antonio-font via the variable key option:

const antonio = Antonio({
  subsets: ['latin'],
  variable: '--antonio-font',
});

If we log the result, we should see the returned object containing a variable property that will let us access the font:

Browser Developer Tools Panel Showing Returned Object For Antonio Font Variable

We can now apply the variable name via a className attribute on a container element. If we declare a global font in the pages/_app.js file, the Component wrapper should look like so:

<div className={antonio.variable}>
  <Component {...pageProps} />
</div>

From here, we can use the font with Tailwind — as you will see later in the lesson — or with CSS modules. For CSS modules, let’s say we have an About page component that uses this module, like so:

import styles from './About.module.css';

const About = () => {
  return (
    <div>
      <h1>About page</h1>
      <p className={styles.text}>This is about page content</p>
    </div>
  );
};

export default About;

We can use the CSS variable that we declared earlier to access the font in the About.module.css file, like so:

.text {
  font-family: var(--antonio-font);
  font-style: italic;
}

Adding custom fonts in Next.js

In some scenarios, the font we want to use in Next.js may not be from Google Fonts. Instead, it could be a custom font that we created, a font that we bought, or one that we downloaded from another font site.

We can use any of these fonts locally by configuring them using the @next/font/local. In this section, we will demonstrate how to do so by downloading a font called Cooper Hewitt from Font Squirrel.

The Cooper Hewitt font doesn’t come in the more modern and well-compressed .woff2 format. As a result, our first step is to convert it using the Font Squirrel Webfont Generator. After that, we will add the bold and normal weight variations of the font in the public/fonts folder of our project:

Project File Structure With Public Folder Open To Show Fonts Subfolder, Which Is Also Open And Contains Two Cooper Hewitt Font Variations

Using the locally configured fonts

Like when using Google Fonts with Next.js, we can load the local fonts across the entire document or for a specific page. To load the local fonts sitewide, we will import the font loader from @next/font/local in the pages/_app.js file:

import localFont from '@next/font/local';

After that, we will define a new instance where we specify the src of the local font files as an array of objects:

const cooper = localFont({
  src: [
    {
      path: '../public/fonts/cooperhewitt-book-webfont.woff2',
      weight: '400',
    },
    {
      path: '../public/fonts/cooperhewitt-bold-webfont.woff2',
      weight: '700',
    },
  ],
});

Each of the objects represents the font for a specific weight. If we use a variable font, we can instead specify the src as a string, like so:

const variableFont = localFont({ src: '../public/fonts/my-variable-font.woff2' });

From this point, how we apply the font styles is the same as that of the method used for Google Fonts discussed earlier. We can use either the className syntax, style, or CSS variables.

Let’s declare a CSS variable for the local font globally with the <style> syntax:

function MyApp({ Component, pageProps }) {
  return (
    <>
      <style jsx global>{`
        :root {
          /* ... */
          --cooper-font: ${cooper.style.fontFamily};
        }
      `}</style>
      <Component {...pageProps} />
    </>
  );
}

We can now use the --cooper-font CSS variable in the components stylesheet. Let’s say we have a Contact page component, like so:

import styles from './Contact.module.css';

const contact = () => {
  return (
    <div className={styles.contact}>
      <h1>Contact page</h1>
      <p>This is contact content uses a local font</p>
    </div>
  );
};

export default contact;

We can use the CSS variable to access the font in the Contact.module.css file, like so:

.contact > *{
  font-family: var(--cooper-font);
}

Adding fonts to Next.js with Tailwind CSS

To use the @next/font package with Tailwind CSS, we will use the CSS variable syntax. Fortunately, we already have declared a couple of CSS variables in the pages/_app.js file.

Recall how earlier, we injected the following in the <head>:

<style jsx global>{`
  :root {
    --raleway-font: ${raleway.style.fontFamily};
    --ibmSans-font: ${ibmSans.style.fontFamily};
    --cooper-font: ${cooper.style.fontFamily};
  }
`}</style>

We also defined a CSS variable name with the variable option like so:

const antonio = Antonio({
  subsets: ['latin'],
  variable: '--antonio-font',
});

We can use any of these CSS variable declarations to add Next.js fonts with Tailwind CSS. Let’s see how in the next section.

Configuring tailwind.config.js file

We will add the CSS variables as a font family in the Tailwind CSS configuration file:

const { fontFamily } = require('tailwindcss/defaultTheme');

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './pages/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  theme: {
    extend: {
      fontFamily: {
        antonio: ['var(--antonio-font)', ...fontFamily.sans],
        ibm: ['var(--ibmSans-font)', ...fontFamily.sans],
        cooper: ['var(--cooper-font)', 'ui-serif', 'Georgia'],
      },
    },
  },
  plugins: [],
};

We can now use font-antonio, font-ibm, and font-cooper utility class names to apply the fonts. A component that implements these utilities will look like so:

const Tailwind = () => {
  return (
    <div>
      <h1 className="font-ibm">With Tailwind CSS</h1>
      <p className="font-antonio">This is a first paragraph</p>
      <p className="font-cooper">This is a second paragraph</p>
    </div>
  );
};

export default Tailwind;

An issue with display: optional in the Chrome browser

You may encounter a Chrome issue with using the default display: optional in a single page. This issue causes elements to revert to fallback fonts after navigating away to reload other pages. See the demo below:

Browser Opened To Localhost 3000 In Light Mode With Menu Bar And Four Lines Of Text In Varying Fonts. User Shown Navigating To Different Page In Menu Bar, Refreshing Page, Then Returning To Home Page, Where Fonts Have Reverted To Fallback Fonts. User Then Refreshes Home Page To Reapply Proper Web Fonts

As seen in the GIF above, the fonts applied to the home page elements reverted to fallback fonts after manually refreshing other pages. We had to again refresh the home page to reapply the proper web fonts.

One way to solve this issue is to use display: 'swap' in the font instance instead of the default. However, as we already know, swapping the font could cause a noticeable layout shift.

What I recommend instead is moving the font instance into the pages/_app.js file and using either the className property, style object, or CSS variables to apply the fonts on any page.

For our project, we will move the font declaration from the pages/index.js file into the pages/_app.js file and declare CSS variables for the font family, like so:

<style jsx global>{`
  :root {
    /* ... */
    --oswald-font: ${oswald.style.fontFamily};
    --merriweather-font: ${merriweather.style.fontFamily};
    --dancingScript-font: ${dancingScript.style.fontFamily};
  }
`}</style>

We can now refactor the pages/index.js file to use CSS modules, like so:

import styles from './Home.module.css';

export default function Home() {
  return (
    <>
      <div className={styles.oswald}>
        <h1 className={styles.dancingScript}>Home page</h1>
        <p className={styles.merriweather}>
          This text uses a non variable font
        </p>
        <p>This paragraph uses another font</p>
      </div>
      <p>This text should take global styles</p>
    </>
  );
}

Then, use CSS variables that we declared to access the fonts in the Home.module.css file, like so:

.oswald {
  font-family: var(--oswald-font);
}
.dancingScript {
  font-family: var(--dancingScript-font);
}
.merriweather {
  font-family: var(--merriweather-font);
}

If we save all files, the fonts should now apply as expected on Chrome as they do in other browsers.

Conclusion

As we have seen in this lesson, the @next/font system introduced in Next.js v13 simplifies font optimization by abstracting its complexity. We have used this system to add both custom and Google Fonts font families in a Next.js application.

In our demo project, we added multiple fonts to help demonstrate the various methods for using fonts in Next.js. However, in a production site, we should consider minimizing the number of fonts to preload.

I hope you enjoyed this lesson. If you have questions or contributions, share your thoughts in the comment section. See the complete source code for the project on GitHub.

LogRocket: Full visibility into production Next.js apps

Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next.js app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your Next.js apps — .

Ibadehin Mojeed I'm an advocate of project-based learning. I also write technical content around web development.

Leave a Reply