Expo Router boilerplate organized with a feature-based, vertical-slice architecture.
The project is structured so each feature owns its UI, hooks, services, and types in one place. That keeps routes thin, makes features easier to delete or move, and gives new engineers a clear place to start.
app/ # Expo Router files only
(auth)/ # route wrappers for auth flows
(tabs)/ # authenticated shell route wrappers
_layout.tsx # app providers + root stack
index.tsx # redirect only
modal.tsx # imports one feature screen
src/
features/ # vertical slices
auth/
home/
matches/
products/
chat/
team/
profile/
design-system/
shared/ # reusable cross-feature code
components/ # design-system primitives
hooks/ # generic hooks
services/api/ # shared fetch + react-query helpers
theme/ # shared tokens and navigation theme
utils/ # generic helpersapp/should only compose route wrappers and import from feature barrels.- Features expose a public API through
index.tsand hide internal details. - Shared code must stay feature-agnostic.
- Cross-feature data fetching goes through the shared API layer, then feature hooks.
- If a feature can be deleted by removing one folder, the structure is healthy.
Use feature barrels:
import { LoginScreen } from '@features/auth';
import { ProductsScreen } from '@features/products';Use shared primitives through shared entry points:
import { AppCard, AppText } from '@shared/components';
import { apiFetch, createApiQueryOptions } from '@shared/services/api';Avoid deep imports from another feature's internal files.
Defined in tsconfig.json:
{
"compilerOptions": {
"paths": {
"@/*": ["./*"],
"@features/*": ["src/features/*"],
"@shared/*": ["src/shared/*"]
}
}
}The shared API layer lives in src/shared/services/api.
apiFetch<T>()handles requests and authorization headers.createApiQueryOptions()gives a consistent base for React Query.- Feature services define endpoint-specific functions.
- Feature hooks call
useQuery/useMutation. - Feature screens render state.
Example flow from the Products feature:
// src/features/products/services/products-service.ts
export async function fetchProducts() {
return apiFetch<ProductsResponse>('https://dummyjson.com/products');
}// src/features/products/hooks/use-products.ts
export function useProducts() {
return useQuery({
...createApiQueryOptions<ProductsResponse>(['products'], 'https://dummyjson.com/products'),
queryFn: fetchProducts,
});
}// app/(tabs)/products.tsx
import { ProductsScreen } from '@features/products';
export default function ProductsRoute() {
return <ProductsScreen />;
}For presentation purposes, the app now includes a Products tab that fetches:
https://dummyjson.com/products
The response is rendered through the current design pattern:
- route wrapper in app/(tabs)/products.tsx
- feature screen in src/features/products/components/products-screen.tsx
- feature hook in src/features/products/hooks/use-products.ts
- feature service in src/features/products/services/products-service.ts
- Install dependencies
npm install- Set the required environment variables
EXPO_PUBLIC_API_BASE_URL=...
EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID=...
EXPO_PUBLIC_GOOGLE_IOS_URL_SCHEME=...
EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=...
EXPO_PUBLIC_MOCK_MATCHES_LIST_RESPONSE=success
EXPO_PUBLIC_MOCK_DISCOVERY_REWIND_RESPONSE=off
EXPO_PUBLIC_MOCK_SUPERLIKE_NO_BOOST=false
EXPO_PUBLIC_MOCK_SPOTLIGHT_ACTIVATION_RESPONSE=off
EXPO_PUBLIC_REVENUECAT_ENABLED=false
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=goog_...
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=appl_...
EXPO_PUBLIC_REVENUECAT_DISCOVERY_BOOSTS_OFFERING_ID=discovery_boosts
EXPO_PUBLIC_REVENUECAT_DISCOVERY_SPOTLIGHT_OFFERING_ID=discovery_spotlight
EXPO_PUBLIC_SUPABASE_ANON_KEY=...
EXPO_PUBLIC_SUPABASE_URL=...Set EXPO_PUBLIC_MOCK_SUPERLIKE_NO_BOOST=true in development to force super_like to throw the
same 409 DISCOVERY_SUPER_LIKE_REQUIRES_BOOST payload as the backend denial flow and open the
RevenueCat paywall.
Set EXPO_PUBLIC_REVENUECAT_DISCOVERY_BOOSTS_OFFERING_ID if your boost paywall offering uses a
different identifier than discovery_boosts.
Set EXPO_PUBLIC_REVENUECAT_DISCOVERY_SPOTLIGHT_OFFERING_ID if your spotlight paywall offering
uses a different identifier than discovery_spotlight.
Set EXPO_PUBLIC_REVENUECAT_ENABLED=false to completely skip RevenueCat initialization for a build.
When you are ready to ship purchases, switch it back to true and provide the real goog_...
or appl_... public SDK key for that platform.
RevenueCat release builds should use the platform public SDK keys from your RevenueCat project
such as goog_... for Android and appl_... for iOS. If no real SDK key is configured, or if a
release build is still pointed at a test_... key, the app now leaves RevenueCat disabled instead
of trying to initialize the purchase SDK and crashing at runtime.
Set EXPO_PUBLIC_MOCK_MATCHES_LIST_RESPONSE=success in development to render the typed mock
matches list response while the backend endpoint is not ready yet.
Set EXPO_PUBLIC_MOCK_DISCOVERY_REWIND_RESPONSE to success, premium_required, or
not_available in development to mock the rewind API path on the discovery deck.
Set EXPO_PUBLIC_MOCK_SPOTLIGHT_ACTIVATION_RESPONSE to success, no_credit, or
already_active in development to mock the spotlight activation API path on the Matches screen.
- Start Expo
npx expo start- Open the authenticated shell and visit the
Productstab to see the React Query example.
The Google login path now creates a Supabase session directly and bypasses the email/WhatsApp verification screens. Email/password login still uses the existing backend verification flow.
Set up Supabase first:
- In Supabase Auth, enable Google and use the same Google project/client family as the app.
- Run supabase/chat-experiment-setup.sql in the Supabase SQL editor.
- Make sure
messagesandconversation_summariesare included in thesupabase_realtimepublication. - If you use channel Presence/Broadcast, keep the
realtime.messagespolicies from the setup script in place so room members can joinroom:<uuid>topics.
The inbox list now reads from conversation_summaries, a per-user summary table maintained by
Postgres triggers. Message history still comes from messages, and the active room still subscribes
at room:<conversationId> for message inserts, typing, and presence.
For the planned Figma-style chat UI database changes, see supabase/chat-figma-db-spec.md.
If your backend already uses public.users as the profile table, use
supabase/chat-figma-backend-handoff.md
as the handoff spec and do not add a duplicate profiles table.
To apply the first concrete schema step for that design, run supabase/chat-figma-schema.sql after the base chat setup script.
To extend chat from text-only messages to image/video/file messages, run supabase/chat-media-message-support.sql.
- Run the app on an iPhone simulator and an Android emulator.
- Sign in with a different Google account on each device.
- In Supabase SQL editor, run:
select id, email, created_at
from auth.users
order by created_at desc;- Copy the two user IDs and replace
USER_ID_ACCOUNT_A/USER_ID_ACCOUNT_Bin the seed block inside supabase/chat-experiment-setup.sql. - Open
Google Test Roomon both devices. - Send a message from device A and confirm device B receives it in realtime.
- Send a message from device B and confirm device A receives it in realtime.
- Start typing on one device and confirm the other device sees the typing indicator.
- Background one device and confirm presence changes on the other device.
- Restart the app and confirm chat history still loads from Supabase.
- The shared API layer already supports bearer-token injection when auth exists.
- The Products demo uses the exact same shared fetch/query foundation as future real endpoints.
- The current app uses Expo Router, React Query, SecureStore, NativeWind, and feature barrels together.
- The discovery consumables contract for
CON-60is documented in docs/discovery-consumables-contract.md.