DEV Community

Cover image for Implement Local & Google Fonts in Next.js (The Right way): A Practical Pattern Guide
Achmad Muqorrobin
Achmad Muqorrobin

Posted on • Originally published at Medium

Implement Local & Google Fonts in Next.js (The Right way): A Practical Pattern Guide

This is the most complete and practical guide I’ve found myself wishing existed when working with Next.js fonts. So, bear with me till the end – this is gonna be fun and practical.

This article consists of two parts: PART I focuses on implementation, and PART II covers patterns. Let’s dive in.

TL;DR

  • Use next/font/local for local fonts and next/font/google for Google Fonts.
  • className applies fonts immediately and has higher specificity.
  • variable creates CSS variables and must be explicitly used in CSS.
  • Always match font weight and style correctly to avoid Tailwind conflicts.
  • Use variable when managing multiple fonts in large projects.
  • With Tailwind v4, prefer @utility for better DX and IntelliSense.
  • Centralize font definitions to keep app/layout.tsx clean and scalable.

Table of Contents

  • PART I – Implementation
    • Chapter 1: Setup
    • Chapter 2: Usage
    • Chapter 3: Arbitrary Values
    • Chapter 4: Multiple Local Fonts
  • PART II – Pattern
    • Chapter 5: Multiple Fonts (Local + Google Fonts)

PART I - IMPLEMENTATION

Of course you can implement this tutorial within your current running projects and you don’t have to create new next.js projects. but, for the sake of simplicity I will generate new next.js project, feel free to skip to Chapter 2 for direct implementation. nevertheless, if you willing to follow through then let’s dip in from Chapter 1, the setup.

Chapter 1 - Setup

First things first, prepare the local fonts. I’ve prepared the fonts, there are two fonts I’m about to use, lufga and SF Pro Text. though it doesn’t matter what fonts you are going to use.

Add your fonts inside your /app folder or if you configure next.js with src folder, then place it inside the /src folder.

Remember, you don’t have to place fonts inside the /public folder because doing so makes them publicly accessible assets (you don’t want users deliberately stealing your custom fonts, do you?).

furthermore, Next.js itself has a built-in optimization for fonts. so, technically you can place the folder anywhere you want as long as you call your fonts file precisely.

Then proceed to call the local fonts. you can implement directly to app/layout.ts

import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

// Start of: Implementing Local Font
const sfProText = localFont({
  src: [
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Light.otf",
      style: "light",
      weight: "300",
    },
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Regular.otf",
      style: "normal",
      weight: "400",
    },
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Medium.otf",
      style: "medium",
      weight: "500",
    },
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Semibold.otf",
      style: "semibold",
      weight: "600",
    },
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Bold.otf",
      style: "bold",
      weight: "700",
    },
  ],
  variable: "--font-sf-pro",
});
// End of: Implementing Local Font

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

Enter fullscreen mode Exit fullscreen mode

Note: Because I’m installing next.js from scratch then this is the default setup derived from next.js, the only addition I made are from “Start of: Implementing Local Font” to “End of: Implementing Local Font”


Now, you noticed that inside the localFont configuration, I added path, style, and weight inside the src with variable adjacent to it.
Let’s break down:

  • path defines the location of your local font files. This is important, and make sure you specify it correctly. if you choose to filled in arbitrary values then it would output with inappropriate values as I show you in Chapter 3 later.
  • style and weight are related, make sure you specify accordingly. if not, it would be break the fonts implementations and confuse you along development (I’ll show you in Chapter 3). the easiest standard you can refer to this tailwind docs. I’ll show you how utilize it correctly.
  • variable is formed by css variables rules. This means you are naming the font implementation using that variable name (in my case —font-sf-pro) that can be use inside css files when necessary.

Chapter 2 - the usage

before you can see the changes, better check if the fonts load correctly by log it.
add this code

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  console.log("sfprotext: ", sfProText);
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

if you see the image like below in your console then you have done the right way.

 Because layout.ts is a Server Component, the console.log output will appear in the server terminal.


with all this setup, let’s try to check out first the font in browser. try add this text in app/page.tsx.

