Expo template with pre-built components and patterns.
- Use
n()for all numeric style values - Use bun instead of npm
- Minimise
useEffect- see You Might Not Need an Effect - Readable code > comments
bun dev- Build and runbun run sync-version- Sync version from app.json to package.json + build.gradlebun run generate-icon- Generate app icon from first letter of app namebun run generate-readme-image- Generate README example image (requires A.png, B.png, C.png, D.png in assets/images/)bun run check- Lintbun run fix- Automatically fix linting issues
Update app.json:
{
"expo": {
"name": "App Name",
"slug": "appname",
"icon": "./assets/icon.png",
"android": {
"package": "com.vandam.appname"
}
}
}Delete /android folder before first build (regenerates with your config).
Workflow at .github/workflows/build.yml builds APK and creates release:
- Triggered manually via
workflow_dispatch - Builds production APK using EAS
- Creates GitHub release with changelog
Requires EXPO_TOKEN secret in repo settings.
Always use n() for sizes - normalises across screen densities:
import { n } from "@/utils/scaling";
const styles = StyleSheet.create({
container: { padding: n(16) },
text: { fontSize: n(18) },
icon: { width: n(24), height: n(24) }
});Wrap screen content in ContentContainer - handles scrolling, theming, padding, and scroll indicators automatically. No styling needed on children.
import ContentContainer from "@/components/ContentContainer";
export default function MyScreen() {
return (
<ContentContainer headerTitle="My Screen">
<MyComponent />
<AnotherComponent />
</ContentContainer>
);
}Props:
headerTitle- Shows header with title (omit to hide)hideBackButton- Hide back arrow (default: false)rightAction- Header icon button:{ icon: "share", onPress: () => {} }contentWidth-"normal"(default) or"wide"for more horizontal spacecontentGap- Gap between children (default: 47)
To add a new tab:
- Create screen file
app/(tabs)/search.tsx - Add to
TABS_CONFIGinapp/(tabs)/_layout.tsx:
export const TABS_CONFIG: ReadonlyArray<TabConfigItem> = [
{ name: "Home", screenName: "index", iconName: "home" },
{ name: "Search", screenName: "search", iconName: "search" }, // new
{ name: "Settings", screenName: "settings", iconName: "settings" },
] as const;- Add
<Tabs.Screen>entry:
<Tabs.Screen name="index" />
<Tabs.Screen name="search" /> // new
<Tabs.Screen name="settings" />Icons: Use MaterialIcons names.
Settings use nested routes:
app/(tabs)/settings.tsx → Main settings page
app/settings/customise.tsx → Customise options
app/settings/option-example.tsx → Options page (example)
Use SelectorButton + OptionsSelector for option pickers:
// In settings page
<SelectorButton
label="Option Example"
value={currentValue}
href="/settings/option-example"
/>
// In options page (app/settings/option-example.tsx)
<OptionsSelector
title="Option Example"
options={[{ label: "Standard", value: "standard" }, ...]}
selectedValue={optionValue}
onSelect={(value) => setOptionValue(value)}
/>For destructive actions, use the confirm screen pattern:
router.push({
pathname: "/confirm",
params: {
title: "Clear Cache",
message: "Are you sure?",
confirmText: "Clear",
action: "clearCache",
returnPath: "/(tabs)/settings",
},
});
// Handle confirmation in useEffect
useEffect(() => {
if (params.confirmed === "true" && params.action === "clearCache") {
router.setParams({ confirmed: undefined, action: undefined });
// Do the action
}
}, [params.confirmed, params.action]);Wrapped in app/_layout.tsx:
InvertColorsContext- Theme toggle (black/white), persists to AsyncStorageOptionExampleContext- Example setting context (seeapp/settings/option-example.tsx)
Use: const { invertColors } = useInvertColors();
Use HapticPressable instead of Pressable for automatic haptic feedback on press.