Full-stack Dart with Flutter, Supabase and Dart Edge

yallurium

Christos

Posted on May 13, 2023

Full-stack Dart with Flutter, Supabase and Dart Edge

Introduction

Supabase has recently had its 7th Launch Week, with lots of new features and functionality shipped by the Supabase team as well as the community.

One of the community highlights was Dart Edge for Supabase Edge Functions. Built by Invertase, Dart Edge allows you to write your edge functions in Dart. It supports Vercel, Cloudflare Workers, and now Supabase Edge Functions, which until recently only supported writing functions with TypeScript. However, it is still experimental!

Being able to write our edge functions with Dart is amazing news for Flutter developers. It allows us to write Dart for both the front-end and any back-end code we need, which also means we can share Dart code, such as the request/response models. And while Supabase's database functions can be quite powerful, an edge function can do additional things such as call third-party APIs and services, and even bypass RLS policies if necessary.

In this post, we will explore Dart Edge with Supabase Edge Functions. We will build a simple app with Dart on the full stack, using a Flutter app front-end and a Dart Edge function as our back-end. We'll keep things simple by starting with the classic Flutter counter app but using an edge function to get the current count and increment it.

Set up an edge function

First, we need both the Supabase CLI and edge CLI. For the edge CLI, simply run:

dart pub global activate edge
Enter fullscreen mode Exit fullscreen mode

The Supabase CLI can be installed through NPM or either Homebrew or Scoop depending on your operating system, so check out the documentation here.

Now, let's set up a Dart Edge project:

edge new supabase_functions edge_functions
cd edge_functions
supabase init
Enter fullscreen mode Exit fullscreen mode

edge_functions is the name of our project and the name of the newly created directory. We run supabase init to generate the Supabase config.

The newly generated edge_functions directory is a typical Dart project with a pubspec.yaml file with the required dependencies. It has a main.dart file with a simple response to get us started:

// edge_functions/lib/main.dart
void main() {
  SupabaseFunctions(fetch: (request) {
    return Response("Hello from Supabase Edge Functions!");
  });
}
Enter fullscreen mode Exit fullscreen mode

To run the edge function locally, we need to build the function and run Supabase:

edge build supabase_functions
supabase start
supabase functions serve dart_edge --no-verify-jwt
Enter fullscreen mode Exit fullscreen mode

We can also use the --dev flag when running edge build to recompile when we make any changes to the code.

By default, the edge function is expected to have a valid authorization header. We pass the --no-verify-jwt flag so we can run the function without one.

We can now access the local edge function at http://localhost:54321/functions/v1/dart_edge

To deploy the function, we can build it and then run the following command:

supabase functions deploy dart_edge
Enter fullscreen mode Exit fullscreen mode

Connect to Supabase from the edge

The current edge function only returns some text in the response. In a real app, we would likely want to interact with our database, make some third-party service calls, or both.

dart pub add supabase
dart pub add edge_http_client
Enter fullscreen mode Exit fullscreen mode

The supabase package has all we need to interact with the Supabase database, but we also need to use a special HTTP client.

We can now instantiate a Supabase client:

final supabase = SupabaseClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
  httpClient: EdgeHttpClient(),
);
Enter fullscreen mode Exit fullscreen mode

A couple of notes on the above snippet:

  • The Supabase credentials are already available by default in the environment variables.

  • The service role key bypasses Row Level Security, meaning we can access and edit all data of all users. Since this is for an edge function, it's fine, but we could also use the anon key (SUPABASE_ANON_KEY environment variable) if that's a concern.

We now have access to the Supabase database from our edge function. Let's modify the function's logic to do two things:

  1. Return the counter's current value if we make a GET request.

  2. Increment the counter by 1 if we make a POST request.

We'll first need to create the database table that will hold our increments. These SQL statements are added to the seed.sql file.

-- edge_functions/supabase/seed.sql
create table increments (
  id bigint generated by default as identity primary key,
  amount int default 0 not null
);

create function get_count()
returns numeric as $$
  select sum(amount) from increments;
$$ language sql;
Enter fullscreen mode Exit fullscreen mode

We also create a SQL function that returns the current count by summing up all the increments.

Now let's update our edge function's main method:

