DartFlutter

Build Once, Deploy Everywhere: A Flutter Web, Mobile, and Desktop Strategy

Build Once, Deploy Everywhere: A Flutter Web, Mobile, and Desktop Strategy

In today’s tech landscape, users expect apps to work everywhere — mobile phones, web browsers, desktops, and even tablets. Rewriting the same app for each platform is a massive waste of time and resources. Flutter offers a build once, run anywhere solution; Google’s UI toolkit lets you write a single Dart codebase and deploy to iOS, Android, Web, macOS, Windows, and Linux.

In this comprehensive guide, we’ll break down a practical Flutter Web, Mobile, and Desktop strategy that helps you maintain one codebase and launch your app across platforms without chaos. You’ll learn project architecture, responsive design patterns, platform-specific abstractions, and deployment pipelines that actually work in production.

Why Flutter for Multi-Platform Development?

Flutter allows you to:

  • Use a single Dart codebase for iOS, Android, Web, macOS, Windows, and Linux
  • Share UI and business logic with typically 80-95% code reuse
  • Maintain platform-specific behavior with minimal branching
  • Deliver native performance using Flutter’s Skia rendering engine
  • Hot reload for rapid development across all platforms

This “build once, deploy everywhere” philosophy works if you plan it right — so let’s walk through how.

Scalable Project Structure

A well-organized project structure is critical for multi-platform success. Here’s a production-ready architecture:

lib/
├── main.dart                    # Entry point (minimal)
├── app.dart                     # App configuration
├── core/
│   ├── constants/
│   │   ├── app_constants.dart
│   │   └── api_constants.dart
│   ├── theme/
│   │   ├── app_theme.dart
│   │   ├── colors.dart
│   │   └── typography.dart
│   ├── utils/
│   │   ├── platform_utils.dart
│   │   └── responsive_utils.dart
│   └── di/
│       └── injection_container.dart
├── platform/
│   ├── platform_service.dart         # Abstract interface
│   ├── platform_service_mobile.dart  # Mobile implementation
│   ├── platform_service_web.dart     # Web implementation
│   └── platform_service_desktop.dart # Desktop implementation
├── features/
│   ├── auth/
│   │   ├── data/
│   │   ├── domain/
│   │   └── presentation/
│   ├── home/
│   └── settings/
└── shared/
    ├── widgets/
    │   ├── responsive_scaffold.dart
    │   ├── platform_button.dart
    │   └── adaptive_layout.dart
    └── services/
        ├── storage_service.dart
        └── navigation_service.dart

Clean Entry Point

// main.dart
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io' show Platform;

import 'app.dart';
import 'core/di/injection_container.dart' as di;

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Initialize dependencies
  await di.init();
  
  // Platform-specific initialization
  await _initializePlatform();
  
  runApp(const MyApp());
}

Future<void> _initializePlatform() async {
  if (kIsWeb) {
    // Web-specific initialization
    // e.g., configure URL strategy
  } else if (Platform.isAndroid || Platform.isIOS) {
    // Mobile-specific initialization
    // e.g., Firebase, push notifications
  } else {
    // Desktop-specific initialization
    // e.g., window size configuration
  }
}

Responsive UI Architecture

Different platforms mean different screen sizes, input methods, and user expectations. A robust responsive system is non-negotiable.

Responsive Breakpoints

// core/utils/responsive_utils.dart
import 'package:flutter/material.dart';

enum DeviceType { mobile, tablet, desktop }

class ResponsiveUtils {
  static const double mobileBreakpoint = 600;
  static const double tabletBreakpoint = 900;
  static const double desktopBreakpoint = 1200;
  
  static DeviceType getDeviceType(BuildContext context) {
    final width = MediaQuery.of(context).size.width;
    if (width < mobileBreakpoint) return DeviceType.mobile;
    if (width < desktopBreakpoint) return DeviceType.tablet;
    return DeviceType.desktop;
  }
  
  static bool isMobile(BuildContext context) =>
      getDeviceType(context) == DeviceType.mobile;
      
  static bool isTablet(BuildContext context) =>
      getDeviceType(context) == DeviceType.tablet;
      
  static bool isDesktop(BuildContext context) =>
      getDeviceType(context) == DeviceType.desktop;
  
