Config
readynative.config.ts fields, app.config.ts variants and the generated .readynative.json.
readynative.config.ts
The one file you edit. It must stay importable under plain Node (no React Native imports;
only types from src/), because app.config.ts reads it while Expo resolves the native
config. The shape is ReadyNativeConfig from src/lib/readynative-config.ts.
| Block | Field | Meaning |
|---|---|---|
app | name | Display name under the icon; variants append (Dev) / (Preview) |
slug | Expo project slug (EAS) | |
scheme | Deep-link scheme, e.g. readynative → readynative:// | |
owner | Expo account / organisation slug owning the EAS project; empty → omitted | |
easProjectId | EAS project id printed by bunx eas-cli init; feeds extra.eas.projectId + updates.url | |
ios.bundleId | iOS bundle identifier; variants append .dev / .preview | |
android.package | Android application id; same suffix rule | |
brand | primary | Hex colour; overrides the light primary token |
accent | Hex colour; exposed as the brandAccent token | |
radius | Base corner radius in px (radius.lg); the other steps derive from it | |
font | Font family name for the fontFamily token; you load the font file (see below) | |
urls | website, support, privacy, terms | Used by the settings screen; support may be a mailto: URL |
store | appStoreId | Numeric App Store id for review prompts / store links |
playPackage | Play Store package (usually equals app.android.package) | |
links | domain | Bare host for iOS universal links / Android App Links (app.example.com). Empty → links off |
appleTeamId | 10-character Apple Team ID; prefixes the AASA app ids | |
androidCertFingerprints | SHA-256 fingerprints of the Android signing certs (eas credentials prints them) | |
features | reviewPrompt | Ask for a store review (expo-store-review) |
updatesBanner | Show the "update available" banner backed by expo-updates |
Who reads it:
app.config.ts- names, bundle ids, scheme,associatedDomains/intentFilters.src/theme/tokens.ts-brand→ colour, radius andfontFamilytokens (thenbun run gen:theme).- The settings screen -
urls,store,features. bun run doctor- flags thecom.acme.appplaceholders, empty URLs / store ids, alinks.domainwithoutappleTeamId/androidCertFingerprints, and an unlinked EAS project (app.easProjectIdempty and noEAS_PROJECT_ID) or a missingupdates.url.bun run gen:links-links→public/.well-known/apple-app-site-associationandassetlinks.json.
Import it in app code through @/lib/readynative (ESLint blocks direct imports of
readynative.config).
Brand font
brand.font is only a name. Nothing in the tree loads a font file, so until you do, the name
points at a font the device doesn't have and text falls back to the system font. Put the file
under assets/fonts/ and load it under exactly the name in brand.font, holding the splash
screen until it's ready:
import { useFonts } from "expo-font";
export default function RootLayout() {
const [fontsLoaded] = useFonts({
Inter: require("@/assets/fonts/Inter-Regular.ttf"),
});
useEffect(() => {
if (!fontsLoaded) return;
hydrateThemeMode().finally(() => {
SplashScreen.hideAsync().catch((err: unknown) => {
console.warn("[splash] hideAsync failed", err);
});
});
}, [fontsLoaded]);
if (!fontsLoaded) return null;
// …the existing return
}expo-font is already a dependency and useFonts works in Expo Go. The token reaches text
through the stylesheet and unistyles kits; with NativeWind set the family in your Tailwind
theme, with Tamagui in tamagui.config.ts. Each weight is its own file - load Inter-Bold under
its own name if you need real bold on Android.
Universal links
links is always wired but off until domain is set:
- Set
domain,appleTeamIdandandroidCertFingerprints. bun run gen:linkswrites the two well-known files for the prod, preview and dev bundle ids intopublic/.well-known/. Host them over https at that domain -expo export -p webshipspublic/as-is, so the web build living there is enough.- Rebuild the native app:
associatedDomainsandintentFiltersare native config thatapp.config.tsemits only whenlinks.domainis non-empty.
Test on a simulator with npx uri-scheme open https://<domain>/x --ios (or --android).
app.config.ts and variants
app.config.ts is hand-written and stays that way; modules never edit it. It derives three
variants from APP_VARIANT (default dev):
APP_VARIANT | Name suffix | Bundle id / package suffix | EAS profile |
|---|---|---|---|
dev | (Dev) | .dev | development |
preview | (Preview) | .preview | preview |
prod | (none) | (none) | production |
So com.acme.app becomes com.acme.app.dev for the dev build and all three can be installed
side by side. eas.json sets APP_VARIANT and EXPO_PUBLIC_APP_VARIANT per profile; locally
they come from .env (.env.example starts with APP_VARIANT=dev). The value is also exposed
as extra.appVariant and validated in src/lib/env.ts.
Other fixed choices in app.config.ts: runtimeVersion: { policy: "appVersion" }, typed
routes and the React Compiler on (experiments), and web.output: "static".
owner, extra.eas.projectId and updates.url all come from readynative.config.ts:
easProjectId (or EAS_PROJECT_ID, which wins) becomes extra.eas.projectId and
updates.url: https://u.expo.dev/<projectId>. While the id is empty the whole updates key is
omitted - an empty URL would break expo-updates. owner is omitted when empty. See
Ship to TestFlight.
Module app patch
Modules contribute native config through module.json → app.expo (config plugins, extra
keys). setup merges every selected module's patch into .readynative.json → modules.app, and
app.config.ts applies it at load time: plugins are appended unique by plugin id, other
keys are deep-merged. Example: push/expo-notifications adds the expo-notifications
plugin, auth/supabase adds expo-apple-authentication.
.readynative.json
Written by setup, read by app.config.ts and doctor:
{
"version": 1,
"preset": "default",
"selection": { "ui": "nativewind4", "data": "react-query", "...": "..." },
"applied": true,
"modules": {
"app": { "expo": { "plugins": ["expo-apple-authentication"] } },
"postSetup": ["Supabase: create a project, …"],
"doctor": [{ "type": "env", "keys": ["EXPO_PUBLIC_SUPABASE_URL"], "module": "supabase" }]
},
"keepModules": true
}preset is null when the selection matches no preset. keepModules: false plus a different
selection on the next setup run is the error that tells you modules/ is gone.
Environment keys
.env (gitignored) holds every key; .env.example (generated) lists them per module with a
# docs: link. Rules:
- Public keys start with
EXPO_PUBLIC_and are the only ones rendered intosrc/lib/env.ts, each asz.string().optional()- a missing key degrades the module ("Configure X"), it never crashes at import time. server: truekeys (STRIPE_SECRET_KEY,BETTER_AUTH_SECRET, …) have no prefix, live under the# server (never EXPO_PUBLIC)section, are read withprocess.env.Xonly insrc/app/api/**/src/server/**, and never reach the bundle.bun run doctorchecks everyrequired: truekey plus explicitdoctorentries.