Cross-Platform Development in 2026

·17 min read·Mobile Developmentintermediate

With device fragmentation growing and user expectations rising, shipping native-quality apps across iOS, Android, desktop, and web from one codebase is no longer theoretical.

A developer workstation with a Flutter app running on an Android phone, an iOS simulator, and a web browser side-by-side, showing shared UI components across platforms

After a decade of twists, cross-platform development has settled into pragmatic patterns rather than ideological battles. In 2026, the landscape is dominated by mature, production-ready frameworks such as React Native, Flutter, Kotlin Multiplatform (KMP), and a resurgent .NET MAUI. The big shift has been from “Can we do this?” to “Which approach best matches our product, team, and timeline?” As a developer who has shipped several cross-platform apps to real users, I’ve learned that the right choice often depends less on the language itself and more on the platform constraints, team expertise, and the long-term maintenance model.

In this post, we’ll walk through the current state of cross-platform development, compare major options with real-world context, and dive into practical code you can adapt. You’ll see how these frameworks are used in production, where they shine, and where they don’t. I’ll also share hard-won lessons from projects where an early decision saved weeks—or cost us debugging time we didn’t budget for.

Where cross-platform stands today

Cross-platform has matured into a set of distinct strategies rather than a single approach. At a high level, you can group the major players into these buckets:

  • JavaScript/TypeScript with React Native or Expo, targeting iOS, Android, and increasingly web and desktop.
  • Dart with Flutter, targeting iOS, Android, web, Windows, macOS, and Linux from a single UI toolkit.
  • Kotlin with Kotlin Multiplatform, sharing business logic across Android and iOS while keeping native UIs.
  • C# with .NET MAUI, extending Xamarin’s reach to mobile and desktop with .NET’s ecosystem.

These approaches aren’t mutually exclusive. It’s common to see hybrid architectures: a React Native app calling into a KMP module for shared domain logic, or a Flutter shell embedding native views when platform-specific UX is required. What changed between 2020 and 2026 is that each option has strong production credentials and clearer boundaries where they excel or underwhelm.

The industry has also converged on TypeScript and null-safety as default expectations. In Flutter, sound null safety is standard. In React Native, TypeScript has become the de facto choice for teams that value maintainability. In KMP, Kotlin’s null safety reduces entire classes of iOS bugs. The tooling has improved too: faster builds, better debugging, and smoother CI pipelines.

In real-world projects, the choice is rarely purely technical. Product requirements like performance budgets, update cadence, and platform-specific UX influence the stack. Team background matters. A React-heavy frontend team often thrives with React Native, while mobile-first teams may prefer Flutter’s integrated UI or KMP’s native-first mindset.

Choosing between the major options

There isn’t a universal “best” framework; there are best-fit scenarios. Let’s compare the most common choices with what you’ll see in production.

React Native (with Expo)

React Native remains a popular choice for teams who already live in the TypeScript/React ecosystem. It’s effective when you need to iterate fast, share UI code with a web app, and leverage a massive library ecosystem. With Expo’s modern tooling, development velocity improves significantly, and the prebuild workflow reduces native module friction.

  • Real-world usage: E-commerce apps, content-heavy apps, B2B tools, and prototypes that must reach both iOS and Android quickly. Teams often pair it with a React web app to share components and design systems.
  • Tradeoffs: Performance can be excellent for typical UI, but heavy animations or complex graphics may require native modules or careful optimization. You’ll sometimes need to drop into native code for platform integrations.

Flutter

Flutter’s single UI toolkit model is compelling for design-centric apps and teams that want strong control over visuals across platforms. Because it compiles to native ARM code and uses its own rendering engine, visual consistency is high.

  • Real-world usage: Consumer apps with custom UI, interactive dashboards, and embedded kiosk-style applications. It’s also popular for teams that want desktop and web targets in addition to mobile.
  • Tradeoffs: App size tends to be larger. Platform-native look-and-feel requires explicit effort. While animations are smooth, integrating platform APIs can sometimes feel less idiomatic than native.

Kotlin Multiplatform (KMP)

