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',
});
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;
--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-faceblocks - 10 distinct
.woff2files - 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]);
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,
};
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 },
};
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)