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/localfor local fonts andnext/font/googlefor Google Fonts. -
classNameapplies fonts immediately and has higher specificity. -
variablecreates CSS variables and must be explicitly used in CSS. - Always match font
weightandstylecorrectly to avoid Tailwind conflicts. - Use
variablewhen managing multiple fonts in large projects. - With Tailwind v4, prefer
@utilityfor better DX and IntelliSense. - Centralize font definitions to keep
app/layout.tsxclean 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>
);
}
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:
-
pathdefines 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. -
styleandweightare 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. -
variableis 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>
);
}
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>
);
}
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>
);
}
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>
);
}
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);
}
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
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"
});
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>
);
}
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
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>
);
}
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>
);
}
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>
);
}
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;
}
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>
);
}
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;
}
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;
}
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>
);
}
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
classNameandvariable - Why
variabledoes not apply styles automatically - Common pitfalls caused by incorrect
weightandstylevalues - 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",
});
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,
};
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>
);
}
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,
};
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>
);
}
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
classNamevsvariable - Treating Google Fonts as secondary or tertiary fonts
- Improving DX by centralizing font configuration
- Keeping
app/layout.tsxconcise 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)