  static double getContentWidth(BuildContext context) {
    final width = MediaQuery.of(context).size.width;
    if (width < mobileBreakpoint) return width;
    if (width < tabletBreakpoint) return width * 0.9;
    if (width < desktopBreakpoint) return width * 0.8;
    return 1100; // Max content width
  }
}

Adaptive Layout Widget

// shared/widgets/adaptive_layout.dart
import 'package:flutter/material.dart';
import '../../core/utils/responsive_utils.dart';

class AdaptiveLayout extends StatelessWidget {
  final Widget mobile;
  final Widget? tablet;
  final Widget? desktop;
  
  const AdaptiveLayout({
    super.key,
    required this.mobile,
    this.tablet,
    this.desktop,
  });
  
  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth >= ResponsiveUtils.desktopBreakpoint) {
          return desktop ?? tablet ?? mobile;
        }
        if (constraints.maxWidth >= ResponsiveUtils.mobileBreakpoint) {
          return tablet ?? mobile;
        }
        return mobile;
      },
    );
  }
}

Responsive Scaffold with Navigation

// shared/widgets/responsive_scaffold.dart
import 'package:flutter/material.dart';
import '../../core/utils/responsive_utils.dart';

class ResponsiveScaffold extends StatelessWidget {
  final String title;
  final Widget body;
  final int currentIndex;
  final Function(int) onNavigationChanged;
  final List<NavigationItem> navigationItems;
  
  const ResponsiveScaffold({
    super.key,
    required this.title,
    required this.body,
    required this.currentIndex,
    required this.onNavigationChanged,
    required this.navigationItems,
  });
  
  @override
  Widget build(BuildContext context) {
    final deviceType = ResponsiveUtils.getDeviceType(context);
    
    return Scaffold(
      appBar: deviceType == DeviceType.mobile
          ? AppBar(title: Text(title))
          : null,
      body: Row(
        children: [
          // Side navigation for tablet/desktop
          if (deviceType != DeviceType.mobile)
            NavigationRail(
              extended: deviceType == DeviceType.desktop,
              selectedIndex: currentIndex,
              onDestinationSelected: onNavigationChanged,
              leading: deviceType == DeviceType.desktop
                  ? Padding(
                      padding: const EdgeInsets.all(16),
                      child: Text(
                        title,
                        style: Theme.of(context).textTheme.headlineSmall,
                      ),
                    )
                  : null,
              destinations: navigationItems
                  .map((item) => NavigationRailDestination(
                        icon: Icon(item.icon),
                        selectedIcon: Icon(item.selectedIcon),
                        label: Text(item.label),
                      ))
                  .toList(),
            ),
          // Main content
          Expanded(
            child: Center(
              child: ConstrainedBox(
                constraints: BoxConstraints(
                  maxWidth: ResponsiveUtils.getContentWidth(context),
                ),
                child: body,
              ),
            ),
          ),
        ],
      ),
      // Bottom navigation for mobile
      bottomNavigationBar: deviceType == DeviceType.mobile
          ? NavigationBar(
              selectedIndex: currentIndex,
              onDestinationSelected: onNavigationChanged,
              destinations: navigationItems
                  .map((item) => NavigationDestination(
                        icon: Icon(item.icon),
                        selectedIcon: Icon(item.selectedIcon),
                        label: item.label,
                      ))
                  .toList(),
            )
          : null,
    );
  }
}

class NavigationItem {
  final IconData icon;
  final IconData selectedIcon;
  final String label;
  
  const NavigationItem({
    required this.icon,
    required this.selectedIcon,
    required this.label,
  });
}

Platform-Specific Abstractions

Use conditional imports to create platform-specific implementations without polluting your business logic.

Abstract Platform Service

// platform/platform_service.dart
abstract class PlatformService {
  String get platformName;
  bool get isMobile;
  bool get isDesktop;
  bool get isWeb;
  
  Future<void> openUrl(String url);
  Future<String?> pickFile({List<String>? allowedExtensions});
  Future<void> saveFile(String fileName, List<int> bytes);
  Future<void> shareContent(String text, {String? subject});
  
  // Factory constructor with conditional import
  factory PlatformService() => getPlatformService();
}

// Stub for conditional import
PlatformService getPlatformService() =>
    throw UnsupportedError('Platform not supported');
