A comprehensive development guide for building cross-platform mobile and web applications using TypeScript, React Native, Expo, Tamagui, Supabase, and modern development tools
This agents.md example showcases industry best practices for AI agent instruction. The agents.md example provides a comprehensive template that you can adapt for your specific project requirements.
Every element in this agents.md example has been carefully designed to optimize OpenAI Codex performance and ensure consistent AI agent behavior across your development workflow.
To use this agents.md example in your project, download the template and customize it according to your specific needs. This agents.md example serves as a solid foundation for your AI agent configuration.
Study the structure and conventions used in this agents.md example to understand how successful projects implement AI agent instruction for optimal results.
This comprehensive guide outlines best practices for developing cross-platform mobile and web applications using TypeScript, React Native, Expo, Tamagui for UI, Supabase for backend services, and modern development tools. The guide emphasizes functional programming patterns, cross-platform compatibility, and scalable monorepo architecture with proper state management and internationalization.
# Install global tools
npm install -g @expo/cli eas-cli
# Create new Expo project with TypeScript
npx create-expo-app --template
# Install core dependencies
npx expo install expo-router expo-constants expo-linking
npm install @tamagui/core @tamagui/config @tamagui/animations-react-native
npm install @supabase/supabase-js
npm install zustand @tanstack/react-query
npm install zod
npm install solito
npm install i18next react-i18next expo-localization
# Development dependencies
npm install -D typescript @types/react @types/react-native
npm install -D turbo
expo-app/
├── apps/ # Applications
│ ├── expo/ # React Native app
│ │ ├── app/ # App Router (Expo Router)
│ │ │ ├── (tabs)/
│ │ │ ├── auth/
│ │ │ ├── _layout.tsx
│ │ │ └── index.tsx
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── utils/
│ │ └── app.json
│ └── next/ # Next.js web app
│ ├── pages/
│ ├── components/
│ └── next.config.js
├── packages/ # Shared packages
│ ├── ui/ # Tamagui components
│ │ ├── src/
│ │ │ ├── components/
│ │ │ ├── themes/
│ │ │ └── index.ts
│ │ └── package.json
│ ├── api/ # Supabase client
│ │ ├── src/
│ │ │ ├── client.ts
│ │ │ ├── auth.ts
│ │ │ └── types.ts
│ │ └── package.json
│ ├── store/ # Zustand stores
│ │ ├── src/
│ │ │ ├── auth-store.ts
│ │ │ ├── user-store.ts
│ │ │ └── index.ts
│ │ └── package.json
│ └── config/ # Shared configuration
│ ├── src/
│ │ ├── env.ts
│ │ └── constants.ts
│ └── package.json
├── turbo.json # Turbo configuration
├── package.json # Root package.json
└── tsconfig.json # TypeScript configuration
// Use functional and declarative programming patterns
// Prefer interfaces over types for object shapes
interface UserProfile {
id: string;
email: string;
name: string;
avatarUrl?: string;
createdAt: Date;
updatedAt: Date;
}
// Use descriptive variable names with auxiliary verbs
interface AuthState {
isLoading: boolean;
hasError: boolean;
isAuthenticated: boolean;
user: UserProfile | null;
}
// Use function keyword for pure functions
function formatUserDisplayName(user: UserProfile): string {
return user.name || user.email.split("@")[0];
}
// Favor named exports
export { UserProfile, AuthState, formatUserDisplayName };
// packages/api/src/types.ts
import { z } from "zod";
// Zod schemas for validation and type inference
export const UserProfileSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(100),
avatarUrl: z.string().url().optional(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
password: z.string().min(8),
});
export const UpdateUserSchema = CreateUserSchema.partial().omit({
password: true,
});
// Type inference from Zod schemas
export type UserProfile = z.infer<typeof UserProfileSchema>;
export type CreateUserRequest = z.infer<typeof CreateUserSchema>;
export type UpdateUserRequest = z.infer<typeof UpdateUserSchema>;
// Avoid enums; use literal types instead
export const UserRole = {
ADMIN: "admin",
USER: "user",
MODERATOR: "moderator",
} as const;
export type UserRoleType = (typeof UserRole)[keyof typeof UserRole];
// packages/ui/src/components/Button.tsx
import { Button as TamaguiButton, ButtonProps, styled } from "@tamagui/core";
import { forwardRef } from "react";
interface CustomButtonProps extends ButtonProps {
variant?: "primary" | "secondary" | "outline";
size?: "small" | "medium" | "large";
isLoading?: boolean;
}
const StyledButton = styled(TamaguiButton, {
borderRadius: "$4",
fontWeight: "600",
variants: {
variant: {
primary: {
backgroundColor: "$blue10",
color: "$white",
hoverStyle: { backgroundColor: "$blue11" },
},
secondary: {
backgroundColor: "$gray4",
color: "$gray12",
hoverStyle: { backgroundColor: "$gray5" },
},
outline: {
backgroundColor: "transparent",
borderWidth: 1,
borderColor: "$blue10",
color: "$blue10",
hoverStyle: { backgroundColor: "$blue2" },
},
},
size: {
small: {
height: "$3",
paddingHorizontal: "$3",
fontSize: "$3",
},
medium: {
height: "$4",
paddingHorizontal: "$4",
fontSize: "$4",
},
large: {
height: "$5",
paddingHorizontal: "$5",
fontSize: "$5",
},
},
},
defaultVariants: {
variant: "primary",
size: "medium",
},
});
export const Button = forwardRef<typeof TamaguiButton, CustomButtonProps>(
({ children, isLoading, disabled, ...props }, ref) => {
return (
<StyledButton
ref={ref}
disabled={disabled || isLoading}
opacity={isLoading ? 0.7 : 1}
{...props}
>
{isLoading ? "Loading..." : children}
</StyledButton>
);
}
);
Button.displayName = "Button";
// packages/ui/src/components/Screen.tsx
import { SafeAreaView } from "react-native-safe-area-context";
import { ScrollView, YStack, YStackProps } from "@tamagui/core";
import { ReactNode } from "react";
interface ScreenProps extends YStackProps {
children: ReactNode;
scrollable?: boolean;
safeArea?: boolean;
}
export function Screen({
children,
scrollable = false,
safeArea = true,
...props
}: ScreenProps) {
const content = (
<YStack flex={1} backgroundColor="$background" {...props}>
{scrollable ? (
<ScrollView showsVerticalScrollIndicator={false}>
<YStack padding="$4">{children}</YStack>
</ScrollView>
) : (
<YStack flex={1} padding="$4">
{children}
</YStack>
)}
</YStack>
);
if (safeArea) {
return <SafeAreaView style={{ flex: 1 }}>{content}</SafeAreaView>;
}
return content;
}
// packages/store/src/auth-store.ts
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { UserProfile } from "@repo/api";
interface AuthState {
user: UserProfile | null;
isLoading: boolean;
hasError: boolean;
isAuthenticated: boolean;
}
interface AuthActions {
setUser: (user: UserProfile | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: boolean) => void;
signOut: () => void;
reset: () => void;
}
type AuthStore = AuthState & AuthActions;
const initialState: AuthState = {
user: null,
isLoading: false,
hasError: false,
isAuthenticated: false,
};
export const useAuthStore = create<AuthStore>()(
devtools(
persist(
(set, get) => ({
...initialState,
setUser: (user) =>
set(
{ user, isAuthenticated: !!user, hasError: false },
false,
"auth/setUser"
),
setLoading: (isLoading) => set({ isLoading }, false, "auth/setLoading"),
setError: (hasError) =>
set({ hasError, isLoading: false }, false, "auth/setError"),
signOut: () => set({ ...initialState }, false, "auth/signOut"),
reset: () => set({ ...initialState }, false, "auth/reset"),
}),
{
name: "auth-store",
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
}
),
{ name: "AuthStore" }
)
);
// Selectors for optimized re-renders
export const useUser = () => useAuthStore((state) => state.user);
export const useIsAuthenticated = () =>
useAuthStore((state) => state.isAuthenticated);
export const useAuthLoading = () => useAuthStore((state) => state.isLoading);
// packages/api/src/hooks/use-user-queries.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { supabase } from "../client";
import { UserProfile, UpdateUserRequest, UpdateUserSchema } from "../types";
import { useAuthStore } from "@repo/store";
// Query keys factory
export const userKeys = {
all: ["users"] as const,
profile: (id: string) => [...userKeys.all, "profile", id] as const,
profiles: () => [...userKeys.all, "profiles"] as const,
};
// Get current user profile
export function useUserProfile() {
const user = useAuthStore((state) => state.user);
return useQuery({
queryKey: userKeys.profile(user?.id || ""),
queryFn: async () => {
if (!user?.id) throw new Error("No user ID");
const { data, error } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single();
if (error) throw error;
return data as UserProfile;
},
enabled: !!user?.id,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Update user profile
export function useUpdateUserProfile() {
const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser);
return useMutation({
mutationFn: async (data: UpdateUserRequest) => {
// Validate data with Zod
const validatedData = UpdateUserSchema.parse(data);
const { data: updatedUser, error } = await supabase
.from("profiles")
.update(validatedData)
.eq("id", data.id)
.select()
.single();
if (error) throw error;
return updatedUser as UserProfile;
},
onSuccess: (updatedUser) => {
// Update cache
queryClient.setQueryData(userKeys.profile(updatedUser.id), updatedUser);
// Update auth store
setUser(updatedUser);
},
onError: (error) => {
console.error("Failed to update profile:", error);
},
});
}
// packages/api/src/auth.ts
import { supabase } from "./client";
import { CreateUserRequest, CreateUserSchema } from "./types";
import { useAuthStore } from "@repo/store";
export class AuthService {
static async signUp(data: CreateUserRequest) {
// Validate input
const validatedData = CreateUserSchema.parse(data);
const { data: authData, error } = await supabase.auth.signUp({
email: validatedData.email,
password: validatedData.password,
options: {
data: {
name: validatedData.name,
},
},
});
if (error) throw error;
return authData;
}
static async signIn(email: string, password: string) {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
return data;
}
static async signOut() {
const { error } = await supabase.auth.signOut();
if (error) throw error;
// Clear auth store
useAuthStore.getState().signOut();
}
static async resetPassword(email: string) {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
});
if (error) throw error;
}
}
// Auth state listener
export function setupAuthListener() {
const { setUser, setLoading } = useAuthStore.getState();
supabase.auth.onAuthStateChange(async (event, session) => {
setLoading(true);
try {
if (session?.user) {
// Fetch user profile
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", session.user.id)
.single();
if (profile) {
setUser(profile);
}
} else {
setUser(null);
}
} catch (error) {
console.error("Auth state change error:", error);
setUser(null);
} finally {
setLoading(false);
}
});
}
// packages/ui/src/navigation/link.tsx
import { Link as SolitoLink } from "solito/link";
import { Text, TextProps } from "@tamagui/core";
import { ReactNode } from "react";
interface LinkProps extends TextProps {
href: string;
children: ReactNode;
replace?: boolean;
}
export function Link({ href, children, replace, ...textProps }: LinkProps) {
return (
<SolitoLink href={href} replace={replace}>
<Text
color="$blue10"
textDecorationLine="underline"
hoverStyle={{ color: "$blue11" }}
{...textProps}
>
{children}
</Text>
</SolitoLink>
);
}
// Usage in screens
export function HomeScreen() {
return (
<Screen>
<YStack space="$4">
<Text fontSize="$6" fontWeight="bold">
Welcome
</Text>
<Link href="/profile">Go to Profile</Link>
<Link href="/settings">Settings</Link>
</YStack>
</Screen>
);
}
// packages/config/src/i18n.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import * as Localization from "expo-localization";
// Translation resources
const resources = {
en: {
translation: {
welcome: "Welcome",
login: "Sign In",
logout: "Sign Out",
email: "Email",
password: "Password",
profile: "Profile",
settings: "Settings",
errors: {
required: "This field is required",
invalidEmail: "Please enter a valid email",
passwordTooShort: "Password must be at least 8 characters",
},
},
},
es: {
translation: {
welcome: "Bienvenido",
login: "Iniciar Sesión",
logout: "Cerrar Sesión",
email: "Correo Electrónico",
password: "Contraseña",
profile: "Perfil",
settings: "Configuración",
errors: {
required: "Este campo es obligatorio",
invalidEmail: "Por favor ingrese un email válido",
passwordTooShort: "La contraseña debe tener al menos 8 caracteres",
},
},
},
};
i18n.use(initReactI18next).init({
resources,
lng: Localization.locale.split("-")[0], // Get language code
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
export default i18n;
// Hook for translations
export { useTranslation } from "react-i18next";
// packages/api/src/stripe.ts
import { supabase } from "./client";
export interface SubscriptionPlan {
id: string;
name: string;
price: number;
interval: "month" | "year";
features: string[];
stripePriceId: string;
}
export const subscriptionPlans: SubscriptionPlan[] = [
{
id: "basic",
name: "Basic",
price: 9.99,
interval: "month",
features: ["Feature 1", "Feature 2"],
stripePriceId: "price_basic_monthly",
},
{
id: "pro",
name: "Pro",
price: 19.99,
interval: "month",
features: ["All Basic features", "Feature 3", "Feature 4"],
stripePriceId: "price_pro_monthly",
},
];
export class StripeService {
static async createCheckoutSession(priceId: string, userId: string) {
const { data, error } = await supabase.functions.invoke(
"create-checkout-session",
{
body: { priceId, userId },
}
);
if (error) throw error;
return data;
}
static async createPortalSession(customerId: string) {
const { data, error } = await supabase.functions.invoke(
"create-portal-session",
{
body: { customerId },
}
);
if (error) throw error;
return data;
}
static async getSubscriptionStatus(userId: string) {
const { data, error } = await supabase
.from("subscriptions")
.select("*")
.eq("user_id", userId)
.eq("status", "active")
.single();
if (error && error.code !== "PGRST116") throw error;
return data;
}
}
// Subscription hook
export function useSubscription() {
const user = useUser();
return useQuery({
queryKey: ["subscription", user?.id],
queryFn: () => StripeService.getSubscriptionStatus(user!.id),
enabled: !!user?.id,
});
}
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"outputs": []
},
"type-check": {
"dependsOn": ["^build"],
"outputs": []
},
"test": {
"outputs": ["coverage/**"]
},
"clean": {
"cache": false
}
}
}
// package.json (root)
{
"name": "expo-monorepo",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"type-check": "turbo run type-check",
"test": "turbo run test",
"clean": "turbo run clean",
"gen": "turbo gen"
},
"devDependencies": {
"turbo": "latest",
"@turbo/gen": "latest",
"typescript": "^5.0.0"
}
}
// packages/ui/src/components/ErrorBoundary.tsx
import React, { Component, ReactNode } from "react";
import { YStack, Text, Button } from "@tamagui/core";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Error caught by boundary:", error, errorInfo);
// Log to error reporting service
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<YStack
flex={1}
justifyContent="center"
alignItems="center"
padding="$4"
>
<Text fontSize="$6" fontWeight="bold" marginBottom="$4">
Something went wrong
</Text>
<Text textAlign="center" marginBottom="$4" color="$gray11">
We're sorry, but something unexpected happened.
</Text>
<Button
onPress={() => this.setState({ hasError: false, error: undefined })}
>
Try Again
</Button>
</YStack>
);
}
return this.props.children;
}
}
// packages/ui/src/hooks/use-debounce.ts
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// packages/ui/src/hooks/use-intersection-observer.ts
import { useEffect, useRef, useState } from "react";
export function useIntersectionObserver(
options: IntersectionObserverInit = {}
) {
const [isIntersecting, setIsIntersecting] = useState(false);
const ref = useRef<any>(null);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
}, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [options]);
return { ref, isIntersecting };
}
This comprehensive guide provides a solid foundation for building scalable, maintainable cross-platform applications using TypeScript, React Native, Expo, and modern development tools with proper state management, backend integration, and performance optimization.