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
| Module | Purpose |
|---|---|
@multinaire/expo-ui | Component library, theme system, hooks |
expo-router | File-based navigation |
iconsax-react-nativejs | Icon set (993 icons) backing the Icon component |
react-native-ui-datepicker | Powers DateTimePicker |
i18next, react-i18next | Localization |
@react-native-async-storage/async-storage | Persists 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-client | Expo 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-web | Peer 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
| Script | Description |
|---|---|
npm run start | Start the Metro dev server |
npm run android | Build and run on Android |
npm run ios | Build and run on iOS |
npm run web | Start the web build |
npm run lint | expo lint --fix |
npm run format | prettier --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
- Configuration — provider setup details.
- Localization — the template's
i18next/react-i18nextsetup. - Components Overview — browse available components.