// platform/platform_service_mobile.dart
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';

import 'platform_service.dart';

PlatformService getPlatformService() => MobilePlatformService();

class MobilePlatformService implements PlatformService {
  @override
  String get platformName => Platform.isIOS ? 'iOS' : 'Android';
  
  @override
  bool get isMobile => true;
  
  @override
  bool get isDesktop => false;
  
  @override
  bool get isWeb => false;
  
  @override
  Future<void> openUrl(String url) async {
    final uri = Uri.parse(url);
    if (await canLaunchUrl(uri)) {
      await launchUrl(uri, mode: LaunchMode.externalApplication);
    }
  }
  
  @override
  Future<String?> pickFile({List<String>? allowedExtensions}) async {
    final result = await FilePicker.platform.pickFiles(
      type: allowedExtensions != null ? FileType.custom : FileType.any,
      allowedExtensions: allowedExtensions,
    );
    return result?.files.single.path;
  }
  
  @override
  Future<void> saveFile(String fileName, List<int> bytes) async {
    final directory = await getApplicationDocumentsDirectory();
    final file = File('${directory.path}/$fileName');
    await file.writeAsBytes(bytes);
  }
  
  @override
  Future<void> shareContent(String text, {String? subject}) async {
    await Share.share(text, subject: subject);
  }
}
// platform/platform_service_web.dart
import 'dart:html' as html;
import 'dart:typed_data';

import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';

import 'platform_service.dart';

PlatformService getPlatformService() => WebPlatformService();

class WebPlatformService implements PlatformService {
  @override
  String get platformName => 'Web';
  
  @override
  bool get isMobile => false;
  
  @override
  bool get isDesktop => false;
  
  @override
  bool get isWeb => true;
  
  @override
  Future<void> openUrl(String url) async {
    final uri = Uri.parse(url);
    await launchUrl(uri, webOnlyWindowName: '_blank');
  }
  
  @override
  Future<String?> pickFile({List<String>? allowedExtensions}) async {
    final result = await FilePicker.platform.pickFiles(
      type: allowedExtensions != null ? FileType.custom : FileType.any,
      allowedExtensions: allowedExtensions,
    );
    return result?.files.single.name;
  }
  
  @override
  Future<void> saveFile(String fileName, List<int> bytes) async {
    final blob = html.Blob([Uint8List.fromList(bytes)]);
    final url = html.Url.createObjectUrlFromBlob(blob);
    final anchor = html.AnchorElement(href: url)
      ..setAttribute('download', fileName)
      ..click();
    html.Url.revokeObjectUrl(url);
  }
  
  @override
  Future<void> shareContent(String text, {String? subject}) async {
    // Use Web Share API if available
    if (html.window.navigator.share != null) {
      await html.window.navigator.share({'text': text, 'title': subject});
    } else {
      // Fallback: copy to clipboard
      await html.window.navigator.clipboard?.writeText(text);
    }
  }
}

Conditional Import Setup

// platform/platform_service_stub.dart
import 'platform_service.dart';

// Import the correct implementation based on platform
export 'platform_service_stub.dart'
    if (dart.library.io) 'platform_service_mobile.dart'
    if (dart.library.html) 'platform_service_web.dart';

Routing for All Platforms

Use go_router for declarative routing that works across all platforms with proper URL handling.

// core/routing/app_router.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

import '../../features/auth/presentation/pages/login_page.dart';
import '../../features/home/presentation/pages/home_page.dart';
import '../../features/settings/presentation/pages/settings_page.dart';

class AppRouter {
  static final _rootNavigatorKey = GlobalKey<NavigatorState>();
  static final _shellNavigatorKey = GlobalKey<NavigatorState>();
  
