Build a full-stack Dart app with Cloud Functions for Firebase

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.

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

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

To deploy your Dart backend, run the following command:

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.