export default function Home() {
  return (
    <div>
      <h1>Text</h1>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

see the result in the browser, then it should look like this.


Notice that the local font we just imported doesn’t render in browser yet. now we can implement it in two methods, inject variable or classname. You can pick one of these method that suits with your projects.

Method 1: Classname

with this method you can just inject the classname property provided by localFont. adjust app/layout.tsx like below

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${sfProText.className}`}>{children}</body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

with this change, next.js automatically prioritize localFont. see results in browser it should look like this


That’s it. Its already implemented.

Method 2: Variable

Second approach is use the variable, the variable lies inside the localFont definitions variable: "--font-sf-pro".

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${sfProText.variable}`}>{children}</body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

now try to see the font in browser.


wait, what’s happening? why wouldn’t the font change appropriately?

This happens because the variable option truly acts as a CSS variable.. The browsers do not execute those variable immediately unlike when you trying to inject className, you should add it’s name to css file you are intended to. for example, if you want to implement this font to all your project and make it as default font then add it to global.css.

@import "tailwindcss";

body {
  font-family: var(--font-sf-pro); 
}

Enter fullscreen mode Exit fullscreen mode

then see the changes in the browser.


Oh look, that’s finally changed.

Up until this point, you might be wondering “why I should choose this method 2 using variable when using className reduce extra steps adding css hassle”. Yup, you’re right, but you will see the benefit of using variable when it comes to multiple local fonts involved. I’ll show you on chapter 4.

⚠️ Chapter 3 - Arbitrary values (Important)

in this chapter I show you when you fill the value of style and weight in localFont. recall this localFont

// Start of: Implementing Local Font
const sfProText = localFont({
  src: [
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Light.otf",
      style: "light",
      weight: "300",
    },
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Regular.otf",
      style: "normal",
      weight: "400",
    },
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Medium.otf",
      style: "medium",
      weight: "500",
    },
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Semibold.otf",
      style: "semibold",
      weight: "600",
    },
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Bold.otf",
      style: "bold",
      weight: "700",
    },
  ],
  variable: "--font-sf-pro",
});

// End of: Implementing Local Font
Enter fullscreen mode Exit fullscreen mode

try to set arbitrary values in one of those source. suppose I switch the bold version with normal version like this:

const sfProText = localFont({
  src: [
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Light.otf",
      style: "light",
      weight: "300",
    },
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Regular.otf", 
      style: "bold", // change this
      weight: "700", // change this
    },
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Medium.otf",
      style: "medium",
      weight: "500",
    },
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Semibold.otf",
      style: "semibold",
      weight: "600",
    },
    {
      path: "../app/fonts/sfprotext/SF-Pro-Text-Bold.otf",
      style: "normal", // change this
      weight: "400", // change this
    },
  ],
  variable: "--font-sf-pro"
});
Enter fullscreen mode Exit fullscreen mode

You see I’ve already switch the style and weight for Bold and Normal font. look what happened in browser right now.

See? the browsers assume it’s normal font but what it comes it’s bold font, it is inappropriate. furthermore, you will confuse when it comes to style it with standard tailwind. for example,