  static final router = GoRouter(
    navigatorKey: _rootNavigatorKey,
    initialLocation: '/',
    debugLogDiagnostics: true,
    routes: [
      // Auth routes (no shell)
      GoRoute(
        path: '/login',
        name: 'login',
        builder: (context, state) => const LoginPage(),
      ),
      // Main app routes (with shell for navigation)
      ShellRoute(
        navigatorKey: _shellNavigatorKey,
        builder: (context, state, child) => MainShell(child: child),
        routes: [
          GoRoute(
            path: '/',
            name: 'home',
            builder: (context, state) => const HomePage(),
            routes: [
              GoRoute(
                path: 'item/:id',
                name: 'item-detail',
                builder: (context, state) {
                  final id = state.pathParameters['id']!;
                  return ItemDetailPage(id: id);
                },
              ),
            ],
          ),
          GoRoute(
            path: '/settings',
            name: 'settings',
            builder: (context, state) => const SettingsPage(),
          ),
        ],
      ),
    ],
    redirect: (context, state) {
      // Add authentication redirect logic here
      final isLoggedIn = /* check auth state */;
      final isLoggingIn = state.matchedLocation == '/login';
      
      if (!isLoggedIn && !isLoggingIn) return '/login';
      if (isLoggedIn && isLoggingIn) return '/';
      return null;
    },
    errorBuilder: (context, state) => ErrorPage(error: state.error),
  );
}

Web URL Strategy

// main.dart - Configure web URL strategy
import 'package:flutter_web_plugins/url_strategy.dart';

void main() {
  // Use path-based URLs instead of hash (#)
  usePathUrlStrategy();
  
  runApp(const MyApp());
}

Desktop Window Configuration

// main.dart - Desktop window setup
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
    await windowManager.ensureInitialized();
    
    const windowOptions = WindowOptions(
      size: Size(1200, 800),
      minimumSize: Size(800, 600),
      center: true,
      backgroundColor: Colors.transparent,
      skipTaskbar: false,
      titleBarStyle: TitleBarStyle.hidden, // Custom title bar
    );
    
    await windowManager.waitUntilReadyToShow(windowOptions, () async {
      await windowManager.show();
      await windowManager.focus();
    });
  }
  
  runApp(const MyApp());
}

Cross-Platform Storage

// shared/services/storage_service.dart
import 'package:hive_flutter/hive_flutter.dart';

class StorageService {
  static const String _userBox = 'user';
  static const String _settingsBox = 'settings';
  
  static Future<void> init() async {
    await Hive.initFlutter();
    await Hive.openBox(_userBox);
    await Hive.openBox(_settingsBox);
  }
  
  // Generic get/set methods
  static T? getValue<T>(String box, String key) {
    return Hive.box(box).get(key) as T?;
  }
  
  static Future<void> setValue<T>(String box, String key, T value) {
    return Hive.box(box).put(key, value);
  }
  
  // User-specific methods
  static String? get authToken => getValue(_userBox, 'authToken');
  static set authToken(String? value) => setValue(_userBox, 'authToken', value);
  
  // Settings methods
  static bool get isDarkMode => getValue(_settingsBox, 'isDarkMode') ?? false;
  static set isDarkMode(bool value) => setValue(_settingsBox, 'isDarkMode', value);
  
  static String get locale => getValue(_settingsBox, 'locale') ?? 'en';
  static set locale(String value) => setValue(_settingsBox, 'locale', value);
}

Build and Deploy Pipelines

GitHub Actions Workflow

# .github/workflows/build-all-platforms.yml
name: Build All Platforms

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

jobs:
  build-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
          channel: 'stable'
      - run: flutter pub get
      - run: flutter test
      - run: flutter build apk --release
      - uses: actions/upload-artifact@v3
        with:
          name: android-apk
          path: build/app/outputs/flutter-apk/app-release.apk

  build-ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
          channel: 'stable'
      - run: flutter pub get
      - run: flutter build ios --release --no-codesign

  build-web:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
          channel: 'stable'
      - run: flutter pub get
      - run: flutter build web --release --web-renderer canvaskit
      - uses: actions/upload-artifact@v3
        with:
          name: web-build
          path: build/web

  build-macos:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
          channel: 'stable'
      - run: flutter pub get
      - run: flutter build macos --release

  build-windows:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
          channel: 'stable'
      - run: flutter pub get
      - run: flutter build windows --release

Build Commands Reference

# Build for each platform
flutter build apk --release            # Android APK
flutter build appbundle --release      # Android App Bundle (Play Store)
flutter build ios --release            # iOS
flutter build web --release            # Web
flutter build macos --release          # macOS
flutter build windows --release        # Windows
flutter build linux --release          # Linux