// edge_functions/lib/model/counter_response.dart
class CounterResponse {
  final int count;

  CounterResponse(this.count);

  factory CounterResponse.fromJson(Map<String, dynamic> json) =>
      CounterResponse(json['count'] as int);

  Map<String, dynamic> get toJson => {'count': count};
}

// edge_functions/lib/main.dart
void main() {
  SupabaseFunctions(fetch: (request) async {
    final client = SupabaseClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
      httpClient: EdgeHttpClient(),
    );

    if (request.method == 'POST') {
      await client.from('increments').insert({'amount': 1});
    }
    final count = await client.rpc('count').single() as int;
    final response = CounterResponse(count);
    return Response.json(response.toJson);
  });
}
Enter fullscreen mode Exit fullscreen mode

If the request is a POST, we increment the count by 1. For all requests, we fetch the current count and return it in the response. To fetch the count, we're calling a SQL function within an edge function!

Call the function from Flutter

With the edge function ready to go, we will now create the Flutter counter app, and update it so that the count display and increment button work by calling the edge function.

flutter create app
Enter fullscreen mode Exit fullscreen mode

We'll be calling the function with the http package, but we're also going to need to add the edge_functions package as a local dependency. This is so we can reuse the CounterResponse model.

Here are the current dependencies (pubspec.yaml) for the Flutter app:

dependencies:
  flutter:
    sdk: flutter
  counter_edge_functions:
    path:  '../edge_functions'
  http: ^0.13.5
Enter fullscreen mode Exit fullscreen mode

At the moment of writing, there seems to be a dependency issue due to the path package, so I had to remove flutter_test as a dev dependency to get this to work. A nicer fix for this would be to move the model to a separate local package instead and have it as a dependency for both the edge function package and the Flutter app.

Let's also encapsulate the interactions with the edge function to a repository:

import 'dart:convert';

import 'package:counter_edge_functions/model/counter_response.dart';
import 'package:http/http.dart' as http;

class CounterRepository {
  static final _functionUrl =
      Uri.parse('http://localhost:54321/functions/v1/dart_edge');

  Future<CounterResponse> get count async {
    final response = await http.get(_functionUrl);
    final body =
        jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
    return CounterResponse.fromJson(body);
  }

  Future<CounterResponse> increment() async {
    final response = await http.post(_functionUrl);
    final body =
        jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
    return CounterResponse.fromJson(body);
  }
}
Enter fullscreen mode Exit fullscreen mode

The final piece is to call the repository methods from our widgets. There are not that many changes compared to the generated counter app code from the template, but here is the final result:

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    final repository = CounterRepository();
    return MaterialApp(
      title: 'Edge Counter',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      // Fetch the initial count from the Edge Function.
      home: FutureBuilder<CounterResponse>(
        future: repository.count,
        builder: (context, snapshot) {
          if (snapshot.connectionState != ConnectionState.done) {
            return const SizedBox();
          }
          return MyHomePage(
            title: 'Edge Counter',
            initialCount: snapshot.data?.count ?? 0,
            onIncrement: repository.increment,
          );
        },
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({
    super.key,
    required this.title,
    required this.initialCount,
    required this.onIncrement,
  });

  final String title;
  final int initialCount;
  final Future<CounterResponse> Function() onIncrement;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late int _counter;

  // Increment through the Edge Function, and update with the latest count.
  Future<void> _incrementCounter() async {
    final response = await widget.onIncrement();
    setState(() {
      _counter = response.count;
    });
  }

  @override
  void initState() {
    super.initState();
    _counter = widget.initialCount;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'Users have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

We wrap the home page in a FutureBuilder to start with the current counter value, and call the increment method on button click. The count persists across restarts, and if we were using a deployed function rather than a local one, we could even share the app with friends and have everyone increment it at the same time!

Wrapping up

In this post, we talked about the experimental Dart Edge, allowing us to write Dart on the full stack - by using Flutter and Supabase. We created a very simple edge function that interacts with our database, and an even simpler Flutter counter app that calls the edge function to increment the counter.

You can find the full source code here.

If you found this helpful and would like to be notified of future tutorials, please follow the blog or subscribe to the newsletter with your email at dartling.dev!

💖 💪 🙅 🚩
yallurium
Christos

Posted on May 13, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related