← Mobile Dev / Flutter for Next.js Developers
Step 01 · Foundations

Core concepts &
mental model

You already think in components, state, and routing. Flutter maps to all of it — but the rendering engine is completely different, and that changes everything.

🚨
The big shift: Flutter has no DOM, no CSS, no browser. It uses the Skia / Impeller graphics engine — like a game engine. Every widget paints its own pixels directly onto a canvas. This is why Flutter apps look identical on every platform.

The parallel universes

🌐

Next.js world

You write JSX that maps to HTML elements. The browser's layout engine handles rendering. CSS positions elements, the DOM is mutated, React reconciles virtual DOM diffs.

📱

Flutter world

You write widget trees that map to drawn shapes. Flutter's engine renders to a canvas — no browser, no HTML. Layout is handled by Flutter's own constraint system.

File equivalents

package.json + next.config.js
// package.json { "name": "my-app", "version": "1.0.0", "dependencies": { "next": "^14.0.0", "axios": "^1.6.0" } } // next.config.js module.exports = { images: { domains: ['cdn.example.com'] }, env: { API_URL: '...' }, };
pubspec.yaml (one file does both)
# identity + deps = package.json name: my_app version: 1.0.0+1 environment: sdk: ">=3.3.0 <4.0.0"< /span> dependencies: flutter: { sdk: flutter } dio: ^5.4.0 # framework config = next.config.js flutter: uses-material-design: true assets: - assets/images/

CLI commands

npm / next CLI
npx create-next-app@latest # scaffold npm install axios # add dep npm run dev # dev server npm run build # production npx tsc --noEmit # type check
flutter CLI
flutter create my_app # scaffold flutter pub get # install deps flutter run # dev + hot reload flutter build apk # Android release flutter analyze # type check
Step 02 · UI Building Blocks

Widgets are just
components

A widget is Flutter's React component. Same idea: composable, reusable UI primitives arranged in a tree. The API looks different but the mental model is identical.

React function component
interface Props { name: string; age: number; } const UserCard = ({ name, age }: Props) => ( <div className="card"> <h2>{name}</h2> <p>Age: {age}</p> </div> );
Flutter StatelessWidget
class UserCard extends StatelessWidget { final String name; final int age; const UserCard({ super.key, required this.name, required this.age, }); @override Widget build(BuildContext context) => Column(children: [ Text(name, style: TextStyle(fontSize: 20)), Text('Age: $age'), ]); }
useState hook
const Counter = () => { const [count, setCount] = useState(0); return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(c => c + 1)} >Increment</button> </div> ); };
StatefulWidget + setState
class Counter extends StatefulWidget { const Counter({super.key}); @override State<Counter> createState() => _CS(); } class _CS extends State<Counter> { int count = 0; @override Widget build(BuildContext _) => Column(children: [ Text('Count: $count'), ElevatedButton( onPressed: () => setState(() => count++), child: Text('Increment'), ), ]); }

Common widget equivalents

Column / Row
div flex-col / flex-row
Vertical or horizontal children. mainAxisAlignment = justify-content.
Container
styled div
Padding, margin, decoration, constraints. Closest to a styled div.
Text
p / span
Render text. TextStyle is inline CSS on the text node.
Stack
position: relative
Overlapping children. Positioned inside = absolute children.
ListView
map() + overflow-y scroll
Scrollable list. ListView.builder = virtualized rendering.
GestureDetector
onClick / onHover
Wraps any widget to detect taps, swipes, long press.
Scaffold
app/layout.tsx shell
The page shell: AppBar, body, FAB, bottom nav, drawer.
TextField
input[type=text]
Uses TextEditingController instead of onChange + useState.
FutureBuilder
Suspense + useEffect
Builds UI reactively based on a Future's state: loading / error / done.
Step 03 · Platform

App lifecycle
in depth

Click any lifecycle event on the phone simulator to see exactly what fires on Android, iOS, and inside Flutter — with the Dart code that handles it.