# Web-specific options
flutter build web --web-renderer canvaskit  # Better for complex graphics
flutter build web --web-renderer html       # Smaller size, better text

# Debug builds for testing
flutter run -d chrome                  # Web
flutter run -d macos                   # macOS
flutter run -d windows                 # Windows

Common Mistakes to Avoid

1. Ignoring Platform Differences

// BAD: Assuming dart:io is always available
import 'dart:io';
final isAndroid = Platform.isAndroid; // Crashes on web!

// GOOD: Check for web first
import 'package:flutter/foundation.dart' show kIsWeb;

bool get isAndroid {
  if (kIsWeb) return false;
  return Platform.isAndroid;
}

2. Hardcoded Sizes

// BAD: Fixed width that breaks on different screens
Container(
  width: 400,
  child: content,
)

// GOOD: Use responsive constraints
ConstrainedBox(
  constraints: BoxConstraints(
    maxWidth: ResponsiveUtils.getContentWidth(context),
  ),
  child: content,
)

3. Touch-Only Interactions

// BAD: No hover states for desktop
GestureDetector(
  onTap: () => doSomething(),
  child: Card(...),
)

// GOOD: Add hover effects for desktop
MouseRegion(
  cursor: SystemMouseCursors.click,
  child: InkWell(
    onTap: () => doSomething(),
    onHover: (hovering) => setState(() => _isHovered = hovering),
    child: AnimatedContainer(
      duration: Duration(milliseconds: 200),
      transform: _isHovered 
          ? Matrix4.identity()..scale(1.02)
          : Matrix4.identity(),
      child: Card(...),
    ),
  ),
)

4. Keyboard Navigation Ignored

// GOOD: Support keyboard shortcuts for desktop
Shortcuts(
  shortcuts: {
    LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
        SaveIntent(),
    LogicalKeySet(LogicalKeyboardKey.escape): DismissIntent(),
  },
  child: Actions(
    actions: {
      SaveIntent: CallbackAction<SaveIntent>(
        onInvoke: (intent) => _save(),
      ),
    },
    child: Focus(
      autofocus: true,
      child: yourWidget,
    ),
  ),
)

5. Not Testing on All Platforms

// Use platform-specific test utilities
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('renders correctly on mobile', (tester) async {
    tester.binding.window.physicalSizeTestValue = Size(375, 812);
    tester.binding.window.devicePixelRatioTestValue = 3.0;
    
    await tester.pumpWidget(MyApp());
    
    expect(find.byType(BottomNavigationBar), findsOneWidget);
    expect(find.byType(NavigationRail), findsNothing);
  });
  
  testWidgets('renders correctly on desktop', (tester) async {
    tester.binding.window.physicalSizeTestValue = Size(1920, 1080);
    tester.binding.window.devicePixelRatioTestValue = 1.0;
    
    await tester.pumpWidget(MyApp());
    
    expect(find.byType(NavigationRail), findsOneWidget);
    expect(find.byType(BottomNavigationBar), findsNothing);
  });
}
Feature Package Notes
Routing go_router Declarative, web-friendly URLs
State Management flutter_bloc or riverpod Platform-agnostic
Storage hive_flutter Fast, works everywhere
HTTP dio Interceptors, cancellation
File Picker file_picker Cross-platform file selection
URL Launcher url_launcher Open URLs, emails, phone
Share share_plus Native share dialogs
Window Management window_manager Desktop window control
Responsive UI responsive_framework Breakpoints, auto-scale

Conclusion

You can build once and deploy everywhere with Flutter — but it takes planning, responsive design, platform awareness, and strong project structure. The key is building abstractions early: responsive layouts that adapt to screen size, platform services that hide implementation details, and routing that works with URLs on web and deep links on mobile.

By following this Flutter Web, Mobile, and Desktop strategy, you’ll avoid duplication, reduce maintenance costs, and reach a broader audience without losing performance or user experience. Start with mobile, expand to web, and add desktop when you’re ready — Flutter’s architecture makes it possible to grow incrementally.

For more Flutter architecture patterns, check out our Clean Architecture with BLoC in Flutter guide. For responsive design specifics, see our Responsive UI in Flutter tutorial. For official platform-specific guidance, visit the Flutter Platform Adaptations documentation.

Leave a Comment