Skip to content

Building a Flutter App from Scratch: A Practical Guide

Published: 5 min read
Edit on GitHub

So you’ve heard about Flutter and want to build something real — not just a counter app. This post walks through everything I use day-to-day: clean project structure, state management with Riverpod, HTTP calls, and a few patterns that keep large codebases sane.

Table of Contents

Open Table of Contents

Project Setup

Start by creating a new Flutter project. I always use --org to set the package name properly from the beginning — changing it later is a pain.

flutter create --org com.alvinaprianto --platforms android,ios my_app
cd my_app
flutter pub get

Check that everything runs before touching anything:

flutter run

Tip: Run flutter doctor if you hit issues. It tells you exactly what’s missing in your environment.


Folder Structure

Flat folders get messy fast. Here’s the structure I use across all my projects:

lib/
├── core/
│   ├── constants/
│   │   └── app_constants.dart
│   ├── errors/
│   │   └── failures.dart
│   └── utils/
│       └── extensions.dart
├── data/
│   ├── models/
│   │   └── user_model.dart
│   ├── repositories/
│   │   └── user_repository.dart
│   └── services/
│       └── api_service.dart
├── presentation/
│   ├── pages/
│   │   ├── home/
│   │   │   ├── home_page.dart
│   │   │   └── home_provider.dart
│   │   └── profile/
│   │       └── profile_page.dart
│   └── widgets/
│       └── app_button.dart
└── main.dart

Keep data/ for anything that touches the network or local DB. presentation/ is pure UI + state. core/ is shared utilities.


State Management with Riverpod

I use Riverpod on every project. It’s type-safe, testable, and doesn’t require a BuildContext for everything.

Add it to pubspec.yaml:

dependencies:
  flutter_riverpod: ^2.5.1
  riverpod_annotation: ^2.3.5

dev_dependencies:
  riverpod_generator: ^2.4.0
  build_runner: ^2.4.9

Wrap your app at the root:

void main() {
  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

A simple async provider that fetches user data:

// lib/presentation/pages/home/home_provider.dart

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:my_app/data/models/user_model.dart';
import 'package:my_app/data/repositories/user_repository.dart';

part 'home_provider.g.dart';

@riverpod
Future<List<User>> userList(UserListRef ref) async {
  final repo = ref.watch(userRepositoryProvider);
  return repo.getUsers();
}

Then consume it in a widget:

class HomePage extends ConsumerWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final usersAsync = ref.watch(userListProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Users')),
      body: usersAsync.when(
        data: (users) => ListView.builder(
          itemCount: users.length,
          itemBuilder: (context, index) => ListTile(
            title: Text(users[index].name),
            subtitle: Text(users[index].email),
          ),
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => Center(child: Text('Error: $e')),
      ),
    );
  }
}

Fetching Data from an API

I use Dio over the default http package — interceptors, logging, and retry handling are built in.

// lib/data/services/api_service.dart

import 'package:dio/dio.dart';

class ApiService {
  late final Dio _dio;

  ApiService() {
    _dio = Dio(
      BaseOptions(
        baseUrl: 'https://api.example.com/v1',
        connectTimeout: const Duration(seconds: 10),
        receiveTimeout: const Duration(seconds: 10),
        headers: {'Content-Type': 'application/json'},
      ),
    );

    // Log requests in debug mode
    _dio.interceptors.add(LogInterceptor(responseBody: true));
  }

  Future<Response> get(String path, {Map<String, dynamic>? params}) {
    return _dio.get(path, queryParameters: params);
  }

  Future<Response> post(String path, {dynamic data}) {
    return _dio.post(path, data: data);
  }
}

And the model with freezed + json_serializable:

// lib/data/models/user_model.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user_model.freezed.dart';
part 'user_model.g.dart';

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String name,
    required String email,
    String? avatarUrl,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

Run code generation after adding freezed models:

dart run build_runner build --delete-conflicting-outputs

Useful Packages

Here’s what goes into almost every app I ship:

PackagePurpose
flutter_riverpodState management
dioHTTP client
freezedImmutable models
go_routerNavigation & deep links
flutter_secure_storageStoring tokens securely
cached_network_imageImage caching
supabase_flutterAuth + DB (when using Supabase)

Deployment Checklist

Before hitting that release button:


Final Thoughts

Flutter moves fast. The tooling is solid, the widget catalog is massive, and Dart is genuinely pleasant to write once you get used to it. If you’re coming from React Native, the paradigm shift is small — StatelessWidget ≈ functional component, ConsumerWidget ≈ connected component.

Got questions or want to see a follow-up post on something specific? Reach me at contact@alvinaprianto.com or find me on GitHub.

New posts, shipping stories, and nerdy links straight to your inbox.

2× per month, pure signal, zero fluff.