DEV Community

Daniel Pertu
Daniel Pertu

Posted on

40 font-face blocks, 10 files, and the word bold appears nowhere: three typefaces reaching a Next.js site and an Expo app

Munchable uses three typefaces, and each has exactly one job. Fredoka, which is round and friendly, is allowed on the wordmark and hero headlines and nowhere else. Outfit does every other heading. Nunito does body text. The sizes come from a tokens package shared by the website, the phone app and the renderer that produces our video, so a heading is 27/33 in all three.

Two of those consumers load the fonts in completely different ways, and the second one surprised me more than it should have.

The web half, and why it is self-hosted

const nunito = Nunito({
  subsets: ['latin'],
  weight: ['400', '500', '600', '700'],
  variable: '--font-nunito',
  display: 'swap',
});
Enter fullscreen mode Exit fullscreen mode

Three of those, one per family, each exposing a CSS variable that globals.css consumes through role names rather than family names:

--font-display: var(--font-outfit), ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-body: var(--font-nunito), ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-brand: var(--font-fredoka), var(--font-nunito), ui-sans-serif, system-ui, -apple-system, sans-serif;
Enter fullscreen mode Exit fullscreen mode

--font-brand falling back to Nunito before the system stack is deliberate: if the display face fails, the wordmark should land on the font the rest of the page is already using rather than on whatever Helvetica the device has.

next/font/google downloads the font files at build time and serves them from our own origin. That is a privacy decision before it is a performance one. A <link> to fonts.googleapis.com means every page load reports the visitor's IP address to a third party, and this is a site where the page somebody is reading can be the IBD page or the SIBO page. The URL is a health disclosure. There is no third-party request in the critical path of any page here, and the privacy policy says so, which means the fonts had to come along.

What that actually ships

You can count this yourself on any page of the site. Pull the stylesheet and look at the @font-face blocks:

  • 40 @font-face blocks
  • 10 distinct .woff2 files
  • 3 of the blocks are not web fonts at all

The three odd ones are named Nunito Fallback, Outfit Fallback and Fredoka Fallback. They are synthetic faces pointing at a local system font with adjusted metrics, generated so that the text drawn before the real font arrives occupies close to the same space. That is the mechanism that keeps display: swap from shoving your layout sideways, and it is the main reason to use the font loader rather than hand-writing the @font-face rules.

Forty blocks for ten files because the same file is declared under several weights and several unicode ranges. The browser then fetches only the ones a page actually asks for. On the homepage, in a fresh profile:

performance.getEntriesByType('resource')
  .filter(r => /\.woff2/.test(r.name))
  .map(r => [r.name.split('/').pop().slice(0, 12), r.encodedBodySize]);
Enter fullscreen mode Exit fullscreen mode

Three files, 101,084 bytes in total, all from munchable.app.

The audit this led to

Eleven weights are declared across the three families: four of Nunito, four of Outfit, three of Fredoka.

The public stylesheet contains exactly three numeric weights: 600, 700 and 800. Body copy inherits 400 and never says so. Grep it and that is the whole list, inline SVG demos included.

Which means at least two declared weights are never referenced by the site: font-weight: 500 appears nowhere in the public CSS at all. It appears in exactly one file in the repository, the admin panel's own stylesheet, which is behind authentication and noindexed.

The honest accounting here matters, because it explains why nobody noticed. A declared and unreferenced weight costs a visitor nothing in bandwidth: no rule asks for it, so the file is never fetched. It costs build output, a slightly bigger stylesheet, and a wrong answer to the question "which weights does this product use". It is a one-line fix in each of three calls, and the reason to make it is tidiness rather than speed, which is worth saying out loud instead of dressing it up as a performance win.

The React Native half, where weight is not a number

Here is the part that trips up anybody moving a design system onto React Native. On the web, you ask for a family and a weight and the browser picks a face. In React Native with custom fonts, the weight is part of the family's name:

export const fontMap = {
  Fredoka_600SemiBold,
  Fredoka_700Bold,
  Outfit_500Medium,
  Outfit_600SemiBold,
  Outfit_700Bold,
  Nunito_400Regular,
  Nunito_600SemiBold,
  Nunito_700Bold,
};
Enter fullscreen mode Exit fullscreen mode

Eight named faces, handed to expo-font's useFonts. Then the type scale attaches a family to each variant rather than a weight:

export const typography = {
  brand: { fontFamily: 'Fredoka_700Bold', ...typeScale.brand },
  hero: { fontFamily: 'Fredoka_700Bold', ...typeScale.hero },
  display: { fontFamily: 'Outfit_700Bold', ...typeScale.display },
  h1: { fontFamily: 'Outfit_700Bold', ...typeScale.h1 },
  h2: { fontFamily: 'Outfit_600SemiBold', ...typeScale.h2 },
  title: { fontFamily: 'Outfit_600SemiBold', ...typeScale.title },
  body: { fontFamily: 'Nunito_400Regular', ...typeScale.body },
  bodyStrong: { fontFamily: 'Nunito_700Bold', ...typeScale.bodyStrong },
  label: { fontFamily: 'Nunito_700Bold', ...typeScale.label },
  caption: { fontFamily: 'Nunito_600SemiBold', ...typeScale.caption },
};
Enter fullscreen mode Exit fullscreen mode

fontWeight appears nowhere in the app. Not in the tokens, not in a component, not in a screen. Grep returns nothing, and that is the design: setting fontWeight: '700' on a custom family asks the platform to synthesise a bold, which on Android is a smeared version of the regular face and on iOS is sometimes ignored entirely. Two platforms, two different wrong answers. Naming the face is the only portable instruction.

The ...typeScale.h2 spread is where the two halves meet. The sizes and line heights live in a platform-free package, so h2 is 21/27 on a phone, in the browser build of the same app, and in the rendered video frames, without anybody retyping a number.

One <Text variant="h2"> component reads that table. No screen picks a family, so there is no screen that can pick the wrong one.

Boot, briefly

Fonts block the splash screen, because the app's first screen is a scanner and text reflowing under somebody's thumb in a supermarket aisle is worse than a slightly longer splash. But the gate is fontsLoaded || fontError: a font that fails to load must not hold the app hostage, and the fallback stack exists precisely so that case is merely uglier rather than broken.

Where to see all three

The homepage is the only page with the brand face at full size, so munchable.app is where Fredoka actually earns its place. A content page such as the congee recipe is the ordinary case: Outfit for the headings, Nunito for everything you read, and nothing from a third-party domain in the network panel.

Top comments (0)