FlutterReact Native

How I Structure My Cross-Platform Projects (Flutter + React Native)

20250416 1622 Cross Platform Project Structure Simple Compose 01jrzfjcy8fpjskz9m9wp8m0hw 1

Introduction

When working on multiple mobile projects using both Flutter and React Native, one of the biggest challenges is maintaining clean, scalable, and consistent folder structures across different codebases. Without a standardized approach, switching between projects becomes disorienting—you waste time remembering where things live instead of building features. Over the past few years working on projects for startups and enterprises, I’ve refined a project structure that works well across both platforms. This consistency makes it easier to onboard new developers, scale features predictably, and share architectural knowledge between teams. In this comprehensive guide, I’ll walk you through how I organize my cross-platform apps, with detailed examples for both Flutter and React Native, including state management patterns, navigation setup, and testing structure.

Why Project Structure Matters

A well-structured codebase provides compounding benefits:

Reduced cognitive load: Developers find files instantly because the structure is predictable. Whether you’re debugging auth in Flutter or React Native, it’s in features/auth/.

Separation of concerns: Business logic stays separate from UI. Data fetching stays separate from state management. Each layer is testable independently.

Easier scaling: Adding a new feature means creating a new folder with the same structure. No decisions about where things go.

Team collaboration: Multiple developers work on different features without merge conflicts. The structure naturally isolates work.

Flutter Project Structure (Feature-First with Clean Architecture)

Here’s the complete structure I use for production Flutter apps:

lib/
├── app/                      # App configuration and entry
│   ├── app.dart              # MaterialApp configuration
│   ├── router.dart           # GoRouter setup
│   └── injection.dart        # GetIt dependency injection
├── core/                     # Shared infrastructure
│   ├── constants/
│   │   ├── api_constants.dart
│   │   └── app_constants.dart
│   ├── errors/
│   │   ├── exceptions.dart
│   │   └── failures.dart
│   ├── network/
│   │   ├── api_client.dart
│   │   └── interceptors.dart
│   ├── theme/
│   │   ├── app_theme.dart
│   │   └── app_colors.dart
│   ├── utils/
│   │   └── validators.dart
│   └── widgets/              # Shared widgets
│       ├── app_button.dart
│       └── app_text_field.dart
├── features/                 # Feature modules
│   ├── auth/
│   │   ├── data/
│   │   │   ├── datasources/
│   │   │   │   ├── auth_local_datasource.dart
│   │   │   │   └── auth_remote_datasource.dart
│   │   │   ├── models/
│   │   │   │   └── user_model.dart
│   │   │   └── repositories/
│   │   │       └── auth_repository_impl.dart
│   │   ├── domain/
│   │   │   ├── entities/
│   │   │   │   └── user.dart
│   │   │   ├── repositories/
│   │   │   │   └── auth_repository.dart
│   │   │   └── usecases/
│   │   │       ├── login_usecase.dart
│   │   │       └── register_usecase.dart
│   │   └── presentation/
│   │       ├── controllers/
│   │       │   └── auth_controller.dart
│   │       ├── screens/
│   │       │   ├── login_screen.dart
│   │       │   └── register_screen.dart
│   │       └── widgets/
│   │           └── login_form.dart
│   ├── home/
│   ├── products/
│   └── profile/
└── main.dart

test/
├── features/
│   ├── auth/
│   │   ├── data/
│   │   ├── domain/
│   │   └── presentation/
├── core/
└── mocks/

Flutter Feature Implementation Example

// lib/features/auth/domain/entities/user.dart
class User {
  final String id;
  final String email;
  final String name;
  final String? avatarUrl;

  const User({
    required this.id,
    required this.email,
    required this.name,
    this.avatarUrl,
  });
}

// lib/features/auth/domain/repositories/auth_repository.dart
abstract class AuthRepository {
  Future> login(String email, String password);
  Future> register(String email, String password, String name);
  Future> logout();
}

// lib/features/auth/domain/usecases/login_usecase.dart
class LoginUseCase {
  final AuthRepository repository;

