1. Introduction
In this codelab, you build a multiplayer counter app. You learn how to use Dart for both the Flutter frontend and the Firebase backend.
You also learn how to share data models between your app and your server, eliminating the need to duplicate logic.
What you'll learn
- Extract shared business logic into a standalone Dart package.
- Write and deploy Cloud Functions for Firebase natively in Dart.
- Leverage Dart's Ahead-of-Time (AOT) compilation to reduce serverless cold starts.
- Test your stack locally using the Firebase Emulator Suite.
2. Prerequisites
- Flutter SDK (latest stable version).
- Firebase CLI (v15.15.0 or higher is required).
- A code editor, such as Antigravity, Visual Studio Code, IntelliJ, or Android Studio, with the Dart and Flutter plugins installed.
- Basic familiarity with Flutter and Firebase.
3. Why use Dart for the backend?
Many cloud applications use Dart for the frontend UI and another language, such as TypeScript, Python, or Go, for the backend. This requires maintaining two separate sets of data models. When a database schema changes, you must update both codebases.
Note: Using Dart on the backend lets you combine the responsive user experience of Flutter on the client with secure validation on the server, without duplicating code.
4. Create the Flutter app
Create a standard Flutter app:
flutter create my_counter
cd my_counter
# Run the app to see the default counter example
flutter run
In a standard Flutter app, lib/main.dart handles the counter state locally:
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
This approach works for local state, but doesn't scale to a multiplayer application where the server must act as the source of truth. To support multiple players, we'll move this logic to the backend in the following steps.
5. Create the shared package
To avoid duplicating models on the frontend and backend, create a shared Dart package inside your project repository. Both the Flutter app and functions for Firebase depend on this package.
From the root of the my_counter project, run the following commands:
mkdir -p packages
cd packages
dart create -t package shared
Add dependencies
In packages/shared/pubspec.yaml, add the JSON serialization tools:
dependencies:
json_annotation: ^4.9.0
dev_dependencies:
build_runner: ^2.4.9
json_serializable: ^6.8.0
Define your shared models
Create packages/shared/lib/src/models.dart. This file defines the data structure used by both the app and the server.
import 'package:json_annotation/json_annotation.dart';
part 'models.g.dart';
@JsonSerializable()
class IncrementResponse {
final bool success;
final String? message;
final int? newCount;
const IncrementResponse({required this.success, this.message, this.newCount});
factory IncrementResponse.fromJson(Map<String, dynamic> json) =>
_$IncrementResponseFromJson(json);
Map<String, dynamic> toJson() => _$IncrementResponseToJson(this);
}
// Store the function name as a constant to ensure consistency between client and server.
const incrementCallable = 'increment';
In packages/shared/lib/shared.dart, export your models:
library shared;
export 'src/models.dart';
In the packages/shared directory, run the build runner to generate the JSON serialization code:
dart run build_runner build
6. Set up Cloud Functions for Firebase
Cloud Functions for Firebase is a serverless framework that lets you automatically run backend code without having to manage and scale your own servers. Dart is a great fit because it compiles Ahead-of-Time (AOT) to a binary, it doesn't require a heavy runtime environment like Node.js or Java. This significantly reduces cold start times for your functions.
Navigate to your project root and initialize Cloud Functions for Firebase:
cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
- When prompted for the language, select Dart.
Link the shared package
In functions/pubspec.yaml, add a relative path to the shared package:
dependencies:
firebase_functions:
google_cloud_firestore:
shared:
path: ../packages/shared
7. Write the function
To write the backend logic, open functions/bin/server.dart and replace the contents with the following code:
import 'dart:convert';
import 'package:firebase_functions/firebase_functions.dart';
import 'package:google_cloud_firestore/google_cloud_firestore.dart'
show FieldValue;
import 'package:shared/shared.dart';
void main(List<String> args) async {
await fireUp(args, (firebase) {
// Listen for calls to the http request and name defined in the shared package.
firebase.https.onRequest(name: incrementCallable, (request) async {
// In a production app, verify the user with request.auth?.uid here.
print('Incrementing counter on the server...');
// Get firestore database instance
final firestore = firebase.adminApp.firestore();
// Get a reference to the counter document
final counterDoc = firestore.collection('counters').doc('global');
// Get the current snapshot for the count data
final snapshot = await counterDoc.get();
// Increment response we will send back
IncrementResponse incrementResponse;
// Check for the current count and if the snapshot exists
if (snapshot.data() case {'count': int value} when snapshot.exists) {
if (request.method == 'GET') {
// Get the current result
incrementResponse = IncrementResponse(
success: true,
message: 'Read-only sync complete',
newCount: value,
);
} else if (request.method == 'POST') {
// Increment count by one
final step = request.url.queryParameters['step'] as int? ?? 1;
await counterDoc.update({'count': FieldValue.increment(step)});
incrementResponse = IncrementResponse(
success: true,
message: 'Atomic increment complete',
newCount: value + step,
);
} else {
throw FailedPreconditionError(
'only GET and POST requests are allowed',
);
}
} else {
// Create a new document with a count of 1
await counterDoc.set({'count': 1});
incrementResponse = const IncrementResponse(
success: true,
message: 'Cloud-sync complete',
newCount: 1,
);
}
// Return the response as JSON
return Response(
200,
body: jsonEncode(incrementResponse.toJson()),
headers: {'Content-Type': 'application/json'},
);
});
});
}
8. Test locally with the Firebase Emulator Suite
You can run both the frontend and backend locally without deploying.
From your project root, start the Firebase Emulator Suite:
# Enable functions and firestore for the emulators
firebase init emulators
# Start the emulators and optionally open up the Admin UI
firebase emulators:start
Link the shared package
In pubspec.yaml, add a relative path to the shared package and add the http package:
dependencies:
http: ^1.6.0
shared:
path: ../packages/shared
In your Flutter project, open lib/main.dart and replace its contents with the following code. This frontend code uses the same IncrementResponse class as the backend.
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:shared/shared.dart';
/// Get from emulator output when running or when deploying:
/// ✔ functions[us-central1-increment]: http function initialized
/// (http://127.0.0.1:5001/demo-no-project/us-central1/increment).
const incrementUrl = 'FIREBASE_FUNCTIONS_URL_HERE';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
home: const CounterPage(),
);
}
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _count = 0;
bool _loading = false;
@override
void initState() {
super.initState();
// Fetch the current count
_increment(readOnly: true).ignore();
}
Future<void> _increment({bool readOnly = false}) async {
setState(() => _loading = true);
try {
// Call the Dart function.
final uri = Uri.parse(incrementUrl);
final response = readOnly ? await http.get(uri) : await http.post(uri);
// Parse the response back into the shared Dart object.
final responseData = jsonDecode(response.body);
final incrementResponse = IncrementResponse.fromJson(responseData);
if (incrementResponse.success) {
setState(() => _count = incrementResponse.newCount ?? _count);
}
} catch (e) {
print("Error calling function: $e");
} finally {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Multiplayer Counter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_count',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _loading ? null : _increment,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
Run the Flutter app. When you click the floating action button, the app calls the local Dart backend, retrieves the new count, and updates the UI.
9. Deploy to Firebase
In this codelab, you were able to try out functions without a Firebase project or billing account by using the Firebase Local Emulator Suite. If you want to use your functions in a real environment (like a production environment), then you need to set up a Firebase project and billing.
Create a Firebase project
- Sign into the Firebase console using your Google Account.
- Click the button to create a new project, and then enter a project name.
- Click Continue.
- If prompted, review and accept the Firebase terms, and then click Continue.
- (Optional) Enable AI assistance in the Firebase console (called "Gemini in Firebase").
- For this codelab, you do not need Google Analytics, so toggle off the Google Analytics option.
- Click Create project, wait for your project to provision, and then click Continue.
Upgrade your Firebase pricing plan
To use the Firebase services in this codelab, your Firebase project needs to be on the pay-as-you go (Blaze) pricing plan, which means it's linked to a Cloud Billing account.
- A Cloud Billing account requires a payment method, like a credit card.
- During special promotions or if you're doing this codelab as part of an event, there may be Google Cloud credits available (for example, the banner at the top of this page).
- If you're new to Firebase and Google Cloud, check if you're eligible for a $300 credit and a Free Trial Cloud Billing account.
To upgrade your project to the Blaze plan, follow these steps:
- In the Firebase console, select to upgrade your plan.
- Select the Blaze plan. Follow the on-screen instructions to link a Cloud Billing account to your project.
- If you claimed Google Cloud credits for this codelab, the billing account is likely called
Google Cloud Platform Trial Billing AccountorMy Billing Account. - If you needed to create a Cloud Billing account as part of this upgrade, you might need to navigate back to the upgrade flow in the Firebase console to complete the upgrade.
- If you claimed Google Cloud credits for this codelab, the billing account is likely called
Deploy to your Firebase project
To deploy your Dart backend, run the following command using the Firebase CLI:
firebase use <PROJECT_ID>
firebase deploy --only functions
After the command runs, copy the URL and replace FIREBASE_FUNCTIONS_URL_HERE in the flutter app source code we added earlier.
10. Troubleshooting
firebase: command not found
Ensure that the Firebase CLI is installed and your PATH is updated. You can install it using npm: npm install -g firebase-tools.
Dart missing from init functions templates
For Dart to show as a list of options to deploy with and create the template code when running firebase init functions an experiment flag needs to be set by running firebase experiments:enable dartfunctions.
The functions emulator isn't connecting
Verify that you are using localhost and port 5001. If you are testing on an Android Emulator, the device doesn't resolve localhost to your host machine. Update the emulator configuration in main.dart to use 10.0.2.2.
The shared package is not found
Verify the relative path in functions/pubspec.yaml. If your folder structure differs from the codelab, adjust path: ../packages/shared to point to the correct directory.
Do I need to use json_serializable?
While not strictly required, using json_serializable prevents errors caused by manually writing fromJson and toJson methods. It ensures your frontend and backend expect the exact same data format.
11. Congratulations
You successfully built a full-stack Dart application. By maintaining your data models in a shared package, you ensure that your API responses and client UI remain synchronized, using a single programming language across your entire stack.