plugins/lisa-expo-cursor/skills/expo-router-best-practices/SKILL.md
This skill should be used when creating new routes, configuring navigation layouts, implementing deep linking, or organizing the app/ directory structure in Expo Router projects. It provides best practices for file-based routing patterns.
npx skillsauth add codyswanngt/lisa expo-router-best-practicesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill provides guidance for implementing file-based routing with Expo Router following established best practices and official documentation patterns.
Route files in the app/ directory should be minimal pass-throughs to feature screen components. Business logic and complex UI components belong in feature directories, not route files.
// app/players/[playerId]/compare.tsx - CORRECT
import { Main } from "@/features/compare-players/screens/Main";
/**
* Compare players route.
* URL: /players/[playerId]/compare
*/
export default function CompareScreen() {
return <Main />;
}
// app/players/[playerId]/compare.tsx - INCORRECT
export default function CompareScreen() {
const { playerId } = useLocalSearchParams();
const [data, setData] = useState(null);
// ... 200 lines of business logic
return <ComplexUI />;
}
Use descriptive names for route components, not generic names.
// CORRECT
export default function CompareScreen() { ... }
export default function PlayerDetailScreen() { ... }
export default function SettingsScreen() { ... }
// INCORRECT
export default function Screen() { ... }
export default function Page() { ... }
export default function Index() { ... } // only acceptable for index.tsx files
Include the URL pattern in route file documentation.
/**
* Player detail route.
* URL: /players/[playerId]
*/
export default function PlayerDetailScreen() {
return <Main />;
}
app/
├── _layout.tsx # Root layout (initialization, providers)
├── index.tsx # Default route (/)
├── +not-found.tsx # 404 handling
├── +html.tsx # Web HTML customization (optional)
├── (tabs)/ # Tab navigator group
│ ├── _layout.tsx # Tab configuration
│ ├── index.tsx # Default tab
│ ├── feed/ # Stack within tab
│ │ ├── _layout.tsx
│ │ ├── index.tsx
│ │ └── [postId].tsx
│ └── settings.tsx
├── (auth)/ # Auth screens group
│ ├── sign-in.tsx
│ └── create-account.tsx
└── modal.tsx # Modal route
| Notation | Purpose | Example | URL |
| ---------------- | --------------------------- | -------------------- | -------- |
| file.tsx | Static route | about.tsx | /about |
| [param].tsx | Dynamic route | [userId].tsx | /123 |
| [...slug].tsx | Catch-all route | [...path].tsx | /a/b/c |
| (group)/ | Route group (no URL impact) | (tabs)/ | / |
| index.tsx | Default route | feed/index.tsx | /feed |
| _layout.tsx | Layout definition | (tabs)/_layout.tsx | - |
| +not-found.tsx | 404 handler | +not-found.tsx | - |
The root _layout.tsx replaces App.jsx/tsx. Place initialization code here.
// app/_layout.tsx
import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useEffect(() => {
if (loaded) {
SplashScreen.hide();
}
}, [loaded]);
if (!loaded) {
return null;
}
return <Stack />;
}
// app/products/_layout.tsx
import { Stack } from "expo-router";
export const unstable_settings = {
initialRouteName: "index",
};
export default function ProductsLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Products" }} />
<Stack.Screen name="[productId]" options={{ headerShown: false }} />
</Stack>
);
}
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
export default function TabLayout() {
return (
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color }) => (
<MaterialIcons size={28} name="home" color={color} />
),
}}
/>
<Tabs.Screen name="feed" options={{ title: "Feed" }} />
<Tabs.Screen name="settings" options={{ title: "Settings" }} />
</Tabs>
);
}
// app/_layout.tsx
import { Stack } from "expo-router";
import { useAuthState } from "@/hooks/useAuthState";
export default function RootLayout() {
const { isLoggedIn } = useAuthState();
return (
<Stack>
<Stack.Protected guard={isLoggedIn}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
</Stack.Protected>
<Stack.Protected guard={!isLoggedIn}>
<Stack.Screen name="sign-in" />
<Stack.Screen name="create-account" />
</Stack.Protected>
</Stack>
);
}
import { Link } from "expo-router";
// Basic link
<Link href="/about">About</Link>
// With custom component
<Link href="/profile" asChild>
<Pressable>
<Text>Profile</Text>
</Pressable>
</Link>
// Dynamic route
<Link href={{ pathname: "/user/[id]", params: { id: "123" } }}>
View User
</Link>
// With prefetching
<Link href="/heavy-page" prefetch>Heavy Page</Link>
import { useRouter } from "expo-router";
export default function Component() {
const router = useRouter();
const handleNavigate = () => {
// Navigate (adds to history)
router.navigate("/about");
// Push (always adds to stack)
router.push("/details");
// Replace (no back navigation)
router.replace("/home");
// Back
router.back();
// Dynamic route
router.navigate({
pathname: "/user/[id]",
params: { id: "123" },
});
};
return <Button onPress={handleNavigate} title="Navigate" />;
}
Always validate parameters before navigation to prevent broken URLs.
const handleNavigation = useCallback(() => {
if (!entityId) {
console.error("Cannot navigate: entity ID is missing");
return;
}
router.push(`/players/${entityId}`);
}, [entityId, router]);
import { useLocalSearchParams, useGlobalSearchParams } from "expo-router";
export default function UserPage() {
// Local params (current route only)
const { id, tab } = useLocalSearchParams<{ id: string; tab?: string }>();
// Global params (entire URL)
const globalParams = useGlobalSearchParams();
return <Text>User ID: {id}</Text>;
}
In app.json or app.config.js:
{
"expo": {
"scheme": "myapp"
}
}
Ensure proper back navigation when deep linking.
// app/feed/_layout.tsx
export const unstable_settings = {
initialRouteName: "index",
};
export default function FeedLayout() {
return <Stack />;
}
// Forces initial route to load first
<Link href="/feed/post/123" withAnchor>
View Post
</Link>
app/
├── (tabs)/
│ ├── _layout.tsx # Tab navigator
│ ├── index.tsx # Home tab
│ ├── feed/ # Feed tab with stack
│ │ ├── _layout.tsx # Stack navigator
│ │ ├── index.tsx # Feed list
│ │ └── [postId].tsx # Post detail
│ └── settings.tsx # Settings tab
app/
├── (tabs)/
│ ├── _layout.tsx
│ ├── (feed)/ # Feed tab group
│ │ └── index.tsx
│ ├── (search)/ # Search tab group
│ │ └── index.tsx
│ └── (feed,search)/ # Shared between both
│ └── users/
│ └── [userId].tsx
// app/_layout.tsx
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="modal"
options={{
presentation: "modal",
animation: "slide_from_bottom",
}}
/>
</Stack>
Route files should only import and render feature components.
Avoid nesting stacks within stacks unnecessarily. Use route groups instead.
Always set initialRouteName in stack layouts for proper deep link behavior.
Use typed routes or constants instead of string literals.
// AVOID
router.push("/players/123/compare");
// PREFER
router.push({
pathname: "/players/[playerId]/compare",
params: { playerId: "123" },
});
// AVOID
const width = window.innerWidth;
// PREFER
import { useWindowDimensions } from "react-native";
const { width } = useWindowDimensions();
For detailed documentation on specific topics, refer to:
references/official-docs.md - Condensed official Expo Router documentationscripts/generate-route.py - Route scaffolding scriptOfficial Documentation: https://docs.expo.dev/router/introduction/
tools
--- name: harper-realtime description: This skill should be used when adding or troubleshooting Harper (HarperDB/Fabric) real-time behavior: MQTT topics, WebSocket resource subscriptions, resource publish/subscribe handlers, SSE-style streaming routes, and local subscriber verification. Pairs with harper-resources, harper-config-yaml, harper-schema-graphql, and harper-build-and-deploy. --- # Harper Realtime ## Overview Harper exposes live data through the same Resource model used for REST and
tools
--- name: harper-realtime description: This skill should be used when adding or troubleshooting Harper (HarperDB/Fabric) real-time behavior: MQTT topics, WebSocket resource subscriptions, resource publish/subscribe handlers, SSE-style streaming routes, and local subscriber verification. Pairs with harper-resources, harper-config-yaml, harper-schema-graphql, and harper-build-and-deploy. --- # Harper Realtime ## Overview Harper exposes live data through the same Resource model used for REST and
tools
--- name: harper-realtime description: This skill should be used when adding or troubleshooting Harper (HarperDB/Fabric) real-time behavior: MQTT topics, WebSocket resource subscriptions, resource publish/subscribe handlers, SSE-style streaming routes, and local subscriber verification. Pairs with harper-resources, harper-config-yaml, harper-schema-graphql, and harper-build-and-deploy. --- # Harper Realtime ## Overview Harper exposes live data through the same Resource model used for REST and
tools
--- name: harper-realtime description: This skill should be used when adding or troubleshooting Harper (HarperDB/Fabric) real-time behavior: MQTT topics, WebSocket resource subscriptions, resource publish/subscribe handlers, SSE-style streaming routes, and local subscriber verification. Pairs with harper-resources, harper-config-yaml, harper-schema-graphql, and harper-build-and-deploy. --- # Harper Realtime ## Overview Harper exposes live data through the same Resource model used for REST and