  LoginUseCase(this.repository);

  Future> call(LoginParams params) {
    return repository.login(params.email, params.password);
  }
}

// lib/features/auth/data/models/user_model.dart
class UserModel extends User {
  const UserModel({
    required super.id,
    required super.email,
    required super.name,
    super.avatarUrl,
  });

  factory UserModel.fromJson(Map json) {
    return UserModel(
      id: json['id'],
      email: json['email'],
      name: json['name'],
      avatarUrl: json['avatar_url'],
    );
  }

  Map toJson() => {
    'id': id,
    'email': email,
    'name': name,
    'avatar_url': avatarUrl,
  };
}

// lib/features/auth/presentation/controllers/auth_controller.dart
@riverpod
class AuthController extends _$AuthController {
  @override
  FutureOr build() async {
    final getCurrentUser = ref.read(getCurrentUserUseCaseProvider);
    final result = await getCurrentUser();
    return result.fold((_) => null, (user) => user);
  }

  Future login(String email, String password) async {
    state = const AsyncLoading();
    final loginUseCase = ref.read(loginUseCaseProvider);
    final result = await loginUseCase(LoginParams(email: email, password: password));
    state = result.fold(
      (failure) => AsyncError(failure, StackTrace.current),
      (user) => AsyncData(user),
    );
  }
}

React Native Project Structure (Feature-First with Hooks)

Here’s the equivalent structure for React Native with TypeScript:

src/
├── app/                      # App configuration
│   ├── App.tsx               # Root component
│   ├── providers.tsx         # Context providers wrapper
│   └── store.ts              # Zustand store setup
├── config/                   # Configuration
│   ├── api.ts                # API client setup
│   ├── constants.ts          # App constants
│   └── theme.ts              # Theme configuration
├── navigation/               # Navigation setup
│   ├── RootNavigator.tsx
│   ├── AuthNavigator.tsx
│   ├── MainNavigator.tsx
│   └── types.ts              # Navigation types
├── components/               # Shared components
│   ├── ui/
│   │   ├── Button.tsx
│   │   ├── TextField.tsx
│   │   └── Card.tsx
│   └── layout/
│       ├── Screen.tsx
│       └── Header.tsx
├── hooks/                    # Shared hooks
│   ├── useAuth.ts
│   └── useApi.ts
├── utils/                    # Utilities
│   ├── validators.ts
│   ├── formatters.ts
│   └── storage.ts
├── features/                 # Feature modules
│   ├── auth/
│   │   ├── api/
│   │   │   └── authApi.ts
│   │   ├── hooks/
│   │   │   ├── useLogin.ts
│   │   │   └── useRegister.ts
│   │   ├── screens/
│   │   │   ├── LoginScreen.tsx
│   │   │   └── RegisterScreen.tsx
│   │   ├── components/
│   │   │   └── LoginForm.tsx
│   │   ├── store/
│   │   │   └── authStore.ts
│   │   └── types.ts
│   ├── home/
│   ├── products/
│   └── profile/
└── types/                    # Global types
    ├── api.ts
    └── navigation.ts

__tests__/
├── features/
│   ├── auth/
│   │   ├── hooks/
│   │   └── screens/
├── components/
└── utils/

React Native Feature Implementation Example

// src/features/auth/types.ts
export interface User {
  id: string;
  email: string;
  name: string;
  avatarUrl?: string;
}

export interface LoginCredentials {
  email: string;
  password: string;
}

export interface AuthState {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
}

// src/features/auth/api/authApi.ts
import { apiClient } from '@/config/api';
import { User, LoginCredentials } from '../types';

export const authApi = {
  async login(credentials: LoginCredentials): Promise<{ user: User; token: string }> {
    const response = await apiClient.post('/auth/login', credentials);
    return response.data;
  },

  async register(data: { email: string; password: string; name: string }): Promise {
    const response = await apiClient.post('/auth/register', data);
    return response.data.user;
  },

  async getCurrentUser(): Promise {
    const response = await apiClient.get('/auth/me');
    return response.data.user;
  },

  async logout(): Promise {
    await apiClient.post('/auth/logout');
  },
};

