Four ways to get a font in, ranked
| Method | Load flash | Works in Expo Go | Use it for |
|---|---|---|---|
| expo-font config plugin (native embed) | None — font is in the binary | No — needs a build | Anything you ship |
| useFonts hook (runtime) | Yes, unless you hold the splash | Yes | Prototyping, Expo Go, fonts chosen at runtime |
| @expo-google-fonts/* package | Same as useFonts | Yes | Google Fonts without hunting for files |
| System font stack | None | Yes | When the brand does not depend on type |
The honest recommendation is to use both, in that order over the life of the project: the hook while you’re still in Expo Go and iterating on which typeface you even want, then the config plugin the moment you start producing real builds. Switching is a five-line change and it deletes an entire class of launch bug.
One family name per weight
This is the part almost everyone gets wrong first. In a browser you register one family and pick weights with font-weight. On Android that does not work for custom families: it looks for a registered font matching the requested weight, doesn’t find one, and falls back to the single cut you loaded. There is no synthetic bolding. So register each cut as its own family:
// assets/fonts/
// Inter-Regular.ttf
// Inter-Medium.ttf
// Inter-SemiBold.ttf
// Inter-Bold.ttf
// theme/type.ts — the only place font names are written as strings
export const fonts = {
regular: 'Inter-Regular',
medium: 'Inter-Medium',
semibold: 'Inter-SemiBold',
bold: 'Inter-Bold',
} as const;
// Usage: pick the family, not the weight.
// GOOD: { fontFamily: fonts.bold }
// BAD: { fontFamily: fonts.regular, fontWeight: '700' } // no-op on AndroidShip only the weights you use. Four cuts of a typical variable-source family is roughly a megabyte of binary you carry forever, and most apps genuinely need two: a body weight and a heading weight. Adding italic doubles the count again, so decide whether you actually use it.
Runtime loading, without the flash
If you load fonts at runtime, the app can render before they arrive. The fix is to keep the splash screen up until the hook says it’s done, so the first painted frame is already correct:
// app/_layout.tsx
import { useCallback } from 'react';
import { View } from 'react-native';
import { Stack } from 'expo-router';
import { useFonts } from 'expo-font';
import * as SplashScreen from 'expo-splash-screen';
// Module scope — must run before the first render.
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded, error] = useFonts({
'Inter-Regular': require('../assets/fonts/Inter-Regular.ttf'),
'Inter-Medium': require('../assets/fonts/Inter-Medium.ttf'),
'Inter-Bold': require('../assets/fonts/Inter-Bold.ttf'),
});
const onReady = useCallback(async () => {
// Hide once we have painted, so there is no blank frame between the two.
if (loaded || error) await SplashScreen.hideAsync();
}, [loaded, error]);
// Note: continue on error. A missing font should not be a black screen.
if (!loaded && !error) return null;
return (
<View style={{ flex: 1 }} onLayout={onReady}>
<Stack />
</View>
);
}The error branch is not decoration. If a font file is missing or corrupt in a production build, gating purely on loaded gives every user a permanent splash screen — the worst possible failure for the most cosmetic possible feature. Render in the fallback font and log it instead. The splash screen guide covers the handoff timing in more detail.
Embedding natively: no loading state at all
Once you are producing development or production builds, the fonts can go into the binary and be available on the very first frame. Configure the expo-font plugin and rebuild:
// app.json
{
"expo": {
"plugins": [
[
"expo-font",
{
"fonts": [
"./assets/fonts/Inter-Regular.ttf",
"./assets/fonts/Inter-Medium.ttf",
"./assets/fonts/Inter-Bold.ttf"
]
}
]
]
}
}Two things to know. This changes native config, so it needs a new build — it will not take effect over an OTA update or in Expo Go. And the family name the platform registers comes from the font file itself rather than from a key you chose, so if text stays stubbornly in the system font after a rebuild, the name in your styles probably doesn’t match the file’s internal name. Check exact spelling and hyphenation before assuming the plugin failed. Consistent filenames that match the internal names save you the trip.
Applying it everywhere without a global patch
The tempting move is to assign Text.defaultProps.styleonce and be done. Don’t: it is deprecated for function components, it breaks silently on upgrades, and it still misses text rendered inside libraries. Shared components are more code the first day and less code every day after:
// components/Type.tsx
import { Text, type TextProps, StyleSheet } from 'react-native';
import { fonts } from '@/theme/type';
const styles = StyleSheet.create({
body: { fontFamily: fonts.regular, fontSize: 16, lineHeight: 23 },
label: { fontFamily: fonts.medium, fontSize: 14, lineHeight: 20 },
heading: { fontFamily: fonts.bold, fontSize: 28, lineHeight: 33, letterSpacing: -0.5 },
});
export const Body = (p: TextProps) => <Text {...p} style={[styles.body, p.style]} />;
export const Label = (p: TextProps) => <Text {...p} style={[styles.label, p.style]} />;
export const Heading = (p: TextProps) => <Text {...p} style={[styles.heading, p.style]} />;Set lineHeightexplicitly while you’re here. Custom fonts carry their own default metrics, and the same numeric size can sit noticeably tighter or looser than the system font did — which is why type often looks subtly wrong right after switching, even though nothing about the sizes changed. Vertical centering inside buttons is where it shows first.
The traps
- fontWeight on a custom family. No-op on Android, works on iOS, therefore ships. Select the family instead.
- Gating render on loaded alone. A missing file becomes a permanent splash screen in production. Continue on error.
- Loading eight weights. Every cut is binary size on a device that already has perfectly good system fonts. Two weights covers most designs.
- Assuming the plugin works in Expo Go. Native embedding needs a development build; the hook is what works in Expo Go.
- Ignoring the user’s text size setting. Custom type does not exempt you from accessibility scaling. Test at the largest system text size before shipping — layouts that only work at the default size are a common App Review comment.
The shortcut: name the typeface, not the plumbing
Typography is the highest-leverage change you can make to how an app feels, and the setup is entirely mechanical: the same file placement, the same per-weight naming, the same splash handoff, in every project.
Tell ShipNative what you want — “use Inter for body and Space Grotesk for headings” — and it wires the loading, the per-weight families and the shared text components, then runs the app on your phone so you can judge the type at real size instead of in a browser. Export the full Expo project whenever you want it.
Build it free
Describe your app in one sentence and have it running on your own phone in minutes at shipnative.dev. No credit card, full React Native source export whenever you want it.