Folders That Survive Growth
Group files by feature instead of by file type, keep routes thin, and stop the deep relative imports that follow from getting it wrong.
import OrderCard from "@/features/orders/components/OrderCard";Expo Router makes one folder structural: every file in src/app becomes a route. The rest of the project is yours to arrange, and the arrangement decides whether month six is pleasant.
Group by feature, not by file type
Top-level components, hooks, types and utils folders look tidy on day one. They age badly, because work arrives as a feature: one change touches four folders, and no folder tells you what the app does. Keep a feature's pieces together instead. src/features/orders holds its hooks, its types and the components only it uses. Promote a file to src/components or src/lib when a second feature needs it, not before.
Routes stay thin
A file in src/app should read route params, get data and render a feature component. Keep the screen body elsewhere and the route file stays readable. Projects created from the default template on SDK 55 and later already ship a top-level src directory containing app, components, constants and hooks. Config files such as app.json, metro.config.js and tsconfig.json stay in the root. If both app and src/app exist, only src/app is used.
Where it goes wrong
Put a helper file inside src/app and Expo Router tries to treat it as a route. Deep relative imports such as ../../../lib/money are the signal that a file sits in the wrong folder; a path alias only fixes the spelling, since @/lib/money and that relative path are the same module. Shared code moves down into src/lib, never sideways between features. Feature A importing feature B is how circular imports start.
Example
// src/features/orders/components/OrderCard.tsx
// Lives with its feature, not in a project-wide components folder
import { StyleSheet, Text, View } from "react-native";
type OrderCardProps = {
reference: string;
totalPence: number;
status: "paid" | "pending";
};
export default function OrderCard({ reference, totalPence, status }: OrderCardProps) {
return (
<View style={styles.card}>
<View style={styles.row}>
{/* A bare string inside a View throws; text is always wrapped in Text */}
<Text style={styles.reference}>{reference}</Text>
<Text style={status === "paid" ? styles.paid : styles.pending}>{status}</Text>
</View>
<Text style={styles.total}>{`£${(totalPence / 100).toFixed(2)}`}</Text>
</View>
);
}
const styles = StyleSheet.create({
card: {
padding: 16,
borderRadius: 12,
backgroundColor: "#ffffff",
// The older shadow props are platform-split: shadow* on iOS, elevation on
// Android. Since 0.76 the New Architecture also takes a cross-platform
// boxShadow, spelled camelCase - never CSS's box-shadow.
elevation: 2,
},
// flexDirection defaults to column, so a row has to be asked for
row: { flexDirection: "row", justifyContent: "space-between" },
reference: { fontWeight: "600" },
paid: { color: "#15803d" },
pending: { color: "#b45309" },
total: { marginTop: 8, fontSize: 20 },
});When to use it
- Starting a new project and deciding where the first ten files go before the habit sets.
- Breaking up a 600-line screen file into a route plus a feature folder.
- Working out whether a new Button belongs to one feature or to src/components.
- Untangling a project where every import begins with ../../..
More examples
A layout that holds up
Each feature is one folder you can read, move or delete in one piece, and the two shared folders stay small because a file only earns a place there once a second feature imports it. Reach for this layout as soon as the app has more than about three screens - converting later means touching every import in the project.
src/
app/ routes only - every file here is a URL
_layout.tsx
index.tsx /
orders/
index.tsx /orders
[id].tsx /orders/42
features/
orders/
components/OrderCard.tsx used by orders and nothing else
useOrders.ts the feature's data hook
types.ts the feature's own types
profile/
components/AvatarRow.tsx
useProfile.ts
components/ UI used by two or more features
Button.tsx
lib/ logic used by two or more features
api.ts
money.ts
assets/
app.json
tsconfig.jsonPath aliases instead of ../../..
Expo CLI reads these aliases for bundling as well as type checking, so @/lib/money resolves at runtime too. Two files importing the same module now spell it the same way regardless of their depth, which makes moving a file a one-line change rather than a search and replace.
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}A route file that only wires things up
The route owns the URL and nothing else: data comes from a feature hook, pixels come from a feature component. On screen this is a padded, scrollable column of order cards. When a screen file grows past a page, this is the split to make first.
// src/app/orders/index.tsx
import { FlatList, StyleSheet } from "react-native";
import OrderCard from "@/features/orders/components/OrderCard";
import { useOrders } from "@/features/orders/useOrders";
export default function OrdersRoute() {
const orders = useOrders();
return (
<FlatList
// FlatList, not ScrollView: the list length is whatever the server returns
data={orders}
keyExtractor={(order) => order.reference}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<OrderCard
reference={item.reference}
totalPence={item.totalPence}
status={item.status}
/>
)}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16, gap: 12 },
});
Discussion