Two lifecycle layers, one app. Flutter maps both Android Activity callbacks and iOS UIApplicationDelegate states down to a single AppLifecycleState enum — but each platform has subtleties that affect when and how you respond. Click each scenario below to explore.
📱 Notification Centre
📧 3 new emails
📱
📷
🗺️
🎵
⚙️
🔒
App Running
Fully interactive
← Click a scenario
to simulate it
Scenarios
Widget-level
resumed
App is in the foreground, fully interactive. The user can see and touch the app. This is the normal running state — equivalent to a browser tab that's active and visible.
Android: onResume() iOS: Active AppLifecycleState.resumed
What to do
Flutter code
React equiv.
Event log
Flutter console output

Widget-level lifecycle vs React hooks

React hooks lifecycle
useEffect(() => { // Mount — runs after first render fetchData(); const sub = subscribe(); return () => { // Cleanup on unmount sub.unsubscribe(); timer.clear(); }; }, []); useEffect(() => { // Dep changed — re-fetch refetch(userId); }, [userId]);
Flutter State methods
void initState() { super.initState(); // Mount — sync only, no await here fetchData(); _sub = stream.listen(_onEvent); } void dispose() { // Cleanup — mirrors useEffect return _sub.cancel(); _ctrl.dispose(); super.dispose(); } void didUpdateWidget(MyWidget old) { super.didUpdateWidget(old); // Props changed — mirrors dep array if (old.userId != widget.userId) refetch(widget.userId); }
Practical cheat sheet: Save lightweight UI state in paused. Save critical data in inactive. Restore connections in resumed. Never rely on detached — it may not fire on iOS at all.
Step 04 · Platform

Navigation
in depth

Flutter's navigation is a physical back-stack of screens. Use the interactive simulator below to push, pop, replace, and nest routes — and see exactly what code each operation generates.

📚
The core mental model: Flutter maintains a stack of routes. Push adds a screen on top. Pop removes the top screen. go_router adds URL-style path matching on top of this stack. Use the simulator below to feel the difference between every navigation operation.

go_router vs Next.js — setup comparison

Next.js App Router
// File system = routes automatically app/ page.tsx → / products/page.tsx → /products products/[id]/page.tsx → /products/:id checkout/page.tsx → /checkout // Navigate programmatically import { useRouter } from 'next/navigation'; const router = useRouter(); router.push('/products/42'); // history push router.replace('/login'); // replace entry router.back(); // go back
go_router setup
// Explicit route config in code final router = GoRouter(routes: [ GoRoute(path: '/', builder: (_, __) => HomeScreen()), GoRoute(path: '/products', builder: (_, __) => ProductsScreen()), GoRoute(path: '/products/:id', builder: (_, s) => ProductDetail( id: s.pathParameters['id']!)), GoRoute(path: '/checkout', builder: (_, __) => CheckoutScreen()), ]); context.push('/products/42'); // ← stack grows context.go('/home'); // ← stack replaced context.pop(); // ← back

Returning data from a screen — a Flutter superpower

💡
No web equivalent: Push a screen, await the result when the user pops. Like a Promise that resolves when the user dismisses the screen. Try the pop(result) operation in the simulator above.
Web workaround
// No native "return value" — use state router.push('/pick-color?from=settings'); // ColorPicker saves to zustand, then router.back() useEffect(() => { const picked = store.getPickedColor(); if (picked) setColor(picked); }, []);
Flutter await pop
// Caller awaits the result directly final color = await Navigator.push<Color>( context, MaterialPageRoute( builder: (_) => ColorPickerScreen()), ); if (color != null) setState(() => _color = color); // ColorPickerScreen pops with value Navigator.pop(context, Colors.blue);
Step 05 · Data & State

State
management

Flutter has three tiers: setState for local UI, Riverpod for shared global state, and BLoC for complex event-driven business logic. Each maps to something you already know from React.

Tier 1 · Local

setState

Built-in to StatefulWidget. For UI-only state: toggles, form inputs, tab selection.

≈ useState
Tier 2 · Shared

Riverpod

Type-safe providers, auto-dispose, works outside BuildContext. The modern recommended solution.

≈ Zustand / Jotai
Tier 3 · Complex ✦ Interactive

BLoC / Cubit

Event-driven stream-based state. Strict separation of UI and logic. Great for large teams.

≈ Redux + RTK
React useState
const Counter = () => { const [count, setCount] = useState(0); const [dark, setDark] = useState(false); return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(c => c+1)}> Increment </button> <button onClick={() => setDark(d => !d)}> Toggle theme </button> </div> ); };
Flutter setState
class _CounterState extends State<Counter> { int count = 0; bool dark = false; @override Widget build(BuildContext _) => Column(children: [ Text('Count: $count'), ElevatedButton( onPressed: () => setState(() => count++), child: Text('Increment'), ), Switch( value: dark, onChanged: (v) => setState(() => dark = v), ), ]); }
⚠️
When to stop using setState: The moment you need the same state in two sibling widgets, you've outgrown setState. Lift state up to a Riverpod provider instead — same rule as lifting state in React.
Zustand store
const useUserStore = create((set) => ({ user: null, isLoading: false, fetch: async (id) => { set({ isLoading: true }); const data = await getUser(id); set({ user: data, isLoading: false }); }, })); // In component const { user, isLoading, fetch } = useUserStore(); // ref.watch ≈ subscribe to store // ref.read ≈ one-time read outside component // ref.listen ≈ useEffect on store change
Riverpod AsyncNotifier
final userProvider = AsyncNotifierProvider<UserNotifier, User>( UserNotifier.new); class UserNotifier extends AsyncNotifier<User> { @override Future<User> build() => fetchUser(); } // In widget final userAsync = ref.watch(userProvider); userAsync.when( data: (u) => Text(u.name), loading: () => CircularProgressIndicator(), error: (e, _) => Text('Error: $e'), );
💡
ref.watch vs ref.read vs ref.listen: ref.watch() = subscribe and rebuild. ref.read() = one-time read without subscribing (use inside callbacks). ref.listen() = run a side-effect when state changes, like useEffect([dep]) in React.
Event
UI DISPATCHES
BLoC
PROCESSES LOGIC
State
WIDGET REBUILDS
Fire an event bloc.add(Event)
Cart events
Checkout events
BLoC logic on<Event>(handler)
on<AddItemEvent>
emit(state.copyWith(
  items: [...state.items, event.item],
  total: state.total + event.item.price,
))
on<RemoveItemEvent>
emit(state.copyWith(
  items: items.where(i ≠ id),
  total: total - item.price,
))
on<ClearCartEvent>
emit(CartState.initial())
on<CheckoutEvent>
emit(loading) →
await placeOrder() →
emit(success | error)
Emitted state BlocBuilder rebuilds
CartState
items.length0
total$0
statusidle
errornull
Idle
BlocBuilder<CartBloc, CartState> REBUILDING…
Cart is empty
Stream<CartState> fire an event to see the stream →
Event classes
State class
BLoC class
Widget (BlocBuilder)
Redux comparison
// Events are plain Dart classes — like Redux actions // Each event is a "thing the user did" sealed class CartEvent {} // No payload needed — just a signal final class ClearCartEvent extends CartEvent {} final class ResetEvent extends CartEvent {} // With payload — like Redux action with payload field final class AddItemEvent extends CartEvent { final CartItem item; const AddItemEvent({required this.item}); } final class RemoveItemEvent extends CartEvent { final String itemId; const RemoveItemEvent({required this.itemId}); } // Complex event with async flow final class CheckoutEvent extends CartEvent { final String userId; const CheckoutEvent({required this.userId}); } // Fire in the UI like this: context.read<CartBloc>().add(AddItemEvent(item: item));
// State is immutable — like Redux state shape // copyWith() is Flutter's spread: { ...state, key: value } enum CartStatus { idle, loading, success, error } class CartState { final List<CartItem> items; final double total; final CartStatus status; final String? error; const CartState({ this.items = const [], this.total = 0, this.status = CartStatus.idle, this.error, }); // ← This is { ...state, items: [...] } in JS CartState copyWith({ List<CartItem>? items, double? total, CartStatus? status, String? error, }) => CartState( items: items ?? this.items, total: total ?? this.total, status: status ?? this.status, error: error ?? this.error, ); // Named constructor — Redux initialState factory CartState.initial() => const CartState(); }
// BLoC class = Redux reducer + middleware combined // on<Event>() registers a handler for that event type class CartBloc extends Bloc<CartEvent, CartState> { final OrderRepository _repo; CartBloc({required OrderRepository repo}) : _repo = repo, super(CartState.initial()) { // Register handlers in constructor on<AddItemEvent> (_onAdd); on<RemoveItemEvent> (_onRemove); on<ClearCartEvent> (_onClear); on<CheckoutEvent> (_onCheckout); } void _onAdd(AddItemEvent event, Emitter<CartState> emit) { emit(state.copyWith( items: [...state.items, event.item], total: state.total + event.item.price, )); } void _onRemove(RemoveItemEvent e, Emitter<CartState> emit) { final item = state.items.firstWhere((i) => i.id == e.itemId); emit(state.copyWith( items: state.items.where((i) => i.id != e.itemId).toList(), total: state.total - item.price, )); } void _onClear(ClearCartEvent _, Emitter<CartState> emit) { emit(CartState.initial()); } // Async handler — like Redux Thunk / RTK createAsyncThunk Future<void> _onCheckout( CheckoutEvent event, Emitter<CartState> emit ) async { emit(state.copyWith(status: CartStatus.loading)); try { await _repo.placeOrder(state.items, event.userId); emit(state.copyWith(status: CartStatus.success)); } catch (e) { emit(state.copyWith( status: CartStatus.error, error: e.toString())); } } }
// BlocProvider provides the BLoC to the subtree // BlocBuilder listens and rebuilds on state change // context.read() fires events without rebuilding class CartPage extends StatelessWidget { @override Widget build(BuildContext ctx) => BlocProvider( // ← like React.Provider create: (_) => CartBloc(repo: GetIt.I()), child: BlocBuilder<CartBloc, CartState>( // buildWhen reduces unnecessary rebuilds buildWhen: (prev, next) => prev.items != next.items || prev.status != next.status, builder: (ctx, state) { if (state.status == CartStatus.loading) return CircularProgressIndicator(); return Column(children: [ ...state.items.map((item) => CartItemTile( item: item, onRemove: () => ctx.read<CartBloc>() .add(RemoveItemEvent(itemId: item.id)), ) ), Text('Total: \$${state.total}'), ElevatedButton( onPressed: () => ctx.read<CartBloc>() .add(CheckoutEvent(userId: 'u1')), child: Text('Checkout'), ), ]); }, ), ); }
// BLoC concepts map almost 1:1 to Redux + RTK // ─── Redux (RTK) ────────────────────────────── const cartSlice = createSlice({ name: 'cart', initialState: { items: [], total: 0, status: 'idle' }, reducers: { addItem: (state, action) => { /* mutate immer */ }, removeItem: (state, action) => { /* mutate immer */ }, clearCart: (state) => { state.items = []; state.total = 0; }, }, extraReducers: (builder) => { builder .addCase(checkout.pending, s => { s.status = 'loading'; }) .addCase(checkout.fulfilled,s => { s.status = 'success'; }) .addCase(checkout.rejected, s => { s.status = 'error'; }); }, }); const checkout = createAsyncThunk('cart/checkout', async (userId) => await api.placeOrder(userId)); // ─── BLoC equivalent ────────────────────────── // Event = Action type + payload // on<E>(handler) = case in reducer // emit(state) = return newState / immer mutate // CheckoutEvent = createAsyncThunk // BlocProvider = Redux <Provider store={store}> // BlocBuilder = useSelector hook // context.read<B>().add(event) = dispatch(action())
Step 06 · Data & State

Data
persistence

Mobile apps need offline-first storage. Flutter has a rich ecosystem — from simple key-value to full SQL, all via pub.dev packages.

Use case Flutter package Web equivalent Notes
Simple key-value shared_preferences localStorage Sync-like API. Strings, bools, ints. Great for settings, theme.
Secure storage flutter_secure_storage httpOnly cookie Android Keystore / iOS Keychain. For JWT tokens, passwords.
Local SQL sqflite SQLite (server-side) Full SQLite on device. Raw SQL queries.
ORM / type-safe DB drift Prisma / Drizzle Code-gen schema, typed queries, reactive streams.
NoSQL document isar Firestore / MongoDB Very fast. Schema in Dart. Great for offline-first apps.
File storage path_provider fs (Node.js) Gets app directories: documents, temp, cache.
Fast cache hive SWR / RQ cache No codegen. Boxes = tables. Great for caching API responses.
localStorage (web)
localStorage.setItem('theme', 'dark'); const theme = localStorage.getItem('theme'); localStorage.removeItem('theme');
shared_preferences
final prefs = await SharedPreferences.getInstance(); await prefs.setString('theme', 'dark'); final theme = prefs.getString('theme'); await prefs.remove('theme');
Step 07 · Tooling

pubspec.yaml
deep dive

One file replaces package.json, next.config.js, .nvmrc, and your asset pipeline — annotated for Next.js developers.

Three separate Next.js files
// package.json { "name": "my-app", "version": "1.0.0", "dependencies": { "next": "^14" } } // .nvmrc 20.11.0 // next.config.js module.exports = { images: { domains: ['cdn.x.com'] }, env: { API_URL: '...' }, };
pubspec.yaml — annotated
# ← package.json name + version name: my_app version: 1.0.0+1 # semver + build# # ← .nvmrc (Dart SDK version) environment: sdk: ">=3.3.0 <4.0.0"< /span> # ← dependencies dependencies: flutter: { sdk: flutter } go_router: ^13.2.0 dio: ^5.4.0 # ← devDependencies dev_dependencies: flutter_test: { sdk: flutter } build_runner: ^2.4.8 # ← next.config.js (framework config) flutter: uses-material-design: true assets: # ← /public - assets/images/ - assets/json/ fonts: # ← @font-face - family: Fraunces fonts: - asset: assets/fonts/Fraunces.ttf weight: 700
🔢
The version number trick: 1.0.0+1 — before + is the user-facing semver, after is the build number. App stores use the build number for internal versioning. Increment it on every release. No web equivalent — unique to mobile distribution.
Step 08 · Tooling

Dart for
JS developers

Dart feels like TypeScript with stricter null safety and no build toolchain anxiety. Most JS patterns translate directly — with some pleasant surprises.

TypeScript
const name: string = "Sakhawat"; const city = user?.address?.city; const label = value ?? 'default'; const fetch = async (id: string) => { const res = await getUser(id); return res.json(); }; // Destructuring const { name, age } = user; // Spread const merged = { ...a, ...b };
Dart
final String name = 'Sakhawat'; final city = user?.address?.city; // same! final label = value ?? 'default'; // same! Future<User> fetch(String id) async { final res = await dio.get('/users/$id'); return User.fromJson(res.data); } // No destructuring — named vars final name = user.name; // Spread works in collections final merged = { ...a, ...b };

The Dart features JS doesn't have

🏷️

Named parameters

Flutter widgets use named params by default. required makes them mandatory. This is why Flutter widget calls read like English:

Text('Hello', style: TextStyle(fontSize: 18), textAlign: TextAlign.center)
🔐

Sound null safety

Stricter than TypeScript. String? can be null. String cannot — the compiler enforces it, no runtime surprises.

String? maybe = null; // OK
String must = null; // ❌ compile error
🔀

Pattern matching (Dart 3)

Switch expressions with exhaustive matching — like TypeScript discriminated unions but built into the language:

final msg = switch (status) {
  Status.ok => 'All good',
  Status.error => 'Failed',
  _ => 'Unknown',
};
📋

Extension methods

Add methods to any type — even SDK types — without subclassing. Scoped to import.

extension StringX on String {
  bool get isEmail => contains('@');
}
'user@email.com'.isEmail; // true
🎓
Closing thought: Next.js abstracts the browser. Flutter abstracts the OS. Same philosophy, different target. Your instinct for component composition, state isolation, and async data fetching carries over almost 1:1. The learning curve is Dart syntax + mobile platform quirks — not a new way of thinking.