Skip to content

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:

  1. 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.
  2. Dart 3 patterns and sealed classes. Pattern-matching switch expressions, exhaustive sealed-class destructuring, and the late final improvements changed what idiomatic Dart looks like. Cursor will fall back to Dart 2 idioms if not told otherwise.
  3. Material 3 by default. The useMaterial3: true opt-out went away in Flutter 3.16+. Older rules sometimes still set it, which is dead code; the rule body removes that noise.
The rule below treats each as a directive.

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 AnimatedBuilder or AnimationController listener where the state is purely visual.
  • Inside a Form widget for transient input not yet submitted.
  • Inside a TextEditingController listener that updates local-only UI (a character count, a clear-button visibility flag).
Everything else routes through a provider.

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

Sources

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.

Related topics