export default function Home() {
  return (
    <div className="">
      <p className="font-bold">
        This text supposed to be bold but become normal
      </p>
      <p className="font-normal">
        This text supposed to be normal but become bold
      </p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode


typescript
then try to see in the browsers. the output is like these


You see clearly? this very inappropriate and not developer-friendly. As a developer it’s our job to make our fellow developers understand what code we write. If this happened then it would brought headache to all other developers. So, make sure to set value appropriately to make better result.


Chapter 4 - Multiple Local Fonts

What if I want to set multiple local fonts at the same time and implement the font accordingly?

It’s just simple and I show you why use variable instead of className is more handy.

just add new localFont below your current font.

// Start of: Implementing Local Font

const sfProText = localFont({...
}) 

const lufga = localFont({
  src: [
    {
      path: "../app/fonts/lufga/LufgaLight.ttf",
      style: "light",
      weight: "300",
    },
    {
      path: "../app/fonts/lufga/LufgaRegular.ttf",
      style: "normal",
      weight: "400",
    },
    {
      path: "../app/fonts/lufga/LufgaMedium.ttf",
      style: "medium",
      weight: "500",
    },
    {
      path: "../app/fonts/lufga/LufgaSemiBold.ttf",
      style: "semibold",
      weight: "600",
    },
    {
      path: "../app/fonts/lufga/LufgaBold.ttf",
      style: "bold",
      weight: "700",
    },
  ],
  variable: "--font-lufga",
});

// End of: Implementing Local Font
Enter fullscreen mode Exit fullscreen mode

then you can implement those fonts into app/layout.tsx. now, I’ll show you why using variables become more handy, let’s look at it

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${sfProText.variable} ${lufga.variable}`}>{children}</body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

After you implemented this, the fonts will not changed immediately. let’s see to the browser


You see? now compared it when you use className.

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${sfProText.variable} ${lufga.className}`}>
        {children}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

now see the result.


See? the font changes all over project.

This tiny adjustment alone makes the variable approach the clear winner for you if you want to inject new local fonts in already-large project. Instead of you changed the default font all over project, you managed it accordingly and set the font when its needed.

How exactly to managed local fonts accordingly?

first off make sure you change back to variable

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${sfProText.variable} ${lufga.variable}`}>
        {children}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

then you can setup the css like this in globals.css

@import "tailwindcss";

body {
  font-family: var(--font-sf-pro);
}

.use-lufga-font {
  font-family: var(--font-lufga) !important;
}
Enter fullscreen mode Exit fullscreen mode

For the best approach, choose one font to become default of entire project. here I set the SF PRO TEXT font to be default font and Lufga is secondary font to be implemented when necessary.
suppose I want to use Lufga font in my Text 2, then I can change it like this

export default function Home() {
  return (
    <div className="">
      <p className="font-bold">Text 1</p>
      <p className="font-normal use-lufga-font ">Text 2</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

now see the result.


See? you can manage accordingly which fonts you have you used, no hassle.


IMPROVEMENT

if you use tailwind version > 4 you can leverage it even better. to use the secondary font (in my case Lufga) I have to use manually in className.

.use-lufga-font {
  font-family: var(--font-lufga) !important;
}

Enter fullscreen mode Exit fullscreen mode

well, sure it has no problem at all but yet it is bad for development experience (DX) because it won’t get advantage of intellisense if we use plain css. instead add utility layer like this.

@utility use-font--lufga {
  font-family: var(--font-lufga) !important;
}
Enter fullscreen mode Exit fullscreen mode

with this setup, you will get better development experience by leverage the intellisense when you wrote inside className like below. notice I get intellisense for utility I just wrote.


export default function Home() {
  return (
    <div className="">
      <p className="font-bold">Text 1</p>
      <p className="font-normal use-lufga-font ">Text 2</p>

      <p className="use-font--lufga">Another text</p>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

The result is like this.


That’s it all you need to know.

PART I Summary

In this section, we covered the practical implementation of local fonts in Next.js:

  • How to load local fonts correctly using next/font/local
  • The difference between className and variable
  • Why variable does not apply styles automatically
  • Common pitfalls caused by incorrect weight and style values
  • How improper font metadata can break Tailwind semantics
  • Managing multiple local fonts without affecting the entire project

At this point, you should be comfortable implementing and debugging local fonts in real-world Next.js projects.


PART II - PATTERN

now we currently in PART II which is Pattern. You don’t necessarily need to implement pattern because it is not mandatory, if you want to know about implementation of Font then PART I is enough. However, if you would then bear with me till the end.

Chapter 5 - Multiple Fonts

You might be wondering “How about cooperating local fonts with google-fonts?”

fortunately, Next.js has innate integration with google-fonts so you don’t have to import manually, you can just call from next/font/google and treat it like localFont. suppose I want to add poppins and poppins italic fonts.

import { Poppins } from "next/font/google";

const poppinst_init = Poppins({
  weight: ["200", "300", "400", "500", "600"],
  subsets: ["latin"],
  style: ["normal"],
  variable: "--font-poppins",
});

const poppinst_italic_init = Poppins({
  weight: ["200", "300", "400", "500", "600"],
  subsets: ["latin"],
  style: ["italic"],
  variable: "--font-poppins-italic",
});
Enter fullscreen mode Exit fullscreen mode

However, for better implementation let’s implement pattern that make your DX more graceful. create new fonts.ts inside app/_lib/NextFont

import { Poppins } from "next/font/google";

const poppinst_init = Poppins({
  weight: ["200", "300", "400", "500", "600"],
  subsets: ["latin"],
  style: ["normal"],
  variable: "--font-poppins",
});

const poppinst_italic_init = Poppins({
  weight: ["200", "300", "400", "500", "600"],
  subsets: ["latin"],
  style: ["italic"],
  variable: "--font-poppins-italic",
});

export const fonts = {
  poppins: poppinst_init.className,
  poppins_italic: poppinst_italic_init.className,
};

Enter fullscreen mode Exit fullscreen mode

in case you’re wondering “why would we choose className for this case?”

The answer is because it will easy to implement. furthermore, className has higher specifity, also we already choosed default font using our custom localFont either way. Consider this as tertiary fonts.

Then, How to implement it to our app? Simply inject it to our desired content.

import { fonts } from "./_lib/NextFonts/fonts";

export default function Home() {
  return (
    <div className="">
      <p className="font-bold">Text 1</p>
      <p className="font-normal use-lufga-font ">Text 2</p>

      <p className="use-font--lufga">Another text</p>

      <p className={`${fonts.poppins}`}>
        Another fonts - using Google Font Poppins
      </p>
      <p className={`${fonts.poppins_italic}`}>
        Another fonts - using Google Font Poppins Italic
      </p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Result will be like this

Nice! Now we can go use both secondary and tertiary fonts in desired places, Great!

IMPROVEMENT

At this stage, when you lookup your app/layout.ts you will see the code cluttered. You can improve it by make it more concise by moving your fonts definitions into one file inside fonts.ts in app/_lib/NextFont.

import { Poppins } from "next/font/google";
import localFont from "next/font/local";

const poppinst_init = Poppins({
  weight: ["200", "300", "400", "500", "600"],
  subsets: ["latin"],
  style: ["normal"],
  variable: "--font-poppins",
});

const poppinst_italic_init = Poppins({
  weight: ["200", "300", "400", "500", "600"],
  subsets: ["latin"],
  style: ["italic"],
  variable: "--font-poppins-italic",
});

// Start of: Implementing Local Font
const sfProText = localFont({
  src: [
    {
      path: "../../../app/fonts/sfprotext/SF-Pro-Text-Light.otf",
      style: "light",
      weight: "300",
    },
    {
      path: "../../../app/fonts/sfprotext/SF-Pro-Text-Regular.otf",
      style: "normal",
      weight: "400",
    },
    {
      path: "../../../app/fonts/sfprotext/SF-Pro-Text-Medium.otf",
      style: "medium",
      weight: "500",
    },
    {
      path: "../../../app/fonts/sfprotext/SF-Pro-Text-Semibold.otf",
      style: "semibold",
      weight: "600",
    },
    {
      path: "../../../app/fonts/sfprotext/SF-Pro-Text-Bold.otf",
      style: "bold",
      weight: "700",
    },
  ],
  variable: "--font-sf-pro",
});

const lufga = localFont({
  src: [
    {
      path: "../../../app/fonts/lufga/LufgaLight.ttf",
      style: "light",
      weight: "300",
    },
    {
      path: "../../../app/fonts/lufga/LufgaRegular.ttf",
      style: "normal",
      weight: "400",
    },
    {
      path: "../../../app/fonts/lufga/LufgaMedium.ttf",
      style: "medium",
      weight: "500",
    },
    {
      path: "../../../app/fonts/lufga/LufgaSemiBold.ttf",
      style: "semibold",
      weight: "600",
    },
    {
      path: "../../../app/fonts/lufga/LufgaBold.ttf",
      style: "bold",
      weight: "700",
    },
  ],
  variable: "--font-lufga",
});

// End of: Implementing Local Font

export const fonts = {
  poppins: poppinst_init.className,
  poppins_italic: poppinst_italic_init.className,
  sfPro_var: sfProText.variable,
  lufga_var: lufga.variable,
};
Enter fullscreen mode Exit fullscreen mode

Note: Don’t forget to readjust the localFonts path.
then in app/layout.ts you can implement it like this:

import type { Metadata } from "next";

import "./globals.css";
import { fonts } from "./_lib/NextFonts/fonts";

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${fonts.lufga_var} ${fonts.sfPro_var} `}>
        {children}
      </body>
    </html>
  );
}

Enter fullscreen mode Exit fullscreen mode

See? More concise and easy to understand. That’s all you need to know!

PART II Summary

In this section, we focused on scalable patterns for managing fonts:

  • Combining local fonts with Google Fonts using next/font/google
  • Deciding when to use className vs variable
  • Treating Google Fonts as secondary or tertiary fonts
  • Improving DX by centralizing font configuration
  • Keeping app/layout.tsx concise and readable
  • Preparing a font system that scales with large codebases

These patterns are optional, but highly recommended for long-term maintainability.


That’s it.

I wrote this article out of frustration with how many resources exist online about implementing local fonts, yet very few explain how to do it properly without unnecessary fluff. The documentation is clear but I know most people feel it’s dry to consume from those. So, I decide to make this small tutorial so you can follow through and digest the idea of implementing local fonts the right way and the pattern that I personally preferred. As a fellow developer, I’m very happy to share this with you. feel free to get in touch and discuss with me!

Oh anyway I forgot something… Happy new year for all the readers, wish you all the best in this new fascinating year! 🎉

Top comments (0)