Topic · A3
Cursor Rules for Flutter 3.27 + Riverpod (2026)
Flutter 3.27 + Riverpod 2 + Dart 3.6 changed enough that pre-2025 .cursorrules ship code your linter rejects. Here is the AsyncValue + codegen + freezed rule, with diffs against what Cursor produces without it.
Flutter Cursor rules are thin. cursor.directory has one Flutter entry; the evanca/flutter-ai-rules repo is the most-cited community alternative and it's still single-author. Most of what you'll find tells the agent to "use Provider for state management" — which is the answer from three Flutter eras ago.
This page is the rule for a Flutter 3.27 project on Riverpod 2 with codegen, freezed models, and AsyncValue everywhere a future surfaces. The full rule body is at the bottom. Each directive above explains a friction point Cursor produces without it, with a before/after diff against real output from cmd+K on an empty file.
Why Flutter rules go stale faster than other stacks
Flutter changes its idioms every 12-18 months. Three shifts since early 2024 break the common Cursor rules:
- Riverpod 2 codegen replaces hand-typed providers. Riverpod 1 ships providers as top-level
final myProvider = StateNotifierProvider(...)declarations. Riverpod 2 ships them as annotated functions with code generation. The two styles look completely different on the page, and Cursor without a rule writes the older style — which still compiles but mixes patterns awkwardly with any modern code already in the project. - Dart 3 patterns and sealed classes. Pattern-matching
switchexpressions, exhaustive sealed-class destructuring, and thelate finalimprovements changed what idiomatic Dart looks like. Cursor will fall back to Dart 2 idioms if not told otherwise. - Material 3 by default. The
useMaterial3: trueopt-out went away in Flutter 3.16+. Older rules sometimes still set it, which is dead code; the rule body removes that noise.
The six directives that change the most output
1. Riverpod 2 codegen for all providers.
Before (Cursor without rule):final productProvider = FutureProvider.family<Product, String>((ref, id) async {
final api = ref.watch(apiProvider);
return api.getProduct(id);
});
After (with rule):
// product_providers.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'product_providers.g.dart';
@riverpod Future<Product> product(ProductRef ref, String id) async { final api = ref.watch(apiProvider); return api.getProduct(id); }
The rule body adds: All providers are defined with the @riverpod annotation on a function in _providers.dart. The codegen part-file (_providers.g.dart) is included via part. Run dart run build_runner watch -d during development. Manual Provider, StateNotifierProvider, FutureProvider, StreamProvider declarations are reserved for legacy migration files only.
2. AsyncValue: three-way match, no .value!.
Before:
class ProductPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final product = ref.watch(productProvider('abc'));
if (product.isLoading) return const CircularProgressIndicator();
return Text(product.value!.name);
}
}
After (Riverpod 2 + Dart 3 pattern matching):
class ProductPage extends ConsumerWidget {
const ProductPage({super.key, required this.id});
final String id;
@override Widget build(BuildContext context, WidgetRef ref) { final product = ref.watch(productProvider(id)); return switch (product) { AsyncData(:final value) => Text(value.name), AsyncLoading() => const CircularProgressIndicator(), AsyncError(:final error, :final stackTrace) => ErrorDisplay( error: error, stackTrace: stackTrace, onRetry: () => ref.invalidate(productProvider(id)), ), }; } }
The rule body says: AsyncValue MUST be unwrapped with three-way matching: either switch pattern matching (preferred) or .when(data:, loading:, error:). .value!, .requireValue, and ignoring the error case are forbidden. The error branch MUST include a retry path — ref.invalidate(provider) or ref.refresh(provider).
3. Freezed for models, sealed classes for state unions.
Before:class Product {
final String id;
final String name;
final double price;
Product({required this.id, required this.name, required this.price});
Product copyWith({String? id, String? name, double? price}) { return Product(id: id ?? this.id, name: name ?? this.name, price: price ?? this.price); }
@override bool operator ==(Object other) => identical(this, other) || other is Product && other.id == id && other.name == name && other.price == price;
@override int get hashCode => Object.hash(id, name, price);
factory Product.fromJson(Map<String, dynamic> json) => Product( id: json['id'] as String, name: json['name'] as String, price: (json['price'] as num).toDouble(), ); }
After:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product.freezed.dart'; part 'product.g.dart';
@freezed class Product with _$Product { const factory Product({ required String id, required String name, required double price, }) = _Product;
factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json); }
For state unions (where the shape varies), Dart 3 sealed classes:
sealed class CheckoutState {}
class CheckoutIdle extends CheckoutState {}
class CheckoutLoading extends CheckoutState {}
class CheckoutSuccess extends CheckoutState {
CheckoutSuccess(this.orderId);
final String orderId;
}
class CheckoutFailed extends CheckoutState {
CheckoutFailed(this.reason);
final String reason;
}
The rule body adds: Feature models with copyWith and JSON serialization use freezed. State unions with discriminated variants (loading / success / error / idle) use Dart 3 sealed classes. Pattern-match exhaustively at the consumer; the compiler enforces it. Never hand-roll == or hashCode on a model that has more than two fields.
4. No setState in feature widgets.
The rule body says: Feature widgets are ConsumerWidget or HookConsumerWidget. State lives in a Riverpod provider — including loading flags, form submission status, and error messages. setState is allowed in three places only:
- Inside an
AnimatedBuilderorAnimationControllerlistener where the state is purely visual. - Inside a
Formwidget for transient input not yet submitted. - Inside a
TextEditingControllerlistener that updates local-only UI (a character count, a clear-button visibility flag).
5. const constructors everywhere they fit.
Flutter's repaint behavior depends on const constructors. Cursor will skip const 60% of the time without the rule. The fix is one directive: Every widget constructor that takes only final fields and has no body MUST be const. The prefer_const_constructors and prefer_const_constructors_in_immutables lints SHOULD be enabled in analysis_options.yaml and the agent SHOULD generate code that passes them.
6. Routing with go_router, route names as constants.
Flutter's Navigator.push with anonymous routes is the source of half the deep-linking bugs in mid-size apps. The rule body says: Routing uses go_router. Routes are declared in src/routing/router.dart with named paths exposed as a RouteNames class of static const String fields. Navigation uses context.go(RouteNames.product) or context.push(RouteNames.checkout) — never raw string literals at the call site.
The full rule (paste into .cursor/rules/flutter.mdc)
---
description: Flutter 3.27 + Dart 3.6 + Riverpod 2 + freezed + go_router production rules.
globs:
- "lib/*/.dart"
- "test/*/.dart"
- "pubspec.yaml"
alwaysApply: false
# Flutter 3.27 + Riverpod Rules
Project contract
- Flutter 3.27, Dart 3.6.
- Riverpod 2 with
riverpod_annotation codegen. dart run build_runner watch -d during development. - Models: freezed. State unions: Dart 3 sealed classes.
- Routing: go_router with named routes.
- Material 3 (default; do not set
useMaterial3 explicitly).
Banned constructs
- Manual
Provider, StateNotifierProvider, FutureProvider, StreamProvider declarations except in legacy migration files. AsyncValue.value!, AsyncValue.requireValue, ignoring the error branch.setState outside the three allowed cases (animation, in-form transient input, TextEditingController local listeners).- Raw
Navigator.push / Navigator.pushNamed. Use context.go / context.push with go_router. - Hand-rolled
== / hashCode on models with more than two fields. Use freezed. dynamic in feature code (dart:async's FutureOr<dynamic> for callbacks is exempt).- Importing
package:flutter/material.dart in pure model files. print() in committed code. Use dart:developer's log() or a logger package.
Required patterns
- Widgets that read providers are
ConsumerWidget or HookConsumerWidget (with flutter_hooks + hooks_riverpod). AsyncValue unwrapping uses switch pattern matching with all three branches (data, loading, error) handled.- Error branches include a retry path via
ref.invalidate(...) or ref.refresh(...). const on every constructor where it fits.- JSON serialization via freezed +
json_serializable. No hand-written fromJson / toJson. - Async operations cancelable via
ref.onDispose(() => ...) when the provider holds a StreamSubscription, Timer, or WebSocket.
Testing conventions
- Widget tests use
ProviderScope with overrides to inject mocks. No GetIt, no service locators. - Mock the API layer (
apiProvider), not individual providers. - Use
testWidgets with pump() for animation frames and pumpAndSettle() only when there is a finite animation to settle.
When the user asks for a simple setState fix
Push back. Ask whether the state belongs in a provider. The default answer is yes for anything beyond a transient animation or single-field form input.
Where this rule fails
1.build_runner is slow. The codegen step that powers Riverpod 2 annotations + freezed + json_serializable runs on every save in watch mode. On a 100-file project, a cold start can take 30 seconds and a hot incremental can take 2-3 seconds. The rule doesn't fix this — it makes you live with it as the cost of the type safety.
2. The agent sometimes generates the .g.dart part-file directives wrong. Cursor will name the part file _provider.g.dart (singular) when the file is product_providers.dart (plural). Build runner then can't find the generated output and the build fails. The fix is a manual rename; the rule body can't enforce file-naming consistency in a way Cursor reliably honors.
3. AsyncValue switch matching requires Dart 3.0+. If the project is pinned below Dart 3 (anything still on Flutter 3.7 or earlier), the switch syntax doesn't compile. The rule body assumes 3.27 / Dart 3.6 — if your project is older, drop back to .when(data:, loading:, error:).
4. The Riverpod docs change. The 2.x → 3.x migration (if/when it lands) will likely move codegen patterns again. The rule body is correct as of Riverpod 2.5; recheck the Riverpod migration guide if you're starting a new project after late 2026.
5. flutter_hooks is divisive. The rule allows HookConsumerWidget but doesn't mandate it. Some teams prefer plain ConsumerWidget with stateful local logic moved to providers. Pick one stance for your project and add it to the rule's project contract — the agent does better with one fewer ambiguous choice.
What to read next
- /topic/cursor-rules-typescript-5-6 — the TypeScript rule for projects where Flutter consumes a TS API
- /topic/cursor-rules-rust-axum — a common backend pairing for Flutter mobile apps
- /topic/claude-md — the same directives translated to Claude Code's format
- /topic/agents-md — and the cross-tool AGENTS.md version
- /for/awesome-cursorrules — the canonical 39.5k-star collection (Flutter category included)
- /for/flutter-ai-rules — evanca's Flutter-focused rule collection
Sources
- Flutter team. Flutter 3.27 release notes. Material 3 default behavior, Impeller defaults.
- Riverpod team. Riverpod 2 documentation. The
@riverpodannotation pattern this rule mandates. - Freezed package. Freezed documentation. Model generation, copyWith, JSON serialization integration.
- Dart team. Dart 3 patterns and sealed classes. The
switchexpression and exhaustive matching pattern. - go_router team. go_router documentation. Named-route conventions and
context.go/context.pushsemantics. - evanca. flutter-ai-rules. The most-cited community Flutter Cursor rule collection.
- PatrickJS.
awesome-cursorrulesMobile category. The Flutter entries this page revises. - The Hacker News. "Cursor AI Code Editor Vulnerability — Rules File Backdoor". Reason every rule should be reviewed before paste.
Related GitHub projects
Frequently asked
- Should the rule mandate Riverpod 2 codegen?
- Yes. The hand-typed `Provider`, `StateNotifierProvider`, and `FutureProvider` declarations from Riverpod 1 are still the most common pattern in older Flutter Cursor rules — but Riverpod 2 introduced the `@riverpod` and `@Riverpod(keepAlive: true)` annotations that generate provider definitions from a regular function. Codegen output is shorter, more type-safe, and (importantly) what the official Riverpod team recommends for new code as of 2.0. The rule body forces codegen except in two named cases: legacy migration files and one-off `Provider.value` overrides in tests.
- Why ban `setState` in feature widgets?
- Because the project chose Riverpod for state. `setState` and `Provider` co-existing in the same feature is the single biggest source of stale-UI bugs in mid-size Flutter apps. The rule allows `setState` in three places: animation `AnimatedBuilder`s, `Form` widgets where you scope state to the form, and `TextEditingController` listeners that update local UI only. Everything else routes through a Riverpod provider — including loading flags, error messages, and form submission state.
- What does the rule say about `AsyncValue`?
- Three-way pattern matching everywhere a future is read. `AsyncValue.when(data: ..., loading: ..., error: ...)` or the more recent `switch (asyncValue) { AsyncData(:final value) => ..., AsyncLoading() => ..., AsyncError(:final error) => ... }`. The rule forbids `asyncValue.value!` (the non-null bang) and forbids ignoring the error case. Both are patterns Cursor reaches for to shorten the code; both ship silent bugs.
- Should immutable models use freezed or Dart 3 sealed classes?
- Freezed for feature models with copyWith, JSON serialization, and equality. Dart 3 sealed classes for state unions (loaded / loading / error variants) where you want exhaustive pattern matching at the call site. The rule has a decision table: if the model needs `copyWith` and `fromJson`, freezed; if it's a discriminated state, sealed class. Cursor will pick one randomly without the rule — usually freezed, even when a sealed class would be cleaner.
- Does the rule cover Material 3 vs Cupertino?
- Yes, lightly. The rule defaults to Material 3 (`useMaterial3: true` is the Flutter 3.27 default and the previous opt-out got removed). For iOS-only screens or platform-conditional widgets, the rule recommends `Platform.isIOS` + Cupertino widgets via the `flutter_platform_widgets` package — not hand-rolled conditional builds in every widget.