Flutter’s biggest advantage as a framework is also its biggest risk when working with AI tools: it generates a lot of code, and that code follows strong conventions. Without explicit rules in your CLAUDE.md, Claude Code will produce syntactically correct Dart that still breaks your architecture, ignores your state management patterns, or generates widget tests when you needed integration tests.
This guide gives you a complete CLAUDE.md template for Flutter projects, covering clean architecture, Riverpod, platform-specific rules, and testing conventions. Browse Flutter-specific rules from real projects in our gallery.
Why Claude Code + Flutter Is a Strong Combination
Flutter projects have a high ratio of boilerplate to actual logic. A StatefulWidget requires a specific class hierarchy. Riverpod providers follow rigid declaration patterns. Clean architecture splits a single feature across four directories. None of this is intellectually difficult — it is just tedious to type.
Claude Code handles this boilerplate extremely well, but only when it understands your project’s conventions:
- Riverpod patterns: There are multiple ways to define a provider. Without rules, Claude will mix
Provider,StateNotifierProvider, andAsyncNotifierProviderin the same codebase - Widget decomposition: Claude will extract widgets at different granularities depending on context. Rules keep this consistent
- Import organization: Flutter projects often mix
package:imports, relative imports, and barrel files. Rules prevent drift - Feature vs. layer structure: Clean architecture can be organized by layer (data/domain/presentation) or by feature. Both are valid; inconsistency is not
Complete CLAUDE.md Template for Flutter Projects
This is a production-ready template. Copy it to your project root as CLAUDE.md and adjust the specifics for your stack.
# Flutter Project — AI Coding Rules
## Project Overview
- Flutter 3.x with Dart 3.x
- State management: Riverpod (flutter_riverpod + riverpod_annotation)
- Architecture: Clean architecture, feature-first directory structure
- Routing: go_router
- Dependency injection: Riverpod (no separate DI framework)
- HTTP: Dio with Retrofit (code-generated API clients)
- Local storage: Hive (structured data) / shared_preferences (simple key-value)
- Code generation: build_runner (freezed, riverpod_generator, retrofit, json_serializable)
## Dart Version and Style
- Dart 3.x with null safety (non-nullable by default)
- `from __future__` is not needed but always use sound null safety patterns
- No `late` unless genuinely necessary — prefer explicit initialization
- Prefer `final` over `var` for all declarations where value does not change
- Use `const` constructors everywhere possible
- Named parameters over positional for 3+ parameters
- Line length: 80 characters (dart format default)
- Never use `dynamic` — use generic types or specific types
## Project Structure (Feature-First Clean Architecture)
lib/
├── core/ # Shared infrastructure
│ ├── constants/ # App-wide constants
│ ├── errors/ # Failure types, exceptions
│ ├── extensions/ # Dart extension methods
│ ├── network/ # Dio setup, interceptors, token refresh
│ ├── router/ # go_router configuration
│ ├── theme/ # ThemeData, text styles, colors
│ └── utils/ # Pure utility functions
├── features/
│ └── [feature_name]/
│ ├── data/
│ │ ├── datasources/ # Remote and local data sources
│ │ ├── models/ # API response models (Freezed + JSON)
│ │ └── repositories/ # Repository implementations
│ ├── domain/
│ │ ├── entities/ # Pure Dart business objects
│ │ ├── repositories/ # Abstract repository interfaces
│ │ └── usecases/ # Single-responsibility use cases
│ └── presentation/
│ ├── pages/ # Full-screen widgets (route targets)
│ ├── widgets/ # Feature-specific reusable widgets
│ └── providers/ # Riverpod providers for this feature
└── shared/
└── widgets/ # Truly cross-feature widgets
## Naming Conventions
### Files
- snake_case for all file names: `user_profile_page.dart`
- Suffix conventions:
- Pages: `_page.dart`
- Widgets: `_widget.dart` (only if needed to disambiguate, otherwise just `_card.dart`, `_tile.dart`)
- Providers: `_provider.dart`
- Use cases: `_usecase.dart`
- Repositories (interface): `_repository.dart`
- Repositories (implementation): `_repository_impl.dart`
- Models: `_model.dart`
- Entities: `_entity.dart` (or plain noun: `user.dart`)
### Classes
- PascalCase for all class names
- Do not repeat the package name in the class name
### Methods and Variables
- camelCase for methods and local variables
- Private members: prefix with `_`
- Constants: lowerCamelCase (not SCREAMING_SNAKE_CASE in Dart)
## State Management (Riverpod)
### Always Use Code Generation
Use `riverpod_annotation` and `riverpod_generator`. Never write
`Provider`, `StateNotifierProvider`, or `FutureProvider` by hand.
```dart
// Good — generated provider
@riverpod
Future<List<User>> userList(UserListRef ref) async {
final repo = ref.watch(userRepositoryProvider);
return repo.getAll();
}
// Good — generated notifier
@riverpod
class UserNotifier extends _$UserNotifier {
@override
Future<User?> build(String userId) async {
return ref.watch(userRepositoryProvider).getById(userId);
}
Future<void> updateName(String name) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(userRepositoryProvider).updateName(id: userId, name: name),
);
}
}
Provider Placement
- Feature providers go in
features/[feature]/presentation/providers/ - Shared/infrastructure providers go in
core/ - Never put providers inside widget files
AsyncValue Handling
Always handle all three states explicitly:
// Good
child: userAsync.when(
data: (user) => UserCard(user: user),
loading: () => const CircularProgressIndicator(),
error: (e, _) => ErrorWidget(message: e.toString()),
),
// Bad — skipping error state
child: userAsync.maybeWhen(
data: (user) => UserCard(user: user),
orElse: () => const CircularProgressIndicator(),
),
ref.watch vs ref.read
ref.watch: insidebuild()— rebuilds widget/provider when value changesref.read: inside callbacks and event handlers — does NOT trigger rebuild- Never use
ref.readinsidebuild()for reactive values
Widget Architecture
StatelessWidget First
Prefer StatelessWidget + ConsumerWidget (Riverpod) over StatefulWidget.
Use StatefulWidget only for:
- Local ephemeral state (animation controllers, text controllers, focus nodes)
- Lifecycle hooks (initState, dispose)
- State that genuinely does not belong in providers
Widget Decomposition
Extract a widget when:
- It would benefit from
constconstruction - It is reused in 2+ places
- It has its own Riverpod dependencies
- It exceeds ~50 lines of build code
Do NOT extract micro-widgets just for line count. Prefer meaningful decomposition.
Const Constructors
Add const to:
- All widget constructors with no dynamic data
- All static
Text,Icon,SizedBox,Paddinginstances - All build methods that return fixed structure
Context Extensions
Create extension methods for frequent theme/media query access:
extension BuildContextX on BuildContext {
ThemeData get theme => Theme.of(this);
TextTheme get textTheme => Theme.of(this).textTheme;
ColorScheme get colorScheme => Theme.of(this).colorScheme;
double get screenWidth => MediaQuery.sizeOf(this).width;
double get screenHeight => MediaQuery.sizeOf(this).height;
}
Data Layer
Models (Freezed + JSON)
All API response models use Freezed with JSON serialization:
@freezed
class UserModel with _$UserModel {
const factory UserModel({
required String id,
required String name,
required String email,
@JsonKey(name: 'created_at') required DateTime createdAt,
}) = _UserModel;
factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json);
}
Entities
Domain entities are plain Dart classes or Freezed classes without JSON:
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
required DateTime createdAt,
}) = _User;
}
Repository Pattern
Always define an abstract interface in domain, implement in data:
// domain/repositories/user_repository.dart
abstract class UserRepository {
Future<List<User>> getAll();
Future<User> getById(String id);
Future<User> updateName({required String id, required String name});
}
// data/repositories/user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource _remote;
final UserLocalDataSource _local;
const UserRepositoryImpl({
required UserRemoteDataSource remote,
required UserLocalDataSource local,
}) : _remote = remote, _local = local;
@override
Future<List<User>> getAll() async {
final models = await _remote.fetchAll();
return models.map((m) => m.toEntity()).toList();
}
// ...
}
Error Handling
Use a sealed Failure class, not raw exceptions:
sealed class Failure {
const Failure();
}
class NetworkFailure extends Failure {
final int? statusCode;
const NetworkFailure({this.statusCode});
}
class CacheFailure extends Failure {
const CacheFailure();
}
class ValidationFailure extends Failure {
final String field;
final String message;
const ValidationFailure({required this.field, required this.message});
}
Use Either<Failure, T> from fpdart in use cases when returning errors is expected
behavior, not exceptional behavior.
Routing (go_router)
Route Constants
Never hardcode route strings in widgets:
// core/router/routes.dart
abstract class Routes {
static const home = '/';
static const userProfile = '/users/:id';
static const settings = '/settings';
}
TypedRoute Pattern
Use TypedGoRoute annotations for type-safe navigation when possible.
Generated route classes eliminate string path bugs.
Code Generation
After modifying any file that uses code generation, remind the user to run:
dart run build_runner build --delete-conflicting-outputs
Files requiring build_runner:
- Freezed models (
@freezed) - Riverpod providers (
@riverpod) - Retrofit API clients (
@RestApi) - JSON serializable (
@JsonSerializable) - go_router typed routes (
@TypedGoRoute)
Never manually edit .g.dart or .freezed.dart files.
Imports
Order (enforced by dart format)
- Dart SDK imports (
dart:) - Flutter imports (
package:flutter/) - External package imports (
package:) - Relative imports (
../,./)
Barrel Files
Use barrel files (index.dart) only for the public API of a layer:
// features/user/domain/domain.dart
export 'entities/user.dart';
export 'repositories/user_repository.dart';
export 'usecases/get_user_usecase.dart';
Do NOT create barrel files that simply re-export everything — be explicit.
Platform-Specific Rules
Adaptive Widgets
Prefer adaptive constructors for components with platform semantics:
Switch.adaptivenotSwitchCircularProgressIndicator.adaptivenotCircularProgressIndicatorAlertDialog.adaptivenotAlertDialog
Platform Detection
Use defaultTargetPlatform (not Platform.isIOS) in Flutter widget code:
import 'package:flutter/foundation.dart';
final isIOS = defaultTargetPlatform == TargetPlatform.iOS;
Use Platform.isIOS only in non-widget code (services, native calls).
Safe Areas
Always wrap root page content with SafeArea. Never hardcode padding values
to compensate for notches or home indicators.
Keyboard Avoidance
Use resizeToAvoidBottomInset: true (scaffold default) for pages with forms.
Never hardcode bottom padding for keyboard avoidance.
Testing Rules
Widget Tests (Unit of: a Widget)
- One test file per widget file
- Test: rendering with required props, user interactions, state changes
- Use
pump()notpumpWidget()for subsequent rebuilds - Mock Riverpod providers with
ProviderScopeoverrides
testWidgets('UserCard shows name and email', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userProvider.overrideWith((ref) => fakeUser),
],
child: const MaterialApp(home: UserCard(userId: 'test')),
),
);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('alice@example.com'), findsOneWidget);
});
Integration Tests (Unit of: a Feature Flow)
- Location:
integration_test/ - Use
integration_testpackage withIntegrationTestWidgetsFlutterBinding - Test full user flows: login → list → detail → action
- Mock at the network layer (Dio interceptors), not at repository level
Unit Tests (Unit of: a Class)
- Test use cases, repositories, and utility functions
- No Flutter dependencies in unit tests
- Use
flutter_testonly when testing widgets
What NOT to Test
- Private methods
- Trivial getters with no logic
- Generated code (
.g.dart,.freezed.dart) - Flutter framework internals (do not test that
setStatewas called)
Common Pitfalls — Explicit Rules
Do NOT use setState with Riverpod
If a project uses Riverpod, do not introduce setState for feature state. Only use setState for widget-local ephemeral state (animations, scroll position).
Do NOT use BuildContext across async gaps
// Bad
Future<void> _submit() async {
await repository.save(data);
Navigator.of(context).pop(); // context might be invalid here
}
// Good
Future<void> _submit() async {
await repository.save(data);
if (!mounted) return;
Navigator.of(context).pop();
}
Do NOT import from another feature’s presentation layer
Cross-feature dependencies must go through the domain layer only.
A widget in features/orders/ must not import from features/users/presentation/.
Do NOT use MediaQuery.of(context).size
Use MediaQuery.sizeOf(context) instead — it rebuilds only on size changes,
not on every MediaQuery property change.
Do NOT skip running build_runner after model changes
Stale generated code causes type errors that look like logic bugs. Always run build_runner after modifying Freezed models or Riverpod providers.
## Project Structure Rules in Practice
The feature-first directory structure makes sense on paper but causes friction when a feature genuinely shares domain logic. The rule is to not share presentation layers — only domain entities and use cases can be shared:
Good: features/orders/presentation/ imports from features/users/domain/
Bad: features/orders/presentation/ imports from features/users/presentation/
When a widget or provider is needed by three or more features, it belongs in `shared/widgets/` or `core/`. The threshold matters: moving things to shared too early creates an implicit dependency graph that is harder to understand than slightly duplicated code.
## State Management Rules in Depth
### The Provider Lifecycle Problem
One of the most common Claude Code mistakes in Riverpod projects: generating a provider that reads another provider in `build()` with `ref.read` instead of `ref.watch`. This produces code that works on first load but does not update when the dependency changes:
```dart
// Bad — using ref.read in build()
@riverpod
Future<UserProfile> userProfile(UserProfileRef ref) async {
final userId = ref.read(authProvider).userId; // will not react to auth changes
return ref.read(userRepositoryProvider).getProfile(userId);
}
// Good — using ref.watch in build()
@riverpod
Future<UserProfile> userProfile(UserProfileRef ref) async {
final userId = ref.watch(authProvider).userId; // reactive
final repo = ref.watch(userRepositoryProvider);
return repo.getProfile(userId);
}
A rule in your CLAUDE.md that explicitly calls out this pattern will prevent it.
Riverpod vs. Local State Decision Tree
Add this decision tree to your CLAUDE.md to prevent state placement debates:
## Where Does State Belong?
Ask: "Does any other widget need this state?"
- No → StatefulWidget local state (or useState in hooks)
- Yes → Riverpod provider
Ask: "Does this state survive navigation?"
- No → ProviderScope auto-dispose (default)
- Yes → keepAlive: true on the provider
Ask: "Is this state derived from other state?"
- Yes → Computed provider (watch multiple providers, return derived value)
- No → Notifier provider with explicit mutations
Platform-Specific Considerations
iOS-Specific Rules
Cupertino widgets are not drop-in replacements for Material widgets. Do not use CupertinoButton inside a Material widget tree without Material ancestor widgets. Use adaptive constructors instead:
## iOS/Android Divergence Policy
- Use Material Design components as the default
- Use adaptive variants (`Switch.adaptive`, `AlertDialog.adaptive`) for OS-native feel
- Do NOT mix CupertinoPageRoute and MaterialPageRoute — use go_router exclusively
- CupertinoApp is only appropriate for fully-iOS-native-looking apps; most projects use MaterialApp
Android-Specific Rules
## Android Platform Rules
- Target Android API 24+ (minSdkVersion)
- Do NOT use deprecated android.support — use AndroidX exclusively
- Predictive Back: handle `PopScope` (not WillPopScope — deprecated in Flutter 3.12)
- Edge-to-edge: configure in AndroidManifest; do not hardcode status bar height
Web Target Rules (If Applicable)
Flutter Web has different performance characteristics from mobile:
## Flutter Web Rules
- Avoid heavy canvas-based animations on web (CanvasKit renderer)
- Prefer HTML renderer for text-heavy UIs (better text rendering, smaller bundle)
- Use `kIsWeb` checks only in justified cases — prefer cross-platform implementations
- Lazy-load routes with go_router's ShellRoute for code splitting
Testing Patterns in Detail
Widget Test Structure
Widget tests for Riverpod apps require overriding providers. The pattern is consistent enough to standardize:
## Widget Test Template
```dart
void main() {
group('FeatureName', () {
late ProviderContainer container;
setUp(() {
container = ProviderContainer(
overrides: [
// Override infrastructure providers only
userRepositoryProvider.overrideWith((ref) => FakeUserRepository()),
],
);
});
tearDown(() => container.dispose());
testWidgets('description of what is being tested', (tester) async {
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(home: TargetWidget()),
),
);
// Act
await tester.tap(find.byKey(const Key('submit_button')));
await tester.pump();
// Assert
expect(find.text('Success'), findsOneWidget);
});
});
}
### Golden Tests
Golden tests for UI regression require specific handling:
```markdown
## Golden Tests
- Use `golden_toolkit` package for multi-device goldens
- Run: `flutter test --update-goldens` only when intentionally updating UI
- Never commit auto-updated goldens without visual review
- Golden test files: `test/goldens/` with `.png` extension
- Git-track goldens — they are the expected UI baseline
Common Pitfalls and How Rules Prevent Them
Pitfall 1: Mixed State Management Patterns
Without rules, Claude Code will use whatever pattern seems appropriate for the immediate context. Over multiple sessions, this produces a codebase that mixes setState, ChangeNotifier, Bloc, and Riverpod. The fix is explicit prohibition:
## State Management Policy
This project uses ONLY Riverpod for application state.
Do NOT introduce:
- setState (for anything beyond widget-local ephemeral state)
- ChangeNotifier / ValueNotifier (outside of animation controllers)
- Bloc / Cubit
- Provider package (the pub.dev package — we use Riverpod)
- GetX / MobX / Redux
If you think you need one of the above, ask before implementing.
Pitfall 2: Inconsistent Navigation
go_router and imperative navigation (Navigator.push) produce different back-stack behaviors. Claude will mix them without a rule:
## Navigation Policy
- Use ONLY go_router for navigation (context.go, context.push, context.pop)
- Never use Navigator.of(context).push/pop directly
- Never use MaterialPageRoute directly
- All routes defined in core/router/app_router.dart
Pitfall 3: BuildContext in Async Gaps
This is a runtime error that static analysis does not always catch. Claude Code generates it frequently in form submission handlers:
## Async BuildContext Rule
After any `await`, check `mounted` before using `context`:
```dart
Future<void> handleSubmit() async {
await service.doSomething();
if (!mounted) return; // Required
context.pop();
}
This applies in StatefulWidget methods AND in callbacks that use BuildContext.
### Pitfall 4: Forgetting Code Generation
Claude Code sometimes modifies Freezed or Riverpod files and presents the result as complete without noting that build_runner must run. Add an explicit reminder pattern:
```markdown
## Code Generation Reminder
After EVERY change to a file containing @freezed, @riverpod, @RestApi, or
@JsonSerializable annotations, output this reminder:
"Run build_runner to regenerate: dart run build_runner build --delete-conflicting-outputs"
Do not present the change as complete without this reminder.
Pitfall 5: Late Variable Overuse
late is valid for non-nullable variables initialized after declaration, but Claude overuses it as a shortcut to avoid thinking about initialization order:
## Late Variable Policy
Use `late` only when:
1. The variable is definitely initialized before first use (and the compiler cannot prove it)
2. The initialization requires `this` access
Do NOT use `late` to avoid null safety. Use:
- Nullable type with `?` instead of `late final T`
- Initializer expressions
- Lazy evaluation patterns
Integrating This Template Into Your Workflow
Copy the full template above and adapt these sections for your specific stack:
- Project Overview: Update packages to match your
pubspec.yaml - Project Structure: Adjust for your feature names and architecture variations
- State Management: Remove sections for state management you don’t use
- Testing: Adjust test package names and locations
- Platform rules: Remove sections for platforms you don’t target
The most important sections are the naming conventions, the Riverpod ref.watch/ref.read rules, and the common pitfalls. These are the areas where Claude Code produces the most inconsistent output without explicit guidance.
Browse Flutter CLAUDE.md examples from real projects in our gallery, or explore our guides on writing effective CLAUDE.md files and Claude Code clean architecture patterns.