Skip to main content

Expo Template

The expo template (scaffolded via create-app) is a production-ready Expo Router app, styled end-to-end with @multinaire/expo-ui.

Structure​

/
├── .npmrc Pre-configured for GitHub Packages auth
├── app.config.ts Dynamic Expo config (name, icons, plugins)
├── translations.d.ts Typed translation keys (augments TranslationSchema)
├── src/
│ ├── app/ Expo Router file-based routes
│ │ ├── +html.tsx Web-only HTML shell — static theme for the first paint
│ │ ├── _layout.tsx Root layout — MultinaireUI + ThemeProvider
│ │ └── index.tsx Home screen
│ ├── components/ Shared app components
│ │ └── ThemeModeButton.tsx Light / dark / system switcher
│ └── assets/
│ ├── theme.json Colors, typography, and variables
│ ├── translations/ One JSON per locale — en.json
│ ├── images/ App icon, adaptive icon, favicon
│ └── app.icon/ iOS Icon Composer asset
├── AGENTS.md Guidance for coding agents (Claude Code, etc.)
└── .claude/ Claude Code plugin config

Routes live under src/app rather than app/ — app.config.ts and tsconfig.json are both configured for this via expo-router/plugin and a @/* path alias to the project root.

Pre-installed modules​

ModulePurpose
@multinaire/expo-uiComponent library, theme system, hooks
expo-routerFile-based navigation
iconsax-react-nativejsIcon set (993 icons) backing the Icon component
react-native-ui-datepickerPowers DateTimePicker
i18next, react-i18nextLocalization
@react-native-async-storage/async-storagePersists theme/language preference
expo-font, expo-image, expo-splash-screen, expo-status-bar, expo-system-ui, expo-navigation-bar, expo-web-browser, expo-constants, expo-linking, expo-localization, expo-dev-clientExpo modules wired into app.config.ts
react-native-gesture-handler, react-native-reanimated, react-native-worklets, react-native-screens, react-native-safe-area-context, react-native-svg, react-native-webPeer deps required by @multinaire/expo-ui

Nothing above needs to be installed manually — it ships in the template's package.json.

GitHub Packages authentication​

@multinaire/expo-ui is published to GitHub Packages, not npm, so the template ships a project-level .npmrc already pointing @multinaire at the right registry. You only need to export a GitHub PAT as PERSONAL_ACCESS_TOKEN before running npm install — see GitHub Packages authentication.

Root layout​

src/app/_layout.tsx wraps the app in MultinaireUI (loading theme.json and the locale bundles) and maps the theme onto React Navigation with useMapNativeTheme and useScreenOptions:

import theme from "@/src/assets/theme.json";
import en from "@/src/assets/translations/en.json";
import { ThemeModeButton } from "@/src/components/ThemeModeButton";
import MultinaireUI, {
useMapNativeTheme,
useScreenOptions,
} from "@multinaire/expo-ui";
import { Stack, ThemeProvider } from "expo-router";

const translations = {
en: { translation: en },
};

export default function RootLayout() {
return (
<MultinaireUI theme={theme} translations={translations}>
<RootNavigator />
</MultinaireUI>
);
}

function RootNavigator() {
const theme = useMapNativeTheme();
const { screenOptions, title } = useScreenOptions("stack");

return (
<ThemeProvider value={theme}>
<Stack
screenOptions={screenOptions({
headerRight: () => <ThemeModeButton />,
})}
>
<Stack.Screen name="index" options={{ title: title("app-name") }} />
</Stack>
</ThemeProvider>
);
}

Don't add a second MultinaireUI or ThemeProvider in nested layouts — the root already provides both.

useScreenOptions('stack') already renders StackHeader, so app chrome is declared as screen options rather than built inside a screen: headerRight mounts the theme switcher, and each root screen carries a title (without one, StackHeader falls through to a back affordance that has nowhere to go).

Localization​

Every user-visible string in the template is a translation key. Locale files live in src/assets/translations/, are registered through MultinaireUI's translations prop, and are typed by translations.d.ts:

import type en from "./src/assets/translations/en.json";

declare module "@multinaire/expo-ui" {
interface TranslationSchema {
en: typeof en;
}
}

Adding a locale is two lines — a file in src/assets/translations/, added to both the translations map and the interface above. TranslationKey then narrows to the keys present in every locale, so a key added to one file only will not type-check. Pass translate={false} only for data rather than copy — a user's name, a URL, a version string — since a bare string that happens to match a key would otherwise be silently replaced.

Responsive layout​

The home screen is the shape to copy for a new one. Everything it needs comes from useResponsiveDesign():

const { isMobile, maxWidths } = useResponsiveDesign();

<Container
flex={1}
safeAreaEdges={["bottom"]}
style={{ width: "100%", maxWidth: maxWidths.content, alignSelf: "center" }}
>

maxWidths.content is undefined on mobile and 800 above it, so the one style caps and centers the screen on a laptop while leaving a phone full-bleed. It sits on the outermost Container, so the footer buttons stay aligned with the scrolling content rather than spreading to the window edges.

isMobile drives the feature grid — a wrapping row whose cells are '100%' wide on a phone and '49%' above it, so the cards fall into two columns once there is room:

<Container horizontal={!isMobile} style={{ flexWrap: "wrap" }} gap={variables.gap.medium}>
{FEATURES.map((feature, index) => (
<Container key={`features:${index}`} width={isMobile ? "100%" : "49%"}>
<FeatureCard feature={feature} />
</Container>
))}
</Container>

Both are width-driven rather than branch-driven — the same tree renders at every breakpoint — so the screen needs no useIsHydrated() gate. Reach for one only when a layout swaps components rather than resizing them, because the first web render always reports mobile.

The hero is a Placeholder, the same component used for empty and error states, rather than a bespoke stack of centered text:

<Placeholder
flex={1}
graphic={<Icon icon="MagicStar" size={80} color="onBackground" />}
title="app-name"
description="app-tagline"
/>

App identity​

Expo's default scaffolding tools only rename the HelloWorld placeholder tokens inside app.json and native ios/android project files — this template has neither, so nothing renames app.config.ts automatically. Instead, name/slug/scheme and android.package/ios.bundleIdentifier are read from two constants declared at the top of app.config.ts:

const SLUG = "expo-template";
const ID = "com.multinaire.template";

Update both when starting a new project — ID must be a reverse-DNS identifier you actually own before submitting to the Play Store or App Store, not com.multinaire.*.

Theming​

src/assets/theme.json is the single source of truth for colors, typography, and spacing, and is validated against @multinaire/expo-ui's theme.schema.json. Edit it directly rather than hard-coding values in components — see Theme Setup.

Scripts​

ScriptDescription
npm run startStart the Metro dev server
npm run androidBuild and run on Android
npm run iosBuild and run on iOS
npm run webStart the web build
npm run lintexpo lint --fix
npm run formatprettier --write .

AGENTS.md​

The template ships an AGENTS.md (referenced from CLAUDE.md) describing the project structure, pre-installed modules, and conventions for coding agents working in the scaffolded app — keep it in sync if you restructure the project.

Next steps​