There is a specific kind of friction that every Flutter developer who has tried to write a backend has felt. You spend your days writing expressive, null-safe, strongly typed Dart code on the frontend. Your models are clean. Your async/await chains read like prose. Your type system catches entire categories of bugs before they run. Then you open a new tab to write a Cloud Function, and suddenly you are in a TypeScript file, re-declaring the same User model you just defined in Dart, manually keeping the two versions in sync, and debugging a cannot read property of undefined error that your Dart compiler would have caught in milliseconds.
This friction was not a minor inconvenience. It was a fundamental structural tax on Flutter developers who wanted to own their full stack. You maintained two codebases in two languages with two concurrency models, two type systems, two package ecosystems, and two sets of tooling. Every change to a shared data shape required two edits. Every bug in the data contract between client and server required you to mentally context-switch between languages to trace. Teams building Flutter apps with Firebase backends often hired backend developers specifically because the JavaScript cognitive overhead was too steep for a mobile-focused team.
That changes now. Cloud Functions for Firebase has announced experimental support for Dart, and alongside it, an experimental Dart Admin SDK that lets you interact with Firestore, Authentication, Cloud Storage, and other Firebase services from your function code. You can write your backend in the same language as your frontend, share data models and validation logic in a common Dart package that both sides import, and deploy your server code with the same firebase CLI you already use. The dream of a unified Dart stack, which developers had been requesting for years, is officially here.
This handbook is a complete engineering guide to that unified stack. It covers how Dart Cloud Functions work, how they differ from Node.js functions in architecture and deployment, how the Admin SDK connects your function to Firebase services, how to share logic between your Flutter app and your backend using a common Dart package, how to call your functions from Flutter, and every current limitation you need to know before betting production workloads on an experimental feature. This is not a five-minute quickstart. It is the guide for teams making the decision about whether and how to build real products with Dart on the server.
By the end, you will understand the full-stack Dart architecture from first principles, know how to set up, write, emulate, and deploy Dart Cloud Functions, understand the Admin SDK's capabilities, build a shared package that eliminates data model duplication, and make a clear-eyed decision about when this experimental feature is ready for your production use case.
Table of Contents
Prerequisites
Before working through this handbook, you should have the following foundations in place. This guide does not assume expertise in cloud infrastructure, but it does build on Flutter and Firebase knowledge throughout.
Flutter and Dart proficiency. You should be comfortable writing multi-file Dart applications, working with async/await and Future, understanding Dart's null safety system, and managing packages with pub. Experience with building Flutter apps is expected because the end-to-end examples call functions from a Flutter client. If you have shipped a Flutter app to any store, you are ready.
Firebase fundamentals. You should have used Firebase before: created a project in the Firebase Console, connected it to a Flutter app using the FlutterFire CLI, and ideally used at least one Firebase service like Firestore or Authentication. You do not need prior Cloud Functions experience, though familiarity with the concept of serverless functions will help.
Command line comfort. The entire Dart Cloud Functions workflow happens in the terminal. You need to be comfortable running commands, reading terminal output, and navigating your filesystem from the command line.
Billing plan awareness. Deploying Cloud Functions of any kind to production requires your Firebase project to be on the Blaze (pay-as-you-go) billing plan. The Firebase Local Emulator Suite lets you develop and test functions without a billing account, so you can follow most of this guide locally without cost. However, be aware that deployment requires Blaze.
Tools to have ready. Ensure the following are installed and accessible from your terminal before you begin:
Flutter SDK 3.x or higher (which includes Dart SDK 3.x)
Firebase CLI version 15.15.0 or higher (run
firebase --versionto check; update withnpm install -g firebase-tools)Node.js 18 or higher (required by the Firebase CLI, not by your Dart code)
A code editor with the Dart plugin (VS Code with the Dart extension, or Android Studio)
A Firebase project created in the Firebase Console
Packages this guide uses. Your functions directory pubspec.yaml will include:
dependencies:
firebase_functions: ^0.1.0
google_cloud_firestore: ^0.1.0
firebase_functions is the core Dart package that provides fireUp, the registration APIs for onRequest and onCall, and the types used throughout your function code. google_cloud_firestore is the standalone Dart Firestore SDK used exclusively on the server side inside your Cloud Functions. It is not the same package as the cloud_firestore package you use in your Flutter app. They both talk to Firestore, but they are different libraries designed for different environments: one for a Flutter client running under Firebase Security Rules, the other for a server-side process running with full admin access.
Your shared package (covered in depth later) will have no Firebase dependencies. Your Flutter app's pubspec.yaml will continue to use the standard firebase_core, cloud_firestore, and other FlutterFire packages it already uses.
A critical note on the experimental status of this feature. Everything in this guide is based on the experimental Dart support announced at Google Cloud Next 2026. Experimental means the API may change without notice, some features available in Node.js functions are not yet available in Dart, and the Firebase Console does not yet display Dart functions. You view and manage them through the Cloud Run functions page in the Google Cloud Console instead. This is genuinely new territory, and the team is actively developing it. The guide will clearly mark every limitation as it is encountered so you always know exactly where the boundaries are.
What Are Cloud Functions and Why Does Dart Change Everything?
What Cloud Functions Are
Cloud Functions for Firebase is a serverless compute platform. "Serverless" means you write a function, deploy it, and Google manages everything else: the servers, the scaling, the load balancing, the operating system updates, and the availability. You pay only for the compute time your functions actually use, measured in fractions of a second, and your functions scale automatically from zero requests to millions without any infrastructure configuration on your part.
The value proposition is straightforward. Without Cloud Functions, adding backend logic to a Flutter app meant either running your own server (expensive, complex to manage) or stuffing business logic into the client (insecure, harder to change without a store update). Cloud Functions gives you a lightweight, secure, scalable backend layer that you can update independently of your app and that can talk to every Firebase service with elevated privileges the client should never have.
Before Dart support, your options for writing Cloud Functions were JavaScript, TypeScript, Python, Java, Go, and Ruby. For Flutter developers, all of those meant context-switching out of Dart, learning a new language's ecosystem and tooling, and duplicating shared logic between the client and server. Now Dart is on that list, and because your Flutter app is already Dart, the implications run deep.
The Unified Stack: What Actually Changes
The obvious change is language. You write .dart files instead of .ts or .py files. But the deeper change is about shared code.
In a TypeScript + Flutter architecture, your User model exists twice. One version in TypeScript on the server defines the shape that Firestore documents take and what the function returns. One version in Dart on the client defines how the Flutter app parses and displays user data. When a field changes, you update both. When a developer forgets to update both, a bug is born. That bug is often invisible in development because the server and client are usually built and tested separately, and it only surfaces in integration testing or in production.
In a full-stack Dart architecture, your User model exists once, in a shared Dart package that both the function and the Flutter app import. Change it in one place and both sides immediately reflect the update. The Dart analyzer enforces that both sides use the type correctly. A field rename is a refactor you run once, with the IDE doing the renaming across the entire codebase simultaneously, and the compiler verifying the result.
This diagram shows the core architectural difference. On the left, both sides of the stack define a User independently, meaning a change to one does not automatically enforce a change to the other. On the right, both sides import from a single shared package. The model exists once. The Dart compiler validates both uses at the same time, making drift structurally impossible rather than just carefully guarded against.
Why Dart Fits the Serverless Model Particularly Well
Dart is an ahead-of-time (AOT) compiled language, which means it compiles to native binary code before it runs rather than being interpreted at runtime. This property has a direct impact on one of the most discussed problems with serverless functions: cold starts.
A cold start happens when your function has been idle and a new request arrives. The platform needs to spin up a fresh instance, and if that requires loading a heavy runtime (as Node.js does) or a virtual machine (as Java does), the first request after a period of inactivity can take multiple seconds. In contrast, a Dart function compiles to a native binary with no runtime overhead. The cold start time for a Dart function is significantly lower than for equivalent Node.js or Python functions, making it better suited to workloads where latency on the first request matters.
The deployment process reflects this architecture. When you deploy a Dart function, the Firebase CLI does not upload your source code to be compiled in the cloud the way Node.js deployments work. It compiles your Dart code to a native binary on your development machine, then uploads that binary directly to Cloud Run. This means your machine needs the Dart SDK to build (which it already has if you develop Flutter), and it means the binary that runs in production is identical to what you tested locally.
The Problem This Solves: Life Before Dart on the Server
The Language Tax on Flutter Teams
Before this feature, a Flutter team that wanted a backend faced a real organizational choice. They could hire a backend developer who knew TypeScript or Python and create a permanent two-language split in the codebase. They could ask Flutter developers to learn TypeScript or Python well enough to write production backend code, which takes significant time and results in backend code written by people who are not experts in the backend language. Or they could avoid a custom backend entirely, trying to fit their entire product into what Firebase's client SDKs could do directly, which sometimes meant moving sensitive business logic into the client where it could be read and manipulated.
None of these choices was good. Each one was a tax on productivity, code quality, or product integrity, paid continuously as long as the split existed.
The Data Contract Problem
Even beyond the language switch, the data contract between a Flutter client and a TypeScript backend had to be maintained manually. Every API call between client and server involved a data shape that both sides needed to agree on. In practice, what happened was one of the following: the contract was documented in a README that fell out of date immediately, the contract was enforced through shared OpenAPI or protobuf schemas that added significant tooling complexity, or the contract was informal and bugs were caught in integration testing or, worse, in production.
Dart's type system, shared across both sides of the call, eliminates this problem structurally. The contract is the Dart type. The Dart compiler enforces it on both sides simultaneously. There is no README to maintain and no schema to generate.
The Tooling Gap
Flutter developers working in Dart have a rich, integrated development experience: a powerful static analyzer, hot reload, excellent IDE tooling, dart fix for automated code fixes, and a package ecosystem on pub.dev that covers most common needs. When those same developers moved to TypeScript for backend code, they left behind a familiar tooling environment and entered one that required its own configuration, its own formatter, its own linter setup, and its own dependency management. The cognitive overhead was real, and for teams where every developer wore multiple hats, it was a source of ongoing friction.
With Dart on the server, the same dart analyze, dart format, and dart pub commands work on both the Flutter app and the Cloud Functions code. The same IDE extensions apply. The same team knowledge applies.
How Dart Cloud Functions Work: Core Architecture
The Entry Point and fireUp
Every Dart Cloud Function starts from a single entry point file, by convention functions/bin/server.dart. The main function calls fireUp, which is the initialization function provided by the firebase_functions package. fireUp sets up the HTTP server that receives incoming requests and routes them to the appropriate handler, initializes the Firebase Admin SDK automatically using Google Application Default Credentials, and starts listening for requests on the correct port.
// functions/bin/server.dart
import 'package:firebase_functions/firebase_functions.dart';
void main(List<String> args) async {
await fireUp(args, (firebase) {
firebase.https.onRequest(
name: 'helloWorld',
options: const HttpsOptions(cors: Cors(['*'])),
(request) async {
return Response.ok('Hello from Dart Cloud Functions!');
},
);
});
}
fireUp is the runtime bootstrap provided by the firebase_functions package. The first argument, args, is the list of command-line arguments that the Cloud Functions environment passes when it starts your binary, which includes the port to listen on and other runtime configuration. fireUp parses those arguments and uses them to configure the underlying Shelf HTTP server. The second argument is a callback that receives a firebase object, which is your handle to everything the Cloud Functions runtime provides. Inside that callback is where you register all your functions. firebase.https exposes the two registration methods: onRequest for raw HTTP functions and onCall for callable functions. The name parameter is the identifier for this function, which appears in Cloud Run logs and is used to route requests. HttpsOptions with cors: Cors(['*']) tells the runtime to allow cross-origin requests from any domain, which is appropriate during development but should be restricted to specific domains in production. Response.ok(...) returns an HTTP 200 response with the given body text.
HTTP Functions with onRequest
An HTTP function responds to raw HTTP requests. It is the most flexible function type because you have full control over the request and response: you can inspect headers, parse any body format, and return any HTTP response code and body.
firebase.https.onRequest(
name: 'getUserProfile',
options: const HttpsOptions(
cors: Cors(['https://yourapp.com', 'https://staging.yourapp.com']),
minInstances: 0,
),
(request) async {
if (request.method != 'GET') {
return Response(405, body: 'Method not allowed');
}
final userId = request.url.queryParameters['userId'];
if (userId == null || userId.isEmpty) {
return Response(400, body: 'userId query parameter is required');
}
try {
final doc = await firebase.adminApp
.firestore()
.collection('users')
.doc(userId)
.get();
if (!doc.exists) {
return Response(404, body: 'User not found');
}
return Response.ok(
jsonEncode(doc.data()),
headers: {'content-type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(body: 'Failed to fetch user profile');
}
},
);
cors: Cors([...]) explicitly lists the domains allowed to call this function from a browser. Restricting this to your actual app domains in production prevents other websites from making requests to your backend on behalf of your users. minInstances: 0 means no instances are kept warm, so the function can experience a cold start after a period of inactivity. Setting this to 1 or higher keeps instances alive at all times, which eliminates cold starts but incurs cost even when no requests are being handled. request.method is the HTTP verb of the incoming request, checked here to enforce that this endpoint only accepts GET requests. request.url.queryParameters gives you the parsed query string as a Map<String, String>. Response(405, ...) constructs an HTTP response with a specific status code. Response.ok(...) is a convenience constructor for a 200 response. headers: {'content-type': 'application/json'} tells the caller that the body is JSON, which is important for any client that uses content negotiation. Response.internalServerError(...) returns a 500 status, used here in the catch block to avoid exposing internal error details to callers.
Callable Functions with onCall
A callable function is a special kind of HTTP function designed for direct invocation from a Firebase client SDK. Unlike raw HTTP functions, callables automatically handle Firebase Authentication context: if the calling client has a signed-in user, the function receives the user's UID and token claims without you needing to parse the Authorization header manually.
firebase.https.onCall(
name: 'createPost',
options: const CallableOptions(
cors: Cors(['*']),
),
(request, response) async {
if (request.auth == null) {
throw FirebaseFunctionsException(
code: 'unauthenticated',
message: 'You must be signed in to create a post.',
);
}
final uid = request.auth!.uid;
final data = request.data as Map<String, dynamic>;
final title = data['title'] as String?;
final content = data['content'] as String?;
if (title == null || title.trim().isEmpty) {
throw FirebaseFunctionsException(
code: 'invalid-argument',
message: 'Post title is required.',
);
}
if (content == null || content.trim().isEmpty) {
throw FirebaseFunctionsException(
code: 'invalid-argument',
message: 'Post content is required.',
);
}
final postRef = await firebase.adminApp
.firestore()
.collection('posts')
.add({
'title': title.trim(),
'content': content.trim(),
'authorId': uid,
'createdAt': FieldValue.serverTimestamp(),
});
return CallableResult({'postId': postRef.id, 'success': true});
},
);
request.auth is automatically populated by the Firebase Functions runtime when the calling client includes a valid Firebase Authentication ID token in the request. If the caller is not authenticated, request.auth is null. Checking for null and throwing FirebaseFunctionsException with the code 'unauthenticated' is the correct pattern for rejecting unauthenticated callers. FirebaseFunctionsException is important here because when you throw one inside a callable function, the Firebase Functions runtime intercepts it and sends a structured error response that the client SDK can interpret as a typed FirebaseFunctionsException object on the Flutter side, meaning you get machine-readable error codes across the boundary without parsing raw HTTP error bodies. request.auth!.uid is the verified Firebase Authentication UID of the signed-in user, safe to use for authorization decisions because the runtime has already verified the token. request.data is the payload sent by the Flutter client, deserialized from the request body into a Map<String, dynamic>. CallableResult(...) wraps the return value into the format the callable protocol expects, which the Flutter client receives as HttpsCallableResult.data.
The Current Limitations: What You Must Know
This is one of the most important sections in the handbook, and it must be read carefully before making architecture decisions.
Only onRequest and onCall can be deployed. Background triggers (Firestore document triggers, Authentication triggers, Pub/Sub triggers, Cloud Storage triggers, and Scheduled functions) can be run inside the local emulator for development purposes, but they cannot be deployed to production in the current experimental release. If your architecture depends on a Firestore trigger that runs when a document is created, you need to keep that trigger in a Node.js function for now and write only the business logic that does not require background triggers in Dart.
httpsCallable cannot call Dart callable functions by name. The standard Firebase client SDK method FirebaseFunctions.instance.httpsCallable('functionName') identifies functions by their name on the server. This identification mechanism does not work with Dart functions in the current release. Instead, you must use httpsCallableFromURL and pass the full Cloud Run URL of your function, which you receive when you deploy it. This is a meaningful workflow difference that affects how you configure your Flutter client.
The Firebase Console does not display Dart functions. When you deploy a Dart function and then open the Firebase Console's Functions section, you will not see it. You must go to the Cloud Run functions page in the Google Cloud Console to see, manage, and monitor your deployed Dart functions. This is a tooling gap that will likely be closed as the feature graduates from experimental status.
This table is the single most important reference when planning your architecture. Read the "Deployed to Production" column before committing to Dart for any function that relies on a trigger type listed as "No". Designing around a limitation you discover at deployment time is far more painful than designing around one you know about upfront.
The Firebase Admin SDK for Dart
What the Admin SDK Is
The Firebase Admin SDK is a set of server-side libraries that let your function code interact with Firebase services with elevated privileges. The client SDKs used by your Flutter app operate under Firebase Security Rules: a user can only read documents they are authorized to read, can only write to fields they are allowed to modify, and so on. The Admin SDK bypasses security rules entirely. It operates with full administrative access to your Firebase project.
This is why Admin SDK code must never run on the client. It runs only in secure server environments (Cloud Functions, Cloud Run, your own server) where the credentials granting admin access are protected. In Cloud Functions, the Admin SDK is initialized automatically using the function's service account, with no additional configuration required from you.
Automatic Initialization in Cloud Functions
When your Dart function runs inside the Cloud Functions environment, the Admin SDK initializes itself automatically using Google Application Default Credentials. These credentials are the function's attached service account, which has admin access to your Firebase project. You do not configure credentials, load a service account JSON file, or call any initialization function. It just works.
await fireUp(args, (firebase) {
firebase.https.onRequest(
name: 'adminExample',
(request) async {
final sensitiveDoc = await firebase.adminApp
.firestore()
.collection('admin_only')
.doc('config')
.get();
return Response.ok(jsonEncode(sensitiveDoc.data()));
},
);
});
firebase.adminApp is the pre-initialized Admin SDK instance. It is available immediately inside the fireUp callback because fireUp handles initialization before your callback runs, using the service account that Cloud Run attaches to your function's execution environment. firebase.adminApp.firestore() returns a Firestore instance that operates with full admin access, bypassing every Security Rule in your database. collection('admin_only').doc('config').get() reads a document from a collection that a regular client SDK user would never be able to access, because the Security Rule protecting it would block them. The Admin SDK has no such restriction. This is the power and the responsibility of server-side code: it can read and write anything, which is why it must never run in the client.
Firestore Operations with the Admin SDK
The Dart Admin SDK provides a Firestore API that covers reads, writes, updates, deletes, queries, and batch operations. The API is structurally similar to the client-side cloud_firestore Flutter package, which makes it immediately familiar, though it is not identical.
// Reading a single document
final docRef = firebase.adminApp
.firestore()
.collection('posts')
.doc(postId);
final snapshot = await docRef.get();
if (!snapshot.exists) {
return Response(404, body: 'Post not found');
}
final data = snapshot.data()!;
final title = data['title'] as String;
final authorId = data['authorId'] as String;
firebase.adminApp.firestore().collection('posts').doc(postId) builds a reference to a specific document without performing any network call. The reference is a lightweight object that describes a path in Firestore. .get() is where the actual network call happens. It returns a DocumentSnapshot whose .exists property tells you whether a document with this ID exists. snapshot.data() returns the document's fields as Map<String, dynamic>?, which is null if the document does not exist. The ! after data() is a null assertion that is safe here because you checked .exists on the line above. Casting data['title'] as String extracts the individual field with the Dart type you expect.
// Writing a new document with a server-generated ID
final newPostRef = await firebase.adminApp
.firestore()
.collection('posts')
.add({
'title': 'My Post',
'authorId': uid,
'createdAt': FieldValue.serverTimestamp(),
});
final newPostId = newPostRef.id;
.add({...}) creates a new document in the collection and lets Firestore generate a random unique ID for it. It returns a DocumentReference pointing to the newly created document. newPostRef.id gives you that generated ID, which you typically return to the client so it can navigate to or reference the new document. FieldValue.serverTimestamp() is a sentinel value that tells Firestore to replace this field with the server's current timestamp at the moment the write is committed, rather than using any clock from the client or from your function code. This ensures timestamps are always accurate regardless of system clock differences.
// Updating specific fields in an existing document
await firebase.adminApp
.firestore()
.collection('posts')
.doc(postId)
.update({
'likeCount': FieldValue.increment(1),
'lastModified': FieldValue.serverTimestamp(),
});
.update({...}) modifies only the fields you specify and leaves every other field in the document unchanged. This is the correct operation when you want to change a subset of fields. .set({...}) would replace the entire document with only the fields you provide, deleting any fields you did not include. FieldValue.increment(1) is another Firestore sentinel that atomically increments a numeric field by the given amount. This is safe for concurrent writes because Firestore handles the increment atomically on the server, preventing the race condition you would get if you read the current value, added one in your function, and wrote the result back.
// Querying with filters and ordering
final querySnapshot = await firebase.adminApp
.firestore()
.collection('posts')
.where('authorId', isEqualTo: uid)
.orderBy('createdAt', descending: true)
.limit(10)
.get();
final posts = querySnapshot.docs.map((doc) {
return {'id': doc.id, ...doc.data()};
}).toList();
.where('authorId', isEqualTo: uid) filters the query to only return documents where the authorId field matches the given uid. Multiple .where() calls can be chained to add additional filters. .orderBy('createdAt', descending: true) sorts the results by the createdAt field, newest first. When you use orderBy on a field, Firestore requires that field to be indexed, which it handles automatically for simple queries. .limit(10) caps the result set at ten documents to prevent unbounded reads. querySnapshot.docs is the list of DocumentSnapshot objects matching the query. Mapping each doc to {'id': doc.id, ...doc.data()} combines the auto-generated document ID (which is not stored inside the document's fields) with the document's field data into a single map.
// Batch writes: multiple operations committed atomically
final batch = firebase.adminApp.firestore().batch();
batch.set(
firebase.adminApp.firestore().collection('posts').doc(newPostId),
{'title': 'New Post', 'authorId': uid},
);
batch.update(
firebase.adminApp.firestore().collection('users').doc(uid),
{'postCount': FieldValue.increment(1)},
);
await batch.commit();
firestore().batch() creates a WriteBatch that accumulates multiple write operations before sending them to Firestore together. batch.set(...) and batch.update(...) queue operations without executing them immediately. batch.commit() is where all queued operations are sent to Firestore and executed atomically: if any operation fails, all of them are rolled back. This is the correct pattern whenever your business logic requires multiple documents to change together as a single unit, such as creating a post while simultaneously incrementing the author's post count. Without a batch, a crash between the two operations would leave your database in an inconsistent state.
Authentication Operations with the Admin SDK
The Admin SDK gives your functions the ability to verify ID tokens, look up users by UID or email, create and delete users, and set custom claims on user tokens. These operations require admin privileges that the client SDK cannot have.
firebase.https.onRequest(
name: 'securedEndpoint',
(request) async {
final authHeader = request.headers['authorization'];
if (authHeader == null || !authHeader.startsWith('Bearer ')) {
return Response(401, body: 'Unauthorized');
}
final idToken = authHeader.substring(7);
try {
final decodedToken = await firebase.adminApp
.auth()
.verifyIdToken(idToken);
final uid = decodedToken.uid;
return Response.ok(jsonEncode({'uid': uid, 'success': true}));
} on FirebaseAuthException catch (e) {
return Response(401, body: 'Invalid or expired token: ${e.message}');
}
},
);
request.headers['authorization'] reads the Authorization header from the incoming HTTP request. Firebase Authentication ID tokens are sent as Bearer tokens, meaning the header value is the string "Bearer " followed by the token. .startsWith('Bearer ') validates the format before attempting to extract the token. .substring(7) strips the "Bearer " prefix (7 characters) to get the raw token string. firebase.adminApp.auth().verifyIdToken(idToken) sends the token to Firebase's token verification service, which validates the signature, checks that it has not expired, and confirms it was issued by your Firebase project. If verification succeeds, it returns a DecodedIdToken containing the user's UID and any custom claims. If the token is invalid or expired, it throws a FirebaseAuthException, which you catch and translate into a 401 response. This pattern applies specifically to onRequest functions where you need to know who the caller is. For onCall functions, this entire flow is handled automatically by the runtime, which is one of the main advantages of using callable functions over raw HTTP functions.
await firebase.adminApp
.auth()
.setCustomUserClaims(uid, {'role': 'admin', 'premiumUser': true});
setCustomUserClaims(uid, {...}) attaches arbitrary key-value data to a user's Firebase Authentication token. This data is included in every ID token that user subsequently obtains, making it available both in your Admin SDK code as decodedToken.claims and in Firestore Security Rules as request.auth.token.role. Custom claims are the standard way to implement role-based access control in Firebase applications. The claims take effect the next time the user's token is refreshed, which happens automatically every hour, or you can force a refresh by calling user.getIdToken(true) on the client.
Setting Up Dart Cloud Functions: Step by Step
Step 1: Enabling the Experimental Feature
Because Dart support is experimental, it is gated behind a feature flag in the Firebase CLI. You must enable the flag before the CLI will offer Dart as an option during setup.
firebase experiments:enable dartfunctions
This command writes a flag to your local Firebase CLI configuration file. It is a one-time setup step that persists across projects and terminals on the same machine.
firebase experiments
Running this command lists all currently enabled experiments, letting you confirm that dartfunctions appears in the output before proceeding. If it does not appear, the firebase init functions command in the next step will not offer Dart as a language option, which is the most common first-time setup failure.
Step 2: Verifying Your CLI Version
Dart Cloud Functions require Firebase CLI version 15.15.0 or higher.
firebase --version
This command prints the currently installed CLI version. If the output is below 15.15.0, run the update command before continuing.
npm install -g firebase-tools
This updates the Firebase CLI to the latest version globally on your machine. The -g flag installs it globally so the firebase command is accessible from any directory.
firebase login
Re-logging in after a CLI update ensures your authentication credentials are fresh and associated with the correct Google account. Skip this if you already logged in recently and are confident your credentials are current.
Step 3: Initializing Cloud Functions with Dart
firebase init functions
When the CLI prompts for a language, select Dart. When it asks whether to install dependencies now, select Yes. The CLI generates the following structure:
functions/bin/server.dart is the entry point. The Firebase CLI knows to look here because firebase.json is configured to point to it. functions/lib/ is where you put additional Dart files that server.dart imports, keeping your function logic organized as the number of functions grows. functions/pubspec.yaml is the Dart package manifest for the functions codebase, separate from the Flutter app's pubspec.yaml. firebase.json is updated by the CLI to include the functions configuration, including the path to the compiled binary and the runtime settings.
The generated server.dart contains a working "Hello World" function you can run immediately to verify the setup:
import 'package:firebase_functions/firebase_functions.dart';
void main(List<String> args) async {
await fireUp(args, (firebase) {
firebase.https.onRequest(
name: 'helloWorld',
options: const HttpsOptions(cors: Cors(['*'])),
(request) async {
return Response.ok('Hello from Dart Cloud Functions!');
},
);
});
}
This is a minimal but complete Dart Cloud Function. The main function receives the command-line args array, which the Cloud Functions runtime passes when it starts the binary, then hands them to fireUp which reads the port configuration from them. The onRequest registration gives the function a name and a handler that responds to every HTTP request with a 200 status and a plain text body. Running this locally verifies that the emulator can compile and start your function before you invest time in more complex logic.
Step 4: Running the Local Emulator
firebase emulators:start
The emulator starts and outputs something like:
firebase emulators:start starts all emulators configured in your firebase.json. The Dart emulator compiles your function locally before starting the server, which is why you see the "Dart emulator ready" line after a brief build step. The functions emulator runs at port 5001 by default. The Firestore emulator runs at port 8080, and your function code automatically connects to the emulated Firestore rather than the production database when running inside the emulator. Your helloWorld function is callable at http://127.0.0.1:5001/your-project-id/us-central1/helloWorld. A notable advantage of the Dart emulator is hot reload: when you save changes to your .dart files, the emulator detects the change and automatically recompiles and restarts your function without you running any command.
Step 5: Connecting Your Flutter App to the Emulator
import 'package:cloud_functions/cloud_functions.dart';
void _connectToEmulators() {
FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001);
}
useFunctionsEmulator('localhost', 5001) tells the Flutter app's Firebase Functions client to send all function calls to the local emulator at port 5001 instead of to production. Call this before any function call is made in your app, typically in main() immediately after Firebase.initializeApp(). This method only affects function calls, not Firestore or Authentication, which have their own equivalent methods if you want to emulate those too.
if (Platform.isAndroid) {
FirebaseFunctions.instance.useFunctionsEmulator('10.0.2.2', 5001);
} else {
FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001);
}
The Android emulator runs inside a virtual machine that has its own network namespace. From the Android emulator's perspective, localhost refers to the emulator itself, not to your development machine. The special address 10.0.2.2 is how the Android emulator reaches the host machine's localhost. iOS simulators do not have this issue because they share the host machine's network, so localhost works correctly there. The Platform.isAndroid check selects the correct address at runtime, allowing the same code to work correctly on both platforms during development.
Step 6: Deploying to Production
firebase deploy --only functions
The --only functions flag tells the CLI to deploy just the functions and skip any other Firebase resources (Firestore rules, Hosting, and so on). The deployment process for Dart is meaningfully different from Node.js: the Firebase CLI runs dart compile exe on your development machine, producing a native binary. It then uploads that binary to Cloud Run. The deployment output includes the URL of your deployed function:
✔ functions: Finished running predeploy script.
✔ functions: helloWorld(us-central1) deployed successfully.
Function URL (helloWorld(us-central1)):
https://helloworld-abc123def456-uc.a.run.app
Save that URL. Because of the current limitation around httpsCallable name resolution, you will need to pass this URL directly when calling the function from Flutter. The hash in the URL (abc123def456) is unique to your project and function, and it does not change between deployments of the same function, so it is safe to hardcode in your Flutter app or load from Firebase Remote Config.
Calling Dart Functions from Flutter
Calling with httpsCallableFromURL
Because httpsCallable('functionName') does not work with Dart functions in the current release, you use httpsCallableFromURL with the full Cloud Run URL instead:
// lib/services/functions_service.dart
import 'package:cloud_functions/cloud_functions.dart';
class FunctionsService {
static const _createPostUrl =
'https://createpost-abc123def456-uc.a.run.app';
static const _getUserProfileUrl =
'https://getuserprofile-abc123def456-uc.a.run.app';
Future<String> createPost({
required String title,
required String content,
}) async {
try {
final callable = FirebaseFunctions.instance.httpsCallableFromURL(
_createPostUrl,
);
final result = await callable.call({
'title': title,
'content': content,
});
return result.data['postId'] as String;
} on FirebaseFunctionsException catch (e) {
throw _mapFunctionException(e);
}
}
Exception _mapFunctionException(FirebaseFunctionsException e) {
switch (e.code) {
case 'unauthenticated':
return UnauthorizedException('Please sign in to continue.');
case 'invalid-argument':
return ValidationException(e.message ?? 'Invalid input.');
case 'not-found':
return NotFoundException(e.message ?? 'Resource not found.');
default:
return ServerException(
e.message ?? 'An unexpected error occurred.',
);
}
}
}
Centralizing the function URLs as static const strings at the top of the service class means they are in one place, easy to find, and easy to update. In a larger app, consider loading them from Firebase Remote Config so you can update URLs without shipping a new app version. FirebaseFunctions.instance.httpsCallableFromURL(_createPostUrl) creates a HttpsCallable object targeting the given URL. This object wraps all the protocol details of the callable function format, including serializing your data as the request body and deserializing the response. callable.call({...}) executes the function call, sends the map as the request payload, and returns a HttpsCallableResult when the function completes. result.data is the Map<String, dynamic> returned by CallableResult(...) on the server. Catching FirebaseFunctionsException captures every structured error thrown by FirebaseFunctionsException on the server. e.code is the machine-readable error code, and _mapFunctionException converts it into a typed domain exception from your app's own exception hierarchy, keeping Firebase-specific types out of your business logic.
Calling HTTP Functions Directly
For onRequest HTTP functions, you call them like any other HTTP endpoint using Dart's http package:
import 'package:http/http.dart' as http;
import 'dart:convert';
class ProfileService {
static const _getUserProfileUrl =
'https://getuserprofile-abc123def456-uc.a.run.app';
Future<Map<String, dynamic>> getUserProfile(String userId) async {
final user = FirebaseAuth.instance.currentUser;
final idToken = await user?.getIdToken();
final response = await http.get(
Uri.parse('\(_getUserProfileUrl?userId=\)userId'),
headers: {
if (idToken != null) 'Authorization': 'Bearer $idToken',
'Content-Type': 'application/json',
},
);
if (response.statusCode == 200) {
return jsonDecode(response.body) as Map<String, dynamic>;
}
throw ServerException('Failed to fetch profile: ${response.statusCode}');
}
}
FirebaseAuth.instance.currentUser retrieves the currently signed-in user from the local Firebase Auth cache without making a network call. user?.getIdToken() fetches the user's current ID token, refreshing it if it has expired. The ? means this returns null if there is no signed-in user, which the conditional header insertion handles gracefully. if (idToken != null) 'Authorization': 'Bearer \(idToken' is Dart's collection if syntax, which conditionally includes the Authorization header only when a token is available. This lets the same service method work for both authenticated and anonymous requests by simply omitting the header when no token exists. Uri.parse('\)_getUserProfileUrl?userId=$userId') appends the query parameter to the URL. jsonDecode(response.body) as Map<String, dynamic> parses the JSON response body into a Dart map. If the status code is anything other than 200, a ServerException is thrown with the status code included for debugging.
The Shared Package: Eliminating Data Model Duplication
The shared package is the most architecturally significant part of the full-stack Dart story. It is a standalone Dart package with no Flutter dependency and no Firebase dependency that defines the data models, validation logic, constants, and utility functions used by both your Cloud Functions backend and your Flutter frontend.
Creating the Shared Package
dart create --template=package packages/shared
dart create --template=package generates a new Dart package with the standard library layout: a lib/ directory for public code, a test/ directory, and a pubspec.yaml. The packages/shared path places it inside a packages/ folder at the project root, which is the conventional location for internal packages in a mono-repository structure. After running this command, your project structure becomes:
The shared pubspec.yaml is intentionally minimal:
name: shared
description: Shared data models and logic for the Kopa app.
version: 0.1.0
environment:
sdk: ^3.0.0
dependencies:
json_annotation: ^4.8.0
dev_dependencies:
build_runner: ^2.4.0
json_serializable: ^6.7.0
test: ^1.24.0
The most important characteristic of this pubspec.yaml is what is absent: there is no flutter, no firebase_core, no firebase_functions, and no cloud_firestore. The shared package depends only on pure Dart libraries. This is what makes it importable from both the server-side functions package and the Flutter app simultaneously without causing version conflicts. json_annotation provides the @JsonSerializable() annotation used on model classes. json_serializable is a build-time code generator that reads those annotations and generates fromJson/toJson methods, listed as a dev dependency because it only runs during development, not at runtime. build_runner is the tool that executes code generators, also a dev dependency. test enables unit testing of the shared logic.
Defining Shared Models
// packages/shared/lib/src/models/post.dart
import 'package:json_annotation/json_annotation.dart';
part 'post.g.dart';
@JsonSerializable()
class Post {
final String id;
final String title;
final String content;
final String authorId;
final int likeCount;
final DateTime createdAt;
const Post({
required this.id,
required this.title,
required this.content,
required this.authorId,
required this.likeCount,
required this.createdAt,
});
factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
Map<String, dynamic> toJson() => _$PostToJson(this);
}
part 'post.g.dart' declares that a generated file named post.g.dart is part of this library. The json_serializable code generator creates this file when you run dart run build_runner build. @JsonSerializable() is the annotation that tells json_serializable to generate serialization code for this class. All fields are final because model objects should be immutable: once created, a Post does not change in place. You create a new Post with different values instead. Using DateTime for createdAt rather than a raw int timestamp or a String keeps the model at the right level of abstraction. Both the Flutter app and the function convert between DateTime and their specific timestamp formats locally, keeping the shared model free of either side's concerns. factory Post.fromJson(...) and toJson() delegate to the generated _\(PostFromJson and _\)PostToJson functions, eliminating hand-written serialization. Hand-written serialization is where most data contract bugs originate: a missed field, a wrong key name, a forgotten null check. Code generation eliminates that entire category of error.
// packages/shared/lib/src/validation/post_validation.dart
class PostValidation {
static const int titleMaxLength = 120;
static const int contentMaxLength = 10000;
static const int titleMinLength = 3;
static String? validateTitle(String? title) {
if (title == null || title.trim().isEmpty) {
return 'Title is required.';
}
if (title.trim().length < titleMinLength) {
return 'Title must be at least $titleMinLength characters.';
}
if (title.trim().length > titleMaxLength) {
return 'Title cannot exceed $titleMaxLength characters.';
}
return null;
}
static String? validateContent(String? content) {
if (content == null || content.trim().isEmpty) {
return 'Content is required.';
}
if (content.trim().length > contentMaxLength) {
return 'Content cannot exceed $contentMaxLength characters.';
}
return null;
}
static bool isValid({required String title, required String content}) {
return validateTitle(title) == null && validateContent(content) == null;
}
}
All members are static because PostValidation is a namespace for functions, not a class you instantiate. The length constants titleMaxLength, contentMaxLength, and titleMinLength are static const, meaning they exist at compile time, take no memory at runtime, and can be used both in runtime validation logic and in Flutter widget configuration (for example, as the maxLength parameter of a TextField). Each validator follows Dart's convention for form validators: returning null means valid, returning a String means invalid with that error message. The validateTitle method calls .trim() before checking length to prevent whitespace-padded strings from passing length validation. The isValid convenience method allows callers who only need a boolean (as opposed to the error message) to check both fields in one call, such as for enabling or disabling a submit button.
// packages/shared/lib/src/constants/api_constants.dart
class ApiConstants {
static const String createPostFunction = 'createPost';
static const String getUserProfileFunction = 'getUserProfile';
static const String likePostFunction = 'likePost';
static const String postsCollection = 'posts';
static const String usersCollection = 'users';
}
ApiConstants stores the string identifiers for function names and Firestore collection names that both sides of the stack reference. Using constants instead of string literals scattered across your code prevents typos and ensures that if a name changes, you update it in one place and the compiler surfaces every location that used it. Function name constants are used in firebase.https.onRequest(name: ApiConstants.createPostFunction) on the server and in URL construction or logging on the client. Collection name constants ensure the server and client always write to and read from identically named collections, preventing the class of bug where the function writes to "Posts" with a capital P and the client queries "posts" with a lowercase p.
// packages/shared/lib/shared.dart
export 'src/models/post.dart';
export 'src/models/user.dart';
export 'src/validation/post_validation.dart';
export 'src/constants/api_constants.dart';
This is the barrel file. It re-exports everything the package provides through a single import point. Consumers of the package write import 'package:shared/shared.dart' and immediately have access to Post, PostValidation, ApiConstants, and everything else the package exports. Without the barrel file, consumers would need to know the internal directory structure and import each file individually, which is a detail the package should hide.
Referencing the Shared Package from Functions
# functions/pubspec.yaml
name: kopa_functions
version: 0.1.0
environment:
sdk: ^3.0.0
dependencies:
firebase_functions: ^0.1.0
google_cloud_firestore: ^0.1.0
shared:
path: ../packages/shared
shared: path: ../packages/shared is a path dependency. It tells the Dart pub tool to resolve the shared package from the filesystem at the given relative path rather than from pub.dev. The path ../packages/shared goes up one level from functions/ to the project root, then down into packages/shared/. When the Firebase CLI compiles your Dart functions for deployment, it resolves this path dependency locally on your development machine and bundles it into the compiled binary, so it works correctly in production despite being a local path reference.
Referencing the Shared Package from Flutter
# pubspec.yaml (Flutter app)
dependencies:
flutter:
sdk: flutter
firebase_core: ^3.0.0
cloud_firestore: ^5.0.0
firebase_auth: ^5.0.0
cloud_functions: ^5.0.0
shared:
path: packages/shared
The Flutter app references the shared package with path: packages/shared, which is a relative path from the Flutter project root. Notice the path is packages/shared without the ../ prefix that the functions package uses, because the Flutter pubspec.yaml lives at the project root while the functions pubspec.yaml lives inside the functions/ subdirectory. Both reference the same physical directory on disk. This is the key insight: two different packages, with two different pubspec.yaml files written from two different perspectives, referencing the same source code.
Using Shared Logic in the Cloud Function
// functions/bin/server.dart
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) {
firebase.https.onCall(
name: ApiConstants.createPostFunction,
(request, response) async {
if (request.auth == null) {
throw FirebaseFunctionsException(
code: 'unauthenticated',
message: 'You must be signed in.',
);
}
final data = request.data as Map<String, dynamic>;
final title = data['title'] as String?;
final content = data['content'] as String?;
final titleError = PostValidation.validateTitle(title);
if (titleError != null) {
throw FirebaseFunctionsException(
code: 'invalid-argument',
message: titleError,
);
}
final contentError = PostValidation.validateContent(content);
if (contentError != null) {
throw FirebaseFunctionsException(
code: 'invalid-argument',
message: contentError,
);
}
final ref = await firebase.adminApp
.firestore()
.collection(ApiConstants.postsCollection)
.add({
'title': title!.trim(),
'content': content!.trim(),
'authorId': request.auth!.uid,
'likeCount': 0,
'createdAt': FieldValue.serverTimestamp(),
});
return CallableResult({'postId': ref.id});
},
);
});
}
import 'package:shared/shared.dart' pulls in the entire shared package in one line. ApiConstants.createPostFunction uses the shared constant for the function name rather than a string literal, ensuring the name the server registers matches exactly what any logging or monitoring system expects. PostValidation.validateTitle(title) and PostValidation.validateContent(content) run the exact same validation logic that the Flutter form runs on the client. Even if a malicious actor bypasses the client validation (which is always possible because client code is not trusted), the server enforces the same rules independently. ApiConstants.postsCollection is the shared collection name constant, ensuring the function writes to the same collection path the Flutter app reads from.
Using Shared Logic in the Flutter App
// lib/features/create_post/create_post_screen.dart
import 'package:flutter/material.dart';
import 'package:shared/shared.dart';
class CreatePostScreen extends StatefulWidget {
const CreatePostScreen({super.key});
@override
State<CreatePostScreen> createState() => _CreatePostScreenState();
}
class _CreatePostScreenState extends State<CreatePostScreen> {
final _titleController = TextEditingController();
final _contentController = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('New Post')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
validator: (value) => PostValidation.validateTitle(value),
maxLength: PostValidation.titleMaxLength,
),
const SizedBox(height: 16),
TextFormField(
controller: _contentController,
decoration: const InputDecoration(labelText: 'Content'),
validator: (value) => PostValidation.validateContent(value),
maxLength: PostValidation.contentMaxLength,
maxLines: 8,
),
],
),
),
);
}
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
}
validator: (value) => PostValidation.validateTitle(value) passes the shared validator directly to the TextFormField's validator property. Flutter's form system calls this function when the user submits the form, and the return value is either null (valid) or an error string (invalid), exactly matching the convention PostValidation uses. maxLength: PostValidation.titleMaxLength uses the shared constant to configure the field's character limit, ensuring the UI reflects the same limit that validation enforces. If the max length is later increased from 120 to 200, updating the constant in the shared package automatically updates both the form's character counter and the validation rule that enforces it, on both client and server, in a single change.
Architecture: How the Full Stack Fits Together
This diagram shows the complete journey of a single request. The Flutter app validates locally using shared logic and then makes a callable function invocation. Firebase's infrastructure receives the request, verifies the Authentication token, and routes the request to the correct Dart binary running on Cloud Run. The Dart function runs its own validation (using the same shared logic) and writes to Firestore using Admin SDK access. It returns a result that the Flutter client receives as structured data. Throughout this entire flow, every piece of code that could be shared between client and server is shared, and every piece that must be separate (Flutter widgets, Firebase Admin operations) is appropriately separated.
Project Structure for a Full-Stack Dart Project
The three-directory structure at the project root is the organizing principle: lib/ for the Flutter app, functions/ for the backend, and packages/ for everything shared between them. This separation makes it immediately clear where any piece of code belongs. The services/ directory in the Flutter app is where FunctionsService and similar classes live, keeping function call logic out of widgets. The handlers/ directory inside functions/lib/ is where per-domain function logic lives, keeping server.dart clean and focused on registration only.
Advanced Concepts
Organizing Multiple Functions
As your backend grows, registering every function inside a single fireUp callback becomes unwieldy. Extract handlers into separate files and import them into the server entry point:
// functions/lib/handlers/post_handler.dart
import 'package:firebase_functions/firebase_functions.dart';
import 'package:google_cloud_firestore/google_cloud_firestore.dart' show FieldValue;
import 'package:shared/shared.dart';
void registerPostHandlers(FirebaseApp firebase) {
firebase.https.onCall(
name: ApiConstants.createPostFunction,
(request, response) async {
// handler logic
},
);
firebase.https.onCall(
name: ApiConstants.likePostFunction,
(request, response) async {
// handler logic
},
);
firebase.https.onRequest(
name: ApiConstants.getUserProfileFunction,
(request) async {
// handler logic
},
);
}
registerPostHandlers(FirebaseApp firebase) is a plain top-level function that accepts the firebase object and registers all post-related functions using it. The function signature FirebaseApp firebase uses the type provided by firebase_functions so the parameter is typed correctly. This approach mirrors how the main.dart of a Flutter app works: a single entry point that calls setup functions responsible for different areas of configuration.
// functions/bin/server.dart
import 'package:firebase_functions/firebase_functions.dart';
import '../lib/handlers/post_handler.dart';
import '../lib/handlers/user_handler.dart';
void main(List<String> args) async {
await fireUp(args, (firebase) {
registerPostHandlers(firebase);
registerUserHandlers(firebase);
});
}
server.dart is now a clean orchestration file. It imports the registration functions from each domain handler file and calls them in sequence inside fireUp. Adding a new domain is as simple as creating a new handler file and adding one line here. The fireUp callback is the only place where the firebase object is available, so it must be passed to every registration function that needs it.
Error Handling Patterns
Production Cloud Functions need consistent, predictable error handling. Define a centralized error handler rather than scattering try-catch blocks across every function:
// functions/lib/utils/error_handler.dart
import 'package:firebase_functions/firebase_functions.dart';
typedef CallableHandler = Future<CallableResult> Function(
CallableRequest request,
CallableResponse response,
);
CallableHandler withErrorHandling(CallableHandler handler) {
return (request, response) async {
try {
return await handler(request, response);
} on FirebaseFunctionsException {
rethrow;
} on ArgumentError catch (e) {
throw FirebaseFunctionsException(
code: 'invalid-argument',
message: e.message,
);
} catch (e, stackTrace) {
print('Unhandled error in function: $e');
print(stackTrace);
throw FirebaseFunctionsException(
code: 'internal',
message: 'An internal error occurred. Please try again.',
);
}
};
}
typedef CallableHandler defines a Dart function type alias for the handler signature that onCall expects. This makes withErrorHandling typeable without repeating the full function signature everywhere. withErrorHandling is a higher-order function: it takes a handler function and returns a new function that wraps the original in a try-catch. on FirebaseFunctionsException { rethrow; } lets structured errors thrown intentionally in your handler pass through unchanged, because they are already in the correct format for the client. on ArgumentError catch (e) converts Dart's built-in ArgumentError (typically thrown by validation code) into a FirebaseFunctionsException with the invalid-argument code that the client can understand. The final catch (e, stackTrace) is the safety net for any unhandled exception, logging the full error internally with its stack trace while returning a sanitized message to the client that reveals nothing about the internal error.
firebase.https.onCall(
name: 'createPost',
withErrorHandling((request, response) async {
if (request.auth == null) {
throw FirebaseFunctionsException(
code: 'unauthenticated',
message: 'Authentication required.',
);
}
return CallableResult({'success': true});
}),
);
withErrorHandling(...) wraps the handler at registration time. The third positional argument to onCall (the handler function) is replaced by the return value of withErrorHandling, which is itself a function with the correct signature. The handler inside has no try-catch blocks of its own because withErrorHandling covers all error scenarios.
Testing Dart Cloud Functions
Cloud Functions written in Dart are plain Dart code, which means they are fully testable using standard Dart testing tools. The business logic inside your handlers can be extracted into pure functions with no Firebase dependency, then unit tested directly:
// functions/lib/handlers/post_logic.dart
import 'package:shared/shared.dart';
PostInput validateCreatePostRequest(Map<String, dynamic> data) {
final title = data['title'] as String?;
final content = data['content'] as String?;
final titleError = PostValidation.validateTitle(title);
if (titleError != null) throw ArgumentError(titleError);
final contentError = PostValidation.validateContent(content);
if (contentError != null) throw ArgumentError(contentError);
return PostInput(
title: title!.trim(),
content: content!.trim(),
);
}
class PostInput {
final String title;
final String content;
const PostInput({required this.title, required this.content});
}
validateCreatePostRequest is a pure function: it takes a Map<String, dynamic> and either returns a PostInput or throws an ArgumentError. It has no Firebase dependencies, no async calls, and no side effects. This makes it testable with a single dart test command, no Firebase emulator required. PostInput is a simple value class that carries the validated and trimmed inputs. Returning a typed result rather than the raw map ensures that callers receive validated data in a form the compiler can reason about.
// functions/test/post_logic_test.dart
import 'package:test/test.dart';
import '../lib/handlers/post_logic.dart';
void main() {
group('validateCreatePostRequest', () {
test('returns valid PostInput for correct data', () {
final result = validateCreatePostRequest({
'title': 'Valid Title',
'content': 'This is valid post content.',
});
expect(result.title, equals('Valid Title'));
expect(result.content, equals('This is valid post content.'));
});
test('throws ArgumentError when title is empty', () {
expect(
() => validateCreatePostRequest({'title': '', 'content': 'Content'}),
throwsA(isA<ArgumentError>()),
);
});
test('throws ArgumentError when title exceeds max length', () {
final longTitle = 'A' * 200;
expect(
() => validateCreatePostRequest({
'title': longTitle,
'content': 'Content',
}),
throwsA(isA<ArgumentError>()),
);
});
test('trims whitespace from title and content', () {
final result = validateCreatePostRequest({
'title': ' Padded Title ',
'content': ' Padded content. ',
});
expect(result.title, equals('Padded Title'));
expect(result.content, equals('Padded content.'));
});
});
}
group('validateCreatePostRequest', ...) groups related tests under a shared label, producing organized output that makes it easy to find failures. Each test(...) call exercises one specific behavior: the happy path, the empty title case, the oversized title case, and the whitespace trimming case. expect(result.title, equals('Valid Title')) is the assertion: it checks that the actual value matches the expected value. throwsA(isA<ArgumentError>()) is a matcher that passes only if the callable throws an ArgumentError, which is the contract validateCreatePostRequest defines for invalid input. 'A' * 200 is a Dart string repetition that creates a 200-character string, which exceeds the titleMaxLength of 120 defined in the shared package.
cd functions
dart test
Running the function tests requires no Firebase emulator, no network access, and no special setup beyond having the Dart SDK installed. The tests complete in milliseconds.
cd packages/shared
dart test
The shared package tests run identically. Both commands use the standard dart test runner, which recursively finds and executes all files ending in _test.dart in the test/ directory.
Function Configuration Options
Both onRequest and onCall accept an options object that controls runtime behavior:
firebase.https.onRequest(
name: 'highTrafficEndpoint',
options: const HttpsOptions(
cors: Cors(['https://yourapp.com']),
minInstances: 1,
maxInstances: 10,
concurrency: 80,
memory: Memory.mb512,
timeoutSeconds: 120,
region: 'europe-west1',
),
(request) async {
return Response.ok('Hello from a configured function!');
},
);
minInstances: 1 keeps one instance of this function warm at all times, which completely eliminates cold starts for this function. The trade-off is that you are billed for one instance running continuously even when no requests are arriving. Use this only for functions where cold start latency is genuinely unacceptable, such as real-time features that users interact with directly. maxInstances: 10 caps the number of concurrent instances at ten. This prevents a sudden traffic spike from scaling the function to hundreds of instances, which protects both your billing and any downstream services (like a database) that could be overwhelmed by sudden high concurrency. concurrency: 80 tells Cloud Run how many simultaneous requests a single instance will handle. Dart's async model handles concurrent I/O-bound requests efficiently without threads, so this can be set higher than for Node.js. memory: Memory.mb512 allocates 512 megabytes of RAM to each function instance. Increase this for memory-intensive operations like image processing or loading large datasets. CPU allocation scales proportionally with memory, so increasing memory also increases processing power. timeoutSeconds: 120 sets the maximum time a request can run before Cloud Run terminates it. Increase this for long-running operations. region: 'europe-west1' deploys this function to a Google data center in Belgium, which reduces latency for users in Europe. By default functions deploy to us-central1.
Best Practices for Production Use
Treat Experimental as Experimental
The most important practice is to calibrate your production use to the feature's actual maturity. Dart Cloud Functions are experimental. This means two specific things for production decisions.
First, the API can change without notice. A future Firebase CLI update may change how fireUp works, how functions are registered, or how the Admin SDK is accessed. Before updating the CLI in a project that uses Dart functions, read the changelog and test in a staging environment. Do not update production tooling blindly.
Second, some things simply do not work yet. Background triggers, name-based httpsCallable invocation, and Firebase Console display are all gaps in the current release. Architect around these limitations from the beginning rather than discovering them during deployment.
Keep Handlers Thin, Keep Logic Shared
The handler registered with firebase.https.onCall or firebase.https.onRequest should do as little as possible: authenticate the request, extract the input, call a pure function that does the actual work, and return the result. The pure function belongs either in the functions library or in the shared package. This structure makes the logic testable without a Firebase environment and makes it easier to move logic to the shared package later if the Flutter app needs it.
Use FieldValue.serverTimestamp() for All Timestamps
Never send a timestamp from the client or generate one in your function code using DateTime.now(). Server timestamps are set by Firestore at the moment of the write and are guaranteed to be accurate regardless of the caller's clock. Client-generated timestamps can be wrong if the user's device clock is incorrect. Function-generated DateTime.now() timestamps are accurate but miss the small window of time between function execution and the Firestore write being committed.
Log Meaningfully but Not Excessively
Cloud Functions logs are visible in the Google Cloud Console and in the Cloud Run logs. print() in Dart functions writes to these logs. Log events that are useful for debugging production issues: function invocations with their input shape (not sensitive data), successful completions with result shape, errors with the full error and stack trace, and performance-relevant events like external API calls. Do not log every line of execution or every data transformation, which floods the logs and makes real errors hard to find.
Rate Limit and Authenticate by Default
Every Cloud Function that is reachable over the internet is potentially callable by anyone who discovers its URL. Callable functions validate Firebase Authentication automatically, but HTTP functions do not. For every onRequest function that should require authentication, verify the ID token explicitly. For every function regardless of type, consider implementing per-user rate limiting before launch to prevent both accidental loops and intentional abuse.
When to Use Dart Cloud Functions and When Not To
Where Dart Cloud Functions Add Real Value
Dart Cloud Functions are most valuable when you are a Flutter-first team that wants to write backend logic without context-switching out of Dart. The shared package pattern is where the architectural value is highest: any time you have validation rules, data models, constants, or utility logic that both the client and server need, having both sides share that code in a single Dart package eliminates an entire category of data contract bugs.
Lightweight, I/O-bound API logic is a strong fit. Dart's async model is efficient for workloads that spend most of their time waiting for Firestore queries, external API calls, or other network operations, rather than doing heavy computation. A function that reads some documents from Firestore, applies business logic, and writes results back is exactly the kind of workload Dart handles well.
Mobile-backend-for-frontend patterns are a natural use case: functions that aggregate data from multiple Firestore collections into a single response shaped for a specific screen, functions that perform write operations that require multiple documents to be updated atomically, and functions that need admin access to create or update records that clients should not be able to modify directly.
Where Dart Cloud Functions Are the Wrong Choice Right Now
Background triggers are currently not deployable. If your architecture depends on functions that run when a Firestore document is created or updated, when a user signs up, on a schedule, or in response to Pub/Sub messages, you cannot use Dart for those functions today. You need to write them in Node.js or Python and wait for background trigger support to land in a future release.
Production-critical infrastructure should be evaluated carefully before committing to experimental tooling. If a function failure would result in data loss, financial errors, or significant user impact, the experimental label on Dart support is a meaningful risk factor. The API may change, behavior may change, and the Firebase team's ability to quickly address critical production bugs in an experimental feature is different from their commitment to stable features.
Highly concurrent workloads that need fine-tuned performance characteristics may benefit from testing with real traffic before committing to Dart. The performance story for Dart functions (excellent cold start, efficient async I/O handling) is theoretically strong, but production traffic can reveal edge cases that local testing does not.
Common Mistakes
Forgetting the Experiment Flag
The most common first-time problem is running firebase init functions and not seeing Dart as a language option. The fix is always the same: run firebase experiments:enable dartfunctions first, then run firebase init functions. The experiment flag must be set in the Firebase CLI before Dart becomes available as an option.
Using Relative Paths Incorrectly in pubspec.yaml
The shared package is referenced using a relative path dependency in both functions/pubspec.yaml and the Flutter app's pubspec.yaml. If the relative path is wrong (because the folder structure differs from what the codebase expected, or because the package was moved), both the function compilation and the Flutter build will fail with package resolution errors. Verify the path by running dart pub get in the functions directory and checking that it resolves without errors before deploying.
Forgetting to Handle the httpsCallable Name Limitation
The most common integration bug in the current release is calling a Dart function with FirebaseFunctions.instance.httpsCallable('functionName') and wondering why it returns a not-found error. The current release does not support name-based resolution for Dart functions. You must use httpsCallableFromURL with the full Cloud Run URL. Save the URL from the deployment output and use it explicitly in your Flutter code.
Looking for Functions in the Firebase Console
After deploying a Dart function, opening the Firebase Console's Functions section and seeing nothing is alarming if you do not know it is expected behavior. Your Dart functions are deployed to Cloud Run and are visible in the Cloud Run functions page of the Google Cloud Console, not in the Firebase Console. This is a known gap in the experimental release and will be addressed when the feature reaches general availability.
Putting Firebase Dependencies in the Shared Package
The shared package must remain dependency-free of Firebase and Flutter packages. Adding firebase_functions or cloud_firestore as a dependency of the shared package breaks the fundamental architecture: the shared package would then pull in server-side Firebase dependencies into the Flutter app or client-side Firebase dependencies into the functions, causing version conflicts and compilation errors. The shared package contains only pure Dart logic and models. Firebase interactions happen in the functions package and the Flutter app separately, both of which import the shared package.
Not Extracting Logic into Pure Functions
Putting all business logic directly inside the onCall or onRequest callback makes it impossible to unit test without a running Firebase emulator. Dart's strength is its testability. Extract validation, transformation, and business logic into pure functions in the functions library or the shared package. Test those pure functions with dart test without any Firebase infrastructure. Reserve the handler callbacks for the thin layer that connects Firebase inputs and outputs to that pure logic.
Mini End-to-End Example
Let's build a complete, working full-stack Dart application: a post creation feature with a shared model, shared validation, a Dart Cloud Function that writes to Firestore, and a Flutter screen that calls the function. This brings together every concept from the handbook in one runnable project.
The Shared Package
// packages/shared/lib/src/models/post.dart
class Post {
final String id;
final String title;
final String content;
final String authorId;
final int likeCount;
const Post({
required this.id,
required this.title,
required this.content,
required this.authorId,
required this.likeCount,
});
factory Post.fromMap(String id, Map<String, dynamic> data) {
return Post(
id: id,
title: data['title'] as String? ?? '',
content: data['content'] as String? ?? '',
authorId: data['authorId'] as String? ?? '',
likeCount: data['likeCount'] as int? ?? 0,
);
}
Map<String, dynamic> toMap() => {
'title': title,
'content': content,
'authorId': authorId,
'likeCount': likeCount,
};
}
Post.fromMap takes both the document ID (which Firestore stores externally to the document data) and the document's field map, combining them into a fully populated Post instance. The as String? ?? '' pattern is a safe cast followed by a null fallback: if the field is absent or null, the empty string is used instead of throwing a null dereference error. toMap() serializes the Post into a Map suitable for writing to Firestore, intentionally excluding id because Firestore generates and stores the document ID outside the document body. The likeCount starts at zero when creating a new post and is updated by the server-side increment operation.
// packages/shared/lib/src/validation/post_validation.dart
class PostValidation {
static const int titleMaxLength = 120;
static const int contentMaxLength = 5000;
static String? validateTitle(String? value) {
if (value == null || value.trim().isEmpty) return 'Title is required.';
if (value.trim().length > titleMaxLength) {
return 'Title cannot exceed $titleMaxLength characters.';
}
return null;
}
static String? validateContent(String? value) {
if (value == null || value.trim().isEmpty) return 'Content is required.';
if (value.trim().length > contentMaxLength) {
return 'Content cannot exceed $contentMaxLength characters.';
}
return null;
}
}
This is the simplified version of PostValidation used in the end-to-end example. Both methods follow the validator contract: null means valid, a String means invalid with the given reason. The checks are ordered from most common failure (empty input) to more specific failures (too long), which is both logical and efficient since the empty check short-circuits before the length check runs.
// packages/shared/lib/src/constants/api_constants.dart
class ApiConstants {
static const String createPost = 'createPost';
static const String postsCollection = 'posts';
}
In the end-to-end example, ApiConstants is trimmed to just the two constants this feature needs: the function name and the collection name. This keeps the example focused. In a real application, this class would grow to include every function and collection name used across the entire app.
// packages/shared/lib/shared.dart
export 'src/models/post.dart';
export 'src/validation/post_validation.dart';
export 'src/constants/api_constants.dart';
The barrel file exports all three modules. Any file on either side of the stack that imports package:shared/shared.dart immediately has access to Post, PostValidation, and ApiConstants without needing to know which subdirectory any of them lives in.
The Cloud Function
// functions/bin/server.dart
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) {
firebase.https.onCall(
name: ApiConstants.createPost,
options: const CallableOptions(cors: Cors(['*'])),
(request, response) async {
if (request.auth == null) {
throw FirebaseFunctionsException(
code: 'unauthenticated',
message: 'You must be signed in to create a post.',
);
}
final uid = request.auth!.uid;
final data = request.data as Map<String, dynamic>? ?? {};
final title = data['title'] as String?;
final content = data['content'] as String?;
final titleError = PostValidation.validateTitle(title);
if (titleError != null) {
throw FirebaseFunctionsException(
code: 'invalid-argument',
message: titleError,
);
}
final contentError = PostValidation.validateContent(content);
if (contentError != null) {
throw FirebaseFunctionsException(
code: 'invalid-argument',
message: contentError,
);
}
try {
final ref = await firebase.adminApp
.firestore()
.collection(ApiConstants.postsCollection)
.add({
'title': title!.trim(),
'content': content!.trim(),
'authorId': uid,
'likeCount': 0,
'createdAt': FieldValue.serverTimestamp(),
});
return CallableResult({
'postId': ref.id,
'success': true,
});
} catch (e) {
print('Error writing post to Firestore: $e');
throw FirebaseFunctionsException(
code: 'internal',
message: 'Failed to create post. Please try again.',
);
}
},
);
});
}
final data = request.data as Map<String, dynamic>? ?? {} safely handles the case where the client sends a null body by falling back to an empty map, preventing a null dereference before the individual field extractions. The ! on title!.trim() and content!.trim() is safe at this point in the code because the validation checks above have already confirmed that both values are non-null and non-empty. The try/catch around the Firestore write is the final safety net: if the Admin SDK write fails for any reason (network issue, Firestore quota, unexpected error), the function catches it, logs the full internal error with print (which writes to Cloud Run logs), and throws a sanitized 'internal' error to the client that says nothing about the cause of the failure.
The Flutter App
// lib/services/functions_service.dart
import 'package:cloud_functions/cloud_functions.dart';
class FunctionsService {
static const String _createPostUrl =
'https://createpost-REPLACE-WITH-YOUR-HASH.a.run.app';
Future<String> createPost({
required String title,
required String content,
}) async {
try {
final callable = FirebaseFunctions.instance
.httpsCallableFromURL(_createPostUrl);
final result = await callable.call({'title': title, 'content': content});
return result.data['postId'] as String;
} on FirebaseFunctionsException catch (e) {
throw _mapError(e);
}
}
Exception _mapError(FirebaseFunctionsException e) {
switch (e.code) {
case 'unauthenticated':
return Exception('Please sign in to continue.');
case 'invalid-argument':
return Exception(e.message ?? 'Invalid input.');
default:
return Exception('Something went wrong. Please try again.');
}
}
}
FunctionsService is a thin wrapper around the callable function invocation. Its only responsibilities are constructing the callable with the correct URL, passing the data, extracting the result, and mapping structured server errors into domain exceptions. _mapError translates FirebaseFunctionsException objects, which carry Firebase-specific codes, into plain Exception objects with user-friendly messages. This keeps Firebase types out of the Bloc or widget layer, where they would create a coupling to the Firebase SDK that is difficult to test or replace.
// lib/features/create_post/create_post_screen.dart
import 'package:flutter/material.dart';
import 'package:shared/shared.dart';
import '../../services/functions_service.dart';
class CreatePostScreen extends StatefulWidget {
const CreatePostScreen({super.key});
@override
State<CreatePostScreen> createState() => _CreatePostScreenState();
}
class _CreatePostScreenState extends State<CreatePostScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _contentController = TextEditingController();
final _service = FunctionsService();
bool _isSubmitting = false;
String? _errorMessage;
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() {
_isSubmitting = true;
_errorMessage = null;
});
try {
final postId = await _service.createPost(
title: _titleController.text,
content: _contentController.text,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Post created successfully! ID: $postId')),
);
Navigator.of(context).pop();
} catch (e) {
setState(() => _errorMessage = e.toString());
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('New Post')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade800),
),
),
TextFormField(
controller: _titleController,
decoration: InputDecoration(
labelText: 'Title',
hintText: 'What is your post about?',
counterText:
'\({_titleController.text.length}/\){PostValidation.titleMaxLength}',
),
maxLength: PostValidation.titleMaxLength,
validator: (value) => PostValidation.validateTitle(value),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
TextFormField(
controller: _contentController,
decoration: InputDecoration(
labelText: 'Content',
hintText: 'Write your post here...',
counterText:
'\({_contentController.text.length}/\){PostValidation.contentMaxLength}',
alignLabelWithHint: true,
),
maxLength: PostValidation.contentMaxLength,
maxLines: 10,
validator: (value) => PostValidation.validateContent(value),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isSubmitting ? null : _submit,
child: _isSubmitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Publish Post'),
),
],
),
),
);
}
}
GlobalKey<FormState> gives _submit() access to the form's state so it can trigger validation across all fields simultaneously. _formKey.currentState?.validate() calls the validator function on every TextFormField in the form and returns true only if all validators return null. The early return on validation failure prevents the network call from being made when the form is invalid. _isSubmitting drives the UI state: the button is disabled (onPressed: null) while the call is in progress, and a CircularProgressIndicator replaces the button label, giving the user clear feedback that something is happening. if (!mounted) return inside the async _submit() method prevents calling setState or Navigator on a widget that has already been removed from the tree, which would throw a "setState called after dispose" error. The finally block ensures _isSubmitting is always reset to false, even if an exception was thrown, preventing the button from being permanently stuck in the loading state.
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_functions/cloud_functions.dart';
import 'dart:io' show Platform;
import 'firebase_options.dart';
import 'features/create_post/create_post_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
if (const bool.fromEnvironment('USE_EMULATOR', defaultValue: false)) {
final host = Platform.isAndroid ? '10.0.2.2' : 'localhost';
FirebaseFunctions.instance.useFunctionsEmulator(host, 5001);
}
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Full-Stack Dart Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const CreatePostScreen(),
);
}
}
WidgetsFlutterBinding.ensureInitialized() must be called before any Flutter plugin code runs, which includes Firebase initialization. Without it, calling Firebase.initializeApp() before runApp() would throw an error. DefaultFirebaseOptions.currentPlatform reads from the generated firebase_options.dart file to get the correct Firebase project configuration for the current platform. const bool.fromEnvironment('USE_EMULATOR', defaultValue: false) reads a compile-time constant that you can set by passing --dart-define=USE_EMULATOR=true to your flutter run command. This approach to emulator switching is safer than using kDebugMode, because a release build with kDebugMode set to false would stop using the emulator, whereas a release build compiled without --dart-define=USE_EMULATOR=true achieves the same result explicitly. Platform.isAndroid selects the correct emulator host address for the current platform, as discussed in the setup section.
Conclusion
Dart on Cloud Functions is the feature the Flutter community has wanted for years, and the announcement at Google Cloud Next 2026 was met with the kind of enthusiasm that only comes when a long-standing pain point is finally addressed. The user voice thread that had been accumulating requests since 2023 filled with celebration. Developers who had learned just enough TypeScript to write backend functions and had never been comfortable with it suddenly had a path back to the language they know.
The technical foundations are genuinely strong. Dart's AOT compilation produces lower cold start times than interpreted runtimes. Its null-safe, strongly typed system makes the shared package pattern reliable rather than aspirational. Its async model handles I/O-bound serverless workloads efficiently. The firebase_functions package mirrors the ergonomics of the FlutterFire packages Flutter developers already use, so the learning curve is shallow for anyone who has already integrated Firebase on the client.
The experimental status is real and must be respected. Background triggers are not yet deployable. The Firebase Console does not display Dart functions. Name-based callable invocation does not work. These are not paper-thin limitations: they affect real architecture decisions, and teams should design around them explicitly rather than assuming they will be resolved before their launch date. The Firebase team is actively developing the feature, and the pace of progress since the announcement has been encouraging, but production systems deserve conservative planning.
The shared package is the idea worth centering your architecture around, regardless of how mature the Dart functions feature becomes. Even if you keep some backend logic in Node.js for now because of the trigger limitations, building your shared data models and validation logic in a common Dart package that both sides import is an immediate improvement to your codebase. Every time you eliminate a duplicated type definition or a manually maintained API contract, you remove a category of bugs that no amount of testing fully eliminates. The package is the payoff that is available today, and the Dart functions feature is the amplifier that makes the whole unified stack possible.
The Flutter community is just beginning to explore what full-stack Dart looks like at scale. The patterns for organizing shared packages, structuring functions for testability, managing the tradeoffs between callable and HTTP functions, and handling the current limitations gracefully are still being established in real projects. This handbook gives you the foundations. The community will fill in the rest as more teams ship production workloads and share what they learn.
References
Official Firebase Documentation
Get Started with the Experimental Dart SDK
The official Firebase documentation for setting up Dart Cloud Functions, covering CLI setup, the experiment flag, local emulation, and deployment. This is the canonical getting-started reference. https://firebase.google.com/docs/functions/start-dartCloud Functions for Firebase Overview
The main Cloud Functions documentation page, which now includes a banner announcing experimental Dart support and links to the Dart-specific guides. https://firebase.google.com/docs/functionsCall Functions from Your App (Dart)
Firebase documentation covering how to call callable functions from Flutter, including the current limitation aroundhttpsCallablename resolution and thehttpsCallableFromURLworkaround. https://firebase.google.com/docs/functions/callableFirebase AI Logic Documentation
For teams combining Dart Cloud Functions with Gemini AI features through [Firebase. https://firebase.google.com/docs/ai-logic\](http://Firebase. https://firebase.google.com/docs/ai-logic)
Announcement and Blog Posts
Announcing Dart Support in Cloud Functions for Firebase
The official Firebase blog post from Google Cloud Next 2026, covering the motivation for Dart support, the Admin SDK, the shared code architecture, and the AOT compilation performance story. https://firebase.blog/posts/2026/05/dart-functions-expDart Language on X: Dart Everywhere
The Dart team's announcement post summarizing the full-stack Dart story in a single sentence.
https://x.com/dart_lang/status/2047418350268273060
Packages
firebase_functions on pub.dev
The official Dart package for Cloud Functions, providingfireUp,onRequest,onCall,HttpsOptions,CallableOptions, andFirebaseFunctionsException. https://pub.dev/packages/firebase_functionsfirebase_functions on GitHub
Source code, issues, and examples for thefirebase_functionsDart package. The README includes additional examples and the latest limitations list.
https://github.com/firebase/firebase-functions-dartdart_firebase_admin on pub.dev
The Dart Admin SDK for use outside of Cloud Functions (Cloud Run, standalone servers, command-line scripts). Maintained by Invertase. https://pub.dev/packages/dart_firebase_admindart_firebase_admin on GitHub
Source code and documentation for the Dart Admin SDK, including examples for Firestore, Authentication, Cloud Storage, and FCM. https://github.com/invertase/dart_firebase_admingoogle_cloud_firestore on pub.dev
The standalone Dart Firestore SDK used inside Dart Cloud Functions for Firestore operations.
https://pub.dev/packages/google_cloud_firestore
Codelabs and Tutorials
- Build a Full-Stack Dart App with Cloud Functions for Firebase
The official Google Codelab walking through a multiplayer counter app using shared Dart packages, Dart Cloud Functions, and a Flutter frontend. The most comprehensive hands-on introduction available. https://codelabs.developers.google.com/deploy-dart-on-firebase-functions
Related Flutter and Dart Packages
cloud_functions (FlutterFire)
The Flutter client package for calling Cloud Functions, used in this guide forhttpsCallableFromURL.
https://pub.dev/packages/cloud_functionsfirebase_core
Required base package for all FlutterFire packages. https://pub.dev/packages/firebase_corejson_annotation and json_serializable
Used in the shared package to generatefromJsonandtoJsonmethods for shared models, eliminating hand-written serialization. https://pub.dev/packages/json_annotation
This handbook was written in May 2026, reflecting the experimental Dart Cloud Functions support announced at Google Cloud Next 2026, the firebase_functions package at version 0.1.x, and the dart_firebase_admin package maintained by Invertase. Because this feature is experimental, the API and supported trigger types may change in future releases. Always consult the official Firebase documentation and the package changelogs before upgrading.