// src/features/auth/store/authStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { User, AuthState } from '../types';

interface AuthStore extends AuthState {
  setUser: (user: User | null) => void;
  setLoading: (loading: boolean) => void;
  logout: () => void;
}

export const useAuthStore = create()(
  persist(
    (set) => ({
      user: null,
      isAuthenticated: false,
      isLoading: false,

      setUser: (user) =>
        set({ user, isAuthenticated: !!user }),

      setLoading: (isLoading) => set({ isLoading }),

      logout: () =>
        set({ user: null, isAuthenticated: false }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => AsyncStorage),
      partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
    }
  )
);

// src/features/auth/hooks/useLogin.ts
import { useMutation } from '@tanstack/react-query';
import { authApi } from '../api/authApi';
import { useAuthStore } from '../store/authStore';
import { LoginCredentials } from '../types';
import { setAuthToken } from '@/config/api';

export function useLogin() {
  const setUser = useAuthStore((state) => state.setUser);

  return useMutation({
    mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
    onSuccess: (data) => {
      setAuthToken(data.token);
      setUser(data.user);
    },
  });
}

// src/features/auth/screens/LoginScreen.tsx
import React, { useState } from 'react';
import { View, StyleSheet, Alert } from 'react-native';
import { Screen, TextField, Button } from '@/components/ui';
import { useLogin } from '../hooks/useLogin';
import { useNavigation } from '@react-navigation/native';

export function LoginScreen() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const navigation = useNavigation();
  const { mutate: login, isPending } = useLogin();

  const handleLogin = () => {
    if (!email || !password) {
      Alert.alert('Error', 'Please fill in all fields');
      return;
    }

    login(
      { email, password },
      {
        onSuccess: () => navigation.reset({ index: 0, routes: [{ name: 'Main' }] }),
        onError: (error) => Alert.alert('Login Failed', error.message),
      }
    );
  };

  return (
    
      
        
        
        

Side-by-Side Structure Comparison

Concept Flutter Location React Native Location
Feature modules lib/features/ src/features/
Shared widgets lib/core/widgets/ src/components/
Navigation lib/app/router.dart src/navigation/
API client lib/core/network/ src/config/api.ts
State management Riverpod controllers Zustand stores + React Query
Business logic UseCases in domain/ Hooks in feature/hooks/
Data models data/models/ types.ts per feature
Dependency injection GetIt in app/injection.dart Context providers

Monorepo Setup for Multiple Apps

When managing multiple apps, use monorepo tools:

# Flutter: Use Melos
# melos.yaml
name: my_workspace
packages:
  - apps/*
  - packages/*

scripts:
  analyze:
    run: melos exec -- flutter analyze
  test:
    run: melos exec -- flutter test

# React Native: Use Nx or Turborepo
# turbo.json
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "build/**"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {}
  }
}

Common Mistakes to Avoid

Mixing feature code: Never import from one feature into another. Use shared modules in core/ or components/ instead.

Inconsistent naming: Use the same feature names across platforms. If it’s “auth” in Flutter, it’s “auth” in React Native.

Flat component folders: As shared components grow, organize into subfolders (ui/, layout/, forms/).

Skipping the domain layer (Flutter): Direct API calls from UI create tight coupling. Use the repository pattern.

Global state for everything (React Native): Not everything needs global state. Use local state and React Query for server state.

Conclusion

Whether you’re building in Flutter or React Native, a feature-first structure keeps your project modular, testable, and easier to scale. The key is consistency—use the same patterns and naming conventions across both platforms so developers can switch contexts quickly. Each feature owns its data, logic, and UI in isolation. Shared code lives in dedicated folders. Navigation and app configuration stay separate from features. This structure has held up across client projects, startup MVPs, and multi-developer teams. Start with this foundation and adjust as your specific needs emerge. For more on Flutter project architecture, check out our guide on Scalable Flutter Project Structure. For React Native patterns, explore the official React Native documentation.

Leave a Comment