KMP is the go-to when you want to keep native UIs on each platform but share domain logic, networking, persistence, and other non-UI layers. It’s a pragmatic middle ground: native performance and UX with shared code.

  • Real-world usage: Teams with strong Android and iOS engineering capacity who want to reduce duplication. Common in fintech, health, and apps where correctness and maintainability are paramount.
  • Tradeoffs: You maintain two UI codebases. Setup and CI can be more complex, and the iOS integration depends on Kotlin/Native’s concurrency model. The payoff is clearer boundaries and fewer surprises.

.NET MAUI

MAUI shines for teams invested in the .NET ecosystem, especially those with existing Xamarin apps. It provides a unified project structure and access to .NET libraries.

  • Real-world usage: Enterprise apps, internal tools, and projects where C# skills dominate. It’s a good fit when desktop and mobile need to share code and infrastructure.
  • Tradeoffs: The ecosystem and community are smaller than React Native or Flutter. Platform integrations can require careful configuration, and UI customizations may be more hands-on.

Technical core: patterns you’ll actually use

Cross-platform apps succeed when they follow proven patterns: clean separation between shared and platform code, clear async boundaries, and robust error handling. Below are practical examples using React Native/TypeScript and Flutter/Dart, as well as a KMP snippet for shared logic. These reflect common workflows you’ll encounter.

React Native + TypeScript: layered architecture

A typical React Native project separates concerns into shared UI components, domain logic, and platform-specific adapters. Expo simplifies development and build workflows.

Folder structure:

/src
  /components
    Button.tsx
    ProductCard.tsx
  /hooks
    useProducts.ts
  /services
    api.ts
    auth.ts
  /navigation
    AppNavigator.tsx
  /screens
    HomeScreen.tsx
  /utils
    errors.ts
/app.json
/tsconfig.json
/package.json

A shared API service with typed errors:

// src/services/api.ts
export interface Product {
  id: string;
  name: string;
  price: number;
}

export class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
    this.name = 'ApiError';
  }
}

export async function fetchProducts(): Promise<Product[]> {
  const res = await fetch('https://api.example.com/products');
  if (!res.ok) {
    throw new ApiError(res.status, `Failed to fetch products: ${res.statusText}`);
  }
  return res.json();
}

A React hook consuming the API with error handling:

// src/hooks/useProducts.ts
import { useEffect, useState } from 'react';
import { Product, fetchProducts, ApiError } from '../services/api';

export function useProducts() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetchProducts()
      .then(setProducts)
      .catch((e) => {
        if (e instanceof ApiError) {
          setError(`API error ${e.status}: ${e.message}`);
        } else {
          setError('Unexpected error loading products');
        }
      })
      .finally(() => setLoading(false));
  }, []);

  return { products, loading, error };
}

A simple UI component using the hook:

// src/screens/HomeScreen.tsx
import React from 'react';
import { View, Text, FlatList, ActivityIndicator } from 'react-native';
import { useProducts } from '../hooks/useProducts';
import { ProductCard } from '../components/ProductCard';

export function HomeScreen() {
  const { products, loading, error } = useProducts();

  if (loading) return <ActivityIndicator />;
  if (error) return <Text>{error}</Text>;

  return (
    <FlatList
      data={products}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <ProductCard product={item} />}
      contentContainerStyle={{ padding: 12 }}
    />
  );
}

Why this pattern matters: It isolates networking and error types from UI. If you later swap REST for GraphQL, only the service and hook change. This aligns with React’s data fetching patterns you likely already know.

Expo’s app.json can configure platform-specific needs. For example, enabling scheme linking for deep links:

{
  "expo": {
    "name": "ShopDemo",
    "slug": "shop-demo",
    "scheme": "shopdemo",
    "ios": {
      "bundleIdentifier": "com.example.shopdemo"
    },
    "android": {
      "package": "com.example.shopdemo"
    }
  }
}

For native modules, Expo prebuild is handy. A common workflow:

npx expo prebuild --platform ios
npx expo run:ios

Flutter: MVVM with repository pattern

Flutter apps benefit from a layered architecture that separates UI, state management, and data sources. Provider or Riverpod are common choices for state management; here we’ll use Provider to keep it simple and accessible.

Folder structure:

lib/
  /models
    product.dart
  /repositories
    product_repository.dart
  /services
    api_service.dart
  /providers
    product_provider.dart
  /screens
    home_screen.dart
  /widgets
    product_card.dart
main.dart
pubspec.yaml

Model with JSON serialization:

// lib/models/product.dart
class Product {
  final String id;
  final String name;
  final double price;

  Product({required this.id, required this.name, required this.price});

  factory Product.fromJson(Map<String, dynamic> json) {
    return Product(
      id: json['id'] as String,
      name: json['name'] as String,
      price: (json['price'] as num).toDouble(),
    );
  }
}

Service handling HTTP and errors:

// lib/services/api_service.dart
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../models/product.dart';

class ApiException implements Exception {
  final int status;
  final String message;
  ApiException(this.status, this.message);
}

class ApiService {
  final http.Client client;
  ApiService(this.client);

  Future<List<Product>> fetchProducts() async {
    final uri = Uri.parse('https://api.example.com/products');
    final response = await client.get(uri);

    if (response.statusCode != 200) {
      throw ApiException(response.statusCode, 'Failed to load products');
    }

    final List<dynamic> json = jsonDecode(response.body);
    return json.map((e) => Product.fromJson(e)).toList();
  }
}

Repository as the single source of truth for the domain:

// lib/repositories/product_repository.dart
import '../models/product.dart';
import '../services/api_service.dart';

class ProductRepository {
  final ApiService api;
  ProductRepository(this.api);

  Future<List<Product>> getProducts() async {
    try {
      return await api.fetchProducts();
    } on ApiException catch (e) {
      // Normalize errors for UI
      throw Exception('Products load failed: ${e.message} (${e.status})');
    }
  }
}

Provider for state management:

// lib/providers/product_provider.dart
import 'package:flutter/material.dart';
import '../models/product.dart';
import '../repositories/product_repository.dart';

class ProductProvider with ChangeNotifier {
  final ProductRepository repository;
  ProductProvider(this.repository);

  List<Product> products = [];
  bool loading = false;
  String? error;

  Future<void> load() async {
    loading = true;
    error = null;
    notifyListeners();

    try {
      products = await repository.getProducts();
    } catch (e) {
      error = e.toString();
    } finally {
      loading = false;
      notifyListeners();
    }
  }
}

UI consuming the provider:

// lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/product_provider.dart';
import '../widgets/product_card.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});
  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<ProductProvider>().load();
    });
  }

  @override
  Widget build(BuildContext context) {
    final provider = context.watch<ProductProvider>();

    if (provider.loading) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }

    if (provider.error != null) {
      return Scaffold(body: Center(child: Text(provider.error!)));
    }

    return Scaffold(
      appBar: AppBar(title: const Text('ShopDemo')),
      body: ListView.builder(
        padding: const EdgeInsets.all(12),
        itemCount: provider.products.length,
        itemBuilder: (context, index) {
          return ProductCard(product: provider.products[index]);
        },
      ),
    );
  }
}

main.dart ties it together:

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:http/http.dart' as http;
import 'services/api_service.dart';
import 'repositories/product_repository.dart';
import 'providers/product_provider.dart';
import 'screens/home_screen.dart';

void main() {
  final api = ApiService(http.Client());
  final repo = ProductRepository(api);

  runApp(
    ChangeNotifierProvider(
      create: (_) => ProductProvider(repo),
      child: const ShopApp(),
    ),
  );
}

class ShopApp extends StatelessWidget {
  const ShopApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ShopDemo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const HomeScreen(),
    );
  }
}

Why this works: It’s testable. You can mock the repository in unit tests and stub the HTTP client. The UI remains simple, while the domain logic stays centralized.

Kotlin Multiplatform: shared business logic

KMP shines when you want to share code without sacrificing platform-native UI. Below is a minimal shared module exposing a repository and a simple suspend function. The iOS app can call this via a framework; Android consumes it directly.

Shared module structure:

shared/
  src/commonMain/kotlin
    /com/example/shop/
      /data
        Product.kt
        ProductRepository.kt
      /network
        ApiClient.kt
  build.gradle.kts

Shared model:

// shared/src/commonMain/kotlin/com/example/shop/data/Product.kt
package com.example.shop.data

@kotlinx.serialization.Serializable
data class Product(val id: String, val name: String, val price: Double)

Shared repository with error normalization:

// shared/src/commonMain/kotlin/com/example/shop/data/ProductRepository.kt
package com.example.shop.data

import com.example.shop.network.ApiClient

class ProductRepository(private val api: ApiClient) {
    suspend fun getProducts(): List<Product> {
        try {
            return api.fetchProducts()
        } catch (e: Exception) {
            // Normalize errors for platforms
            throw Exception("Repository error: ${e.message}")
        }
    }
}

HTTP client using Ktor:

// shared/src/commonMain/kotlin/com/example/shop/network/ApiClient.kt
package com.example.shop.network

import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.serialization.json.Json

class ApiClient(private val client: HttpClient) {
    private val baseUrl = "https://api.example.com"

    suspend fun fetchProducts(): List<Product> {
        val response: HttpResponse = client.get("$baseUrl/products")
        if (response.status.value != 200) {
            throw Exception("API error ${response.status.value}")
        }
        val body = response.bodyAsText()
        return Json.decodeFromString(body)
    }
}

To use this in Android, inject the repository into a ViewModel. On iOS, expose a helper class that Swift can call. This separation lets teams iterate on UI independently while sharing the domain.

Honest evaluation: strengths, weaknesses, and tradeoffs

React Native:

  • Strengths: Rapid iteration, large ecosystem, code sharing with web, strong community.
  • Weaknesses: Performance edges require native modules; bridge overhead can bite; dependency churn.
  • Best fit: Teams comfortable in TypeScript, apps with content-driven UIs, and projects needing frequent updates.

Flutter:

  • Strengths: Consistent visuals, performance, desktop/web targets in one toolkit.
  • Weaknesses: Larger binary size; platform idioms need manual work; fewer native SDK wrappers.
  • Best fit: Design-first products, custom UI, and projects targeting mobile + desktop + web.

Kotlin Multiplatform:

  • Strengths: Shared logic with native UI; strong null safety; easier reasoning about concurrency.
  • Weaknesses: Two UI codebases; iOS build/tooling complexity; smaller community.
  • Best fit: Teams with both Android and iOS capacity and a focus on correctness.

.NET MAUI:

  • Strengths: Great for .NET shops, unified project, access to C# libraries.
  • Weaknesses: Smaller ecosystem; UI customization can be more manual.
  • Best fit: Enterprises with C# expertise, desktop + mobile needs.

General tradeoffs:

  • App size: Flutter tends to be larger; React Native depends on bundling and native modules; KMP adds little overhead if shared logic is small.
  • Update cadence: Over-the-air updates are easier in React Native (via Expo Updates) but require policy compliance. Flutter and KMP rely on store releases unless you integrate a custom update mechanism.
  • Testing: Flutter’s widget tests are powerful; React Native needs more integration tests; KMP’s shared logic is unit-testable on the JVM.

Personal experience: lessons from real projects

I once inherited a React Native app that started as a prototype and quickly scaled to hundreds of thousands of users. The initial velocity was fantastic; we built screens faster than the backend could keep up. But as we added complex animations and heavy image processing, the bridge started to show its age. Moving heavy computations into native modules smoothed the experience. The lesson: React Native is a productivity champion, but plan early for performance bottlenecks and native integration.

With Flutter, I worked on a dashboard app that needed to run on Android tablets and Windows kiosks. The consistency across devices was a huge win; the UI team could ship a polished design once. However, we had to implement platform-specific behaviors from scratch, like system share sheets and accessibility hooks. The tradeoff was clear: visual control vs. idiomatic platform features.

On a KMP project, sharing networking and persistence reduced duplication between Android and iOS significantly. The hardest part was onboarding iOS engineers to Kotlin’s coroutine model and getting the build pipeline right. When we got it working, the code review process became smoother because bugs in domain logic were caught in shared tests.

Common mistakes I’ve made—and you can avoid:

  • Skipping types. Using untyped API responses in React Native leads to runtime surprises; TypeScript or zod-style validation is worth it.
  • Overloading the UI layer. Whether Flutter or React Native, putting business logic in widgets/components makes testing hard.
  • Underestimating CI. Cross-platform builds take longer and can be brittle; invest in caching and artifact management early.
  • Ignoring platform UX norms. Even when sharing code, respect iOS and Android interaction patterns to avoid user complaints.

Getting started: workflow and mental models

Rather than step-by-step commands, focus on the mental model you’ll use daily.

Start by mapping your app into layers:

  • UI layer: Components/screens that render data and dispatch actions.
  • Domain layer: Business rules, models, and state transitions.
  • Data layer: APIs, caching, and repositories.
  • Platform layer: Permissions, deep links, notifications, and hardware access.

Then pick a scaffolding approach:

  • React Native: Prefer Expo’s managed workflow for velocity; drop into prebuild when you need custom native modules. Keep TypeScript strict. Use an HTTP client like fetch or axios, and a state management solution you’re comfortable with (Zustand, Redux Toolkit, or React Query).
  • Flutter: Use a single repository pattern with feature-first folders. Pick Riverpod or Provider for state. Configure your pubspec with environment constraints. For CI, use fastlane or GitHub Actions to build and sign.
  • KMP: Start with a small shared module that handles networking and models. On Android, plug into ViewModel; on iOS, expose a Swift-friendly facade. Use a CI matrix that builds both platforms and runs shared tests.

A simple CI pattern in GitHub Actions for a Flutter app:

name: Flutter CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: 'stable'
          flutter-version: '3.19'
      - run: flutter pub get
      - run: flutter analyze
      - run: flutter test
      - run: flutter build apk --release

For React Native with Expo, a common CI step:

name: Expo CI

on:
  push:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx expo prebuild --platform android
      - run: npx expo run:android --variant release

What makes 2026’s cross-platform stand out

Developer experience has improved meaningfully:

  • React Native’s New Architecture reduces bridge overhead and improves concurrency. Combined with Expo Updates, you can ship small fixes quickly while staying within store policies.
  • Flutter’s multiplatform maturity means fewer surprises when moving from mobile to desktop or web. The framework’s stable channel is reliable for production.
  • Kotlin Multiplatform’s embrace of Structured Concurrency makes sharing async code safer across platforms.
  • .NET MAUI’s integration with Visual Studio and .NET tooling simplifies builds and debugging for C# teams.

Maintainability has improved through type systems and clearer project structures. The ecosystem around testing and CI is better than ever, and teams can choose patterns that fit their stack rather than forcing one approach.

Free learning resources

These are reliable starting points without fluff. When evaluating libraries, favor ones with active maintenance, clear licensing, and real-world usage signals like download counts or GitHub activity.

Summary: who should use what, and the bottom line

Choose React Native if:

  • Your team is already fluent in TypeScript/React.
  • You need fast iteration and possible code sharing with a web app.
  • You can handle native modules for performance-critical features.

Choose Flutter if:

  • Visual polish and UI consistency are product-critical.
  • You target mobile, desktop, and web with a single toolkit.
  • You’re comfortable with a non-native look that may require extra work for platform idioms.

Choose Kotlin Multiplatform if:

  • You want native UI on each platform but shared domain logic.
  • Your Android and iOS teams can collaborate on a shared module.
  • Correctness and maintainability are top priorities.

Choose .NET MAUI if:

  • Your organization is invested in C# and .NET.
  • You need desktop and mobile from one codebase and toolchain.
  • You prefer an enterprise-friendly stack.

There’s no silver bullet. The best choice emerges from your product requirements, team skills, and the long-term maintenance plan. In 2026, cross-platform is less about ideology and more about pragmatic layering: share what you can, keep native where it counts, and build a workflow that won’t collapse under growth. That approach has served me well across multiple projects, and it’s where the ecosystem as a whole has landed.