# Flutter Tutorial: Building An Offline-First Chat App With Supabase And PowerSync

> This tutorial walks through building an offline-first chat app, using the Supabase Flutter chat app tutorial as a starting point and then integrating PowerSync. It should take ~30-45min to complete.

- Published: 2023-12-14
- Updated: 2025-09-30
- Author: Kobie Botha
- Category: Tutorial
- Source: https://powersync.com/blog/flutter-tutorial-building-an-offline-first-chat-app-with-supabase-and-powersync

---

Offline-first apps feel instant to use because they work with a local in-app database, are functional even if the user’s network connection is interrupted, and provide multi-user real-time collaboration. If you're new to PowerSync, [start at this post](https://powersync.com/blog/introducing-powersync-v1-0-postgres-sqlite-sync-layer) for an introduction to the technology.

This tutorial walks through building an offline-first chat app, using the [Supabase Flutter chat app tutorial](https://supabase.com/blog/flutter-tutorial-building-a-chat-app) as a starting point and then integrating PowerSync. It should take ~30-45min to complete.

Before proceeding, you should have accounts with both Supabase and PowerSync. If you don't have accounts yet, don't worry — you can get started for free with PowerSync [here](https://accounts.journeyapps.com/portal/powersync-signup), and with Supabase [here](https://supabase.com/dashboard/sign-in?). You should also have Flutter set up.

For a video version of this tutorial, watch the full walkthrough below:

[Video: Build an offline-first chat app with Flutter, Supabase and PowerSync](https://www.youtube.com/watch?v=EmtOfiELasY)

## Tutorial Overview

  1. Set up Supabase project and publication to PowerSync
  2. Clone Supabase Flutter chat app
  3. Run the app in its online-only form
  4. Set up PowerSync service
  5. Integrate PowerSync's Flutter SDK to make the app offline-first

## 1\. Set up Supabase project and publication to PowerSync

Complete only the following two steps in the [Supabase Flutter chat app tutorial](https://supabase.com/blog/flutter-tutorial-building-a-chat-app). This should take less than 2 minutes.

  1. [Creating a new Supbase project](https://supabase.com/blog/flutter-tutorial-building-a-chat-app#creating-a-new-supabase-project)
  2. [Setting up tables in Supabase](https://supabase.com/blog/flutter-tutorial-building-a-chat-app#setting-up-tables-in-supabase)

You should now have two tables in your Supabase project: `messages` and `profiles` You can now exit the Supabase Flutter chat app tutorial.

Allow Supabase to publish data changes to PowerSync by running the below SQL statement in your **Supabase SQL Editor** :

```sql
CREATE PUBLICATION powersync FOR TABLE public.messages, public.profiles;
```

## 2\. Clone Supabase Flutter chat app

Clone the Supabase Flutter chat app code from [this template](https://github.com/powersync-ja/powersync-supabase-flutter-simple-chat-demo-template) with this command:

```shell
git clone https://github.com/powersync-ja/powersync-supabase-flutter-simple-chat-demo-template
```

Make sure you're in the newly cloned directory:

```shell
cd powersync-supabase-flutter-simple-chat-demo-template
```

Install dependencies by running this command:

```shell
flutter pub get
```

Open the project in VSCode — a quick way to do this is with the `code .` command.

You don't want to commit your secrets into GitHub so copy and rename the `.env.template` file to `.env`.

Replace the placeholder values in that file:

  1. Find your **project URL** in Supabase, go to **Settings > API > Copy Project URL** and replace the placeholder URL value at `SUPABASE_URL=`.
  2. Find your **anon key** just below that at `anon` `public` and replace the placeholder value at `SUPABASE_ANON_KEY=`.

Save that file. 

Because of the way signups are handled in the Supabase Flutter chat app, we need to set up a Postgres function and trigger to copy usernames into the `profiles` table (see [this section](https://supabase.com/blog/flutter-tutorial-building-a-chat-app#step-5-create-register-page-with-email-password-and-username) in their tutorial if you want to learn more). To do this, run the following SQL in the Supabase **SQL Editor**.

```sql
-- Function to create a new row in profiles table upon signup
-- Also copies the username value from metadata
create or replace function handle_new_user() returns trigger as $$
    begin
        insert into public.profiles(id, username)
        values(new.id, new.raw_user_meta_data->>'username');

        return new;
    end;
$$ language plpgsql security definer;

-- Trigger to call `handle_new_user` when new user signs up
create trigger on_auth_user_created
    after insert on auth.users
    for each row
    execute function handle_new_user();
```

Back in VSCode, go to the **lib** folder and take a look at the code in the `main.dart` file:

```dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await dotenv.load(fileName: '.env');

  await Supabase.initialize(
    url: dotenv.env['SUPABASE_URL']!,
    anonKey: dotenv.env['SUPABASE_ANON_KEY']!,
  );
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'My Chat App',
      theme: appTheme,
      home: const SplashPage(),
    );
  }
}
```

This code first loads that `.env` file that we just created and then initializes Supabase using the **project URL** and the **anon key** in that file. It then runs the app.

The app itself has four pages:

**1\. Splash page** `splash_page.dart`

The splash page checks if the user is currently logged in or not. If not, it takes the user to the register page, otherwise it takes the users straight to the chat page.

**2\. Register page** `register_page.dart`

The register page lets users sign up with email and password. It also allows users to skip this step if they already have an account, with a button which takes them to the login page.

**3\. Login page** `login_page.dart`

The login page lets users log in with their email and password.

**4\. Chat page** `chat_page.dart`

The chat page opens up a message stream from Supabase which binds to a `List` widget that streams messages. There's also a send button which calls a `_submitMessage()` function that inserts a row into the `messages` table with `profile_Id` and `content`.

## 3\. Run the app in its online-only form

In VSCode, click on `main.dart` and then select a target device (e.g. an Android emulator) in the bottom right to start debugging by clicking **Run > Start Debugging** from the main IDE window, or by pressing **F5**. See [Flutter docs](https://docs.flutter.dev/tools/vs-code#running-and-debugging) for more detailed instructions.

Run the app on a second device through the same process or from the command line:

```shell
//To run the app on a specific simulator device
flutter run -d "device name or id"
```

For easier testing, in your Supabase dashboard, go to **Authentication > Providers > Email > Toggle Confirm Email**. This will remove the need to verify test user email addresses.

![](https://powersync.com/images/blog/vue-tutorial-offline-first-todo-app-with-supabase-and-powersync-inline-3610c2e1a5.png)

Remember to hit save in the bottom right:

![](https://powersync.com/images/blog/flutter-tutorial-building-an-offline-first-chat-app-with-supabase-and-powersync-inline-30d3be7bc5.png)

On each different device, create a unique user through the register page and log in with that user. Once logged in, each user should be at the chat page in a live chat with the other user.

At this point you'll be able to see certain limitations in the online-only app. For example, users cannot load the app or use it while offline. 

Let's change that by making the app offline-first.

## 4\. Set up PowerSync service

If you don't have a PowerSync account yet, don't worry, you can [get started for free](https://accounts.journeyapps.com/portal/free-trial?powersync=true).

Create a new instance of the PowerSync service and connect it to your Supabase project by following these steps:

  1. Log in to your [PowerSync dashboard](https://powersync.journeyapps.com/).
  2. Create a new instance of the PowerSync service and give it a name. The **Edit Instance** modal will then open (to open it any other time, right-click on the instance in the left sidebar).
  3. Go to the **DB Connections** tab. Now navigate to your [Supabase dashboard](https://supabase.com/dashboard/projects) to find your database URI. In the relevant Supabase project go to**Settings > Database  **and uncheck **"Use connection pooling"** both under the **Connection string** and**Connection parameters   **headings. Now copy the **URI** under the **Connection string** heading. Back in your PowerSync dashboard, paste that Supabase URI value in the **URI** field on the **Edit Instance** modal. This should autofill most fields in this tab. 
  4. Next paste your Supabase database password in the **Password** field and toggle **Allow querying data**. Click **Test connection** to validate everything works. 
  5. Now go to the **Client Auth** tab and and check the **Use Supabase Auth** checkbox. Click **Save**.

See [this chapter in the Supabase integration video](https://www.youtube.com/watch?v=80mnzGkeNgw&t=66s) for a visual walkthrough of the steps above.

You should now see the `public.messages` and `public.profiles` tables from Supabase listed under the PowerSync instance you created (other schemas and tables that PowerSync detects in your database will also be listed).

### Configure Sync Rules

> **Note**
>
> Sync Rules control which data gets synced to which users. They allow you to define how data should be partitioned into data buckets that get downloaded to user devices. If you want to learn more about sync rules, see [the documentation](https://docs.powersync.com/usage/sync-rules).

On the [PowerSync dashboard](https://powersync.journeyapps.com/?__hstc=156206116.e69c38546d40c0f365508d4f2a05064f.1701701286478.1701958907610.1701977967871.13&__hssc=156206116.1.1701977967871&__hsfp=2479405419), open the `sync-rules.yaml` file in the left sidebar and delete all existing code.

For this tutorial, we'll simply sync all profiles and all messages to all users. To do that, paste the following into that file:

```yaml
# Sync rule definitions
bucket_definitions:
    global:
        data:
            - SELECT * FROM profiles
            - SELECT * FROM messages
```

Click **Validate** in the top right of that panel and select your instance as the source to validate against. You should see a response of _No issues detected,_ like this:

![](https://powersync.com/images/blog/flutter-tutorial-building-an-offline-first-chat-app-with-supabase-and-powersync-inline-01537bfcd2.png)

Click **Deploy sync rules** and select the instance you created to deploy against, e.g.:

![](https://powersync.com/images/blog/flutter-tutorial-building-an-offline-first-chat-app-with-supabase-and-powersync-inline-a20db82856.png)

Wait a few moments for the deployment to complete. Now that we have an instance of the PowerSync service set up, we need to wire it up to the app through the PowerSync SDK to get an offline-first architecture.

## 5\. Integrate PowerSync's Flutter SDK to make the app offline-first

### Install the SDK

Ensure you’re in the correct directory, then add the PowerSync SDK:

```javascript
flutter pub add powersync
```

Navigate to your project in VSCode. In the **lib** folder, create a new file called `powersync.dart` where we'll add some PowerSync-specific code.

Import the **PowerSync SDK** and **Supabase SDK** : 

You'll also want to import some packages to make managing paths easier, as well as the **dotenv** package:

```dart
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
```

Under that, add a global handle to the PowerSync database, like this:

```dart
late final PowerSyncDatabase db;
```

### Instantiate the PowerSync in-app database

You need to instantiate the PowerSync database — this is the core managed database. Its primary functions are to record all changes in the local database, whether online or offline. In addition, it automatically uploads changes to your app backend when connected.

In the same `powersync.dart` file, we'll use the `openDatabase()` method ([docs](https://docs.powersync.com/usage/installation/client-side-setup/instantiate-powersync-database)) to instantiate the local SQLite database.

```dart
Future<void> openDatabase() async {
  db = PowerSyncDatabase(schema: schema, path: path);
  }
```

The constructor above requires a client-side **schema** and a **path** to the actual database file in the app. We'll create the client-side schema in the next section below. 

#### Configure the path value

For the **path** , it works well to abstract path logic. Do this with a `getDatabasePath()` method, which you can paste above the `openDatabase()` code:

```dart
Future<String> getDatabasePath() async {
  final dir = await getApplicationSupportDirectory();
  return join(dir.path, 'powersync-demo.db');
}
```

`getDatabasePath()` returns a string which appends the current application directory with the filename of the database, in the case of this example, that is "powersync-demo.db". It uses the uses the **path_provider** package.

```dart
Future<void> openDatabase() async {
  db = PowerSyncDatabase(schema: schema, path: await getDatabasePath());
  }
```

Now set the value of the **path** in the `openDatabase()` constructor to call the `getDatabasePath()` method like so:

#### Configure the schema

Now for the **schema**. Complete the steps in the [Implement client-side schema](https://powersync.com/blog/flutter-tutorial-building-an-offline-first-chat-app-with-supabase-and-powersync#implement-client-side-schema) section below. Once you've done that, continue by importing the `schema.dart` file into the `powersync.dart` file like so:

```dart
import './models/schema.dart';
```

Your `openDatabase()` constructor is now ready to initialize the database, call `db.initialize()` to do that:

```dart
Future<void> openDatabase() async {
  db = PowerSyncDatabase(schema: schema, path: await getDatabasePath());
  await db.initialize();
  }
```

#### Integrate with Supabase client SDK

A few steps are required to make sure the PowerSync and Supabase SDKs work well together. The first thing to do is move the Supabase initialization code into the `openDatabase()` constructor. Navigate to the `main.dart` file, and find this:

```dart
  await dotenv.load(fileName: '.env');

  await Supabase.initialiize(
    url: dotenv.env['SUPABASE_URL']!,
    anonKey: dotenv.env['SUPABASE_ANON_KEY']!,
  );
```

Cut that code, go back to the `openDatabase()` constructor in the `powersync.dart` file, and paste it there so that it looks like this:

```dart
Future<void> openDatabase() async {
  db = PowerSyncDatabase(schema: schema, path: await getDatabasePath());
  await db.initialize();

  await dotenv.load(fileName: '.env');

  await Supabase.initialize(
    url: dotenv.env['SUPABASE_URL']!,
    anonKey: dotenv.env['SUPABASE_ANON_KEY']!,
  );
}
```

Now go back to the `main.dart` file and remove the **Supabase** and **dotenv** packages by deleting these two lines in the import section:

```dart
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:supabase_flutter/supabase_flutter.dart'
```

Import the `powersync.dart` file and add a call to `openDatabase()` in the `main()` function like so:

```dart
import 'package:powersync/powersync.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
```

We're not quite done here, but the last few steps require some helper auth methods that we'll get to later in this tutorial.

### Connect the in-app database to Supabase and the PowerSync service

Back in the `powersync.dart` file, below the `openDatabase()` constructor, we'll create an instance of the `PowerSyncBackendConnector` ([docs](https://pub.dev/documentation/powersync/latest/powersync/PowerSyncBackendConnector-class.html)) which does two things:

```dart
import './powersync.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
 
  openDatabase();
  
  runApp(const MyApp());
}
```

  1. Uploads changes made on the local database to Supabase with the `uploadData()` method.
  2. Gets credentials to connect to the PowerSync service with the `fetchCredentials()` method.

After implementing these two methods, the SDK will take care of uploading local data changes to Supabase, and then connecting to the PowerSync service to download any data updates from Supabase as specified in your sync rules. It will automatically handle retrying if the network fails. 

Before getting to the methods, we need to specify that this connector takes `db` as a parameter, like so:

```dart
Future<void> class SupabaseConnector extends PowerSyncBackendConnector {
  PowerSyncDatabase db;
  SupabaseConnector(this.db);
}
```

#### The uploadData() method

> **Note**
>
> To better understand this section, complete the [Wire up in-app database calls to UI](https://powersync.com/blog/flutter-tutorial-building-an-offline-first-chat-app-with-supabase-and-powersync#wire-up-in-app-database-calls-to-ui) section before continuing.

The `uploadData()` method gets called by the SDK whenever there's data to upload, whether the device is online or offline. If the call throws an error, it will be retried periodically until it succeeds.

In the `uploadData()` method we'll use the `getNextCrudTransaction()` method ([docs](https://pub.dev/documentation/powersync/latest/powersync/PowerSyncDatabase/getNextCrudTransaction.html)) to check whether there are any transactions to process. If there are, we'll upload them to the Supabase RESTful API, PostgREST.

We'll iterate through every operation in the `transaction.crud` object, which is a list of all client-side changes ([docs](https://pub.dev/documentation/powersync/latest/powersync/CrudTransaction/crud.html)). We'll have to map the operations to what Supabase will accept like so:

  * put –> upsert
  * patch –> update
  * delete –> delete

For every operation, we'll evaluate what type it is and then perform the relevant update. Once we've iterated through all operations, we can complete the transaction which means that all operations were successfully applied against Supabase.

We also need to monitor whether there are any errors returned from Postgrest. We're specifically interested in whether the response codes correspond to codes we know are fatal (i.e. we cannot recover from them by retrying). Instead of blocking the rest of the upload queue, we'll simply mark a specific transaction as completed (note that errors are typically the result of bugs in your code and in a production environment you'll want to save failing records somewhere else in order to prevent data loss and allow debugging).

Here is the code that will do all that:

```dart
Future<void>  @override
  Future<void> uploadData(PowerSyncDatabase database) async {
    final transaction = await database.getNextCrudTransaction();
    if (transaction == null) {
      return;
    }

    final rest = Supabase.instance.client.rest;

    try {
      for (var op in transaction.crud) {
        final table = rest.from(op.table);
        if (op.op == UpdateType.put) {
          var data = Map <String, dynamic>.of(op.opData!);
          data['id'] = op.id;
          await table.upsert(data);
        } else if (op.op == UpdateType.patch) {
          await table.update(op.opData!).eq('id', op.id);
        } else if (op.op == UpdateType.delete) {
          await table.delete().eq('id', op.id);
        }
      }

      await transaction.complete();
    } on PostgrestException catch (e) {
      if (e.code != null &&
          fatalResponseCodes.any((re) => re.hasMatch(e.code!))) {
        await transaction.complete();
      } else {
        rethrow;
      }
    }
  }
```

We also need to list the fatal response codes that we're referencing, do that by pasting the following code snippet below your import section:

```dart
/// Postgres Response codes that we cannot recover from by retrying.
final List<RegEx> fatalResponseCodes = [
  // Class 22 — Data Exception
  // Examples include data type mismatch.
  RegExp(r'^22...$'),
  // Class 23 — Integrity Constraint Violation.
  // Examples include NOT NULL, FOREIGN KEY and UNIQUE violations.
  RegExp(r'^23...$'),
  // INSUFFICIENT PRIVILEGE - typically a row-level security violation
  RegExp(r'^42501$'),
];
```

#### The fetchCredentials() method

Now for the `fetchCredentials()` method. Paste this below the `uploadData()` code snippet:

```dart
  @override
  Future<PowerSyncCredentials?> fetchCredentials() async {
  	return PowerSyncCredentials(endpoint: endpoint, token: token)
    }
  }
```

This `fetchCredentials()` method will return `PowerSyncCredentials` ([docs](https://pub.dev/documentation/powersync/latest/powersync/PowerSyncBackendConnector-class.html)). You'll need to pass two parameters: 

  1. the **endpoint** that the PowerSync service will connect to, and 
  2. a **token** to authenticate the user.

You can find the relevant endpoint on your [PowerSync dashboard](https://powersync.journeyapps.com/) by right-clicking on  **[Instance Name] > Edit instance > Copy Instance URL**, e.g:

![](https://powersync.com/images/blog/flutter-tutorial-building-an-offline-first-chat-app-with-supabase-and-powersync-inline-b69ed91b59.png)

Copy your **Instance URL** and set it as your **endpoint** value, pasting it in single quotes.

We'll use the Supabase `user access token` as the **token** parameter. We'll first do a null check on the session to see if the user is actually logged in and if they are, we'll extract the token, like so:

```dart
  @override
  Future<PowerSyncCredentials?> fetchCredentials() async {
    final session = Supabase.instance.client.auth.currentSession;

    if (session == null) {
      // not logged in
      return null;
    }

    final token = session.accessToken;

    return PowerSyncCredentials(
        endpoint: 'https://65130c9db6679a3682ba380a.powersync.journeyapps.com',
        token: token);
  }
```

### Implement client-side schema

The schema is the application-level view onto the underlying SQLite data. We'll define our client-side schema by going to **lib > models** and creating a new file called `schema.dart`. Import the `powersync` package, set `schema` as a constant but don't assign a value to it just yet:

```dart
import 'package:powersync/powersync.dart';

const schema = ;
```

Go to your PowerSync dashboard, right-click on the name of your instance in the left sidebar and select **Generate client-side schema**. Select **Dart** as the language.

![](https://powersync.com/images/blog/flutter-tutorial-building-an-offline-first-chat-app-with-supabase-and-powersync-inline-31cec0800d.png)

> **Note**
>
> You'll notice that the generated schema does not include the `id` column for either table. This is because the `id` column is automatically available on the client and does not need to be defined in the schema file.

Copy the generated schema into your `schema.dart` file so that it looks like this:

```dart
import 'package:powersync/powersync.dart';

const schema = Schema([
  Table('messages', [
    Column.text('profile_id'),
    Column.text('content'),
    Column.text('created_at')
  ]),
  Table('profiles',[
    Column.text('username'), 
    Column.text('created_at')
  ])
]);
```

Now you can complete [the steps for instantiating your in-app database](https://powersync.com/blog/flutter-tutorial-building-an-offline-first-chat-app-with-supabase-and-powersync#instantiate-the-powersync-in-app-database).

### Implement auth methods

#### Check if a user is logged in

We'll connect the in-app database to the PowerSync service with the `db.connect()` method. However,  we only want to call this method if users are logged in. To check if users are logged in, we'll  implement a helper method called `isLoggedIn`. 

Do this above the `openDatabase()` constructor in the `powersync.dart` file, with this code that checks whether a user has an access token, and is therefore logged in:

```dart
bool isLoggedIn() {
  return Supabase.instance.client.auth.currentSession?.accessToken != null;
}
```

Now we'll add logic that relies on this helper method and calls `db.connect()` if a user is logged in:

```dart
if (isLoggedIn()) {
    db.connect(connector: connector);
  }
```

`db.connect` takes a **connector** as an input. We want that to be the `SupabaseConnector` but we'll define that outside of this logic because we'll re-use it later, like so:

```dart
 SupabaseConnector? currentConnector;

  if (isLoggedIn()) {
    currentConnector = SupabaseConnector(db);
    db.connect(connector: currentConnector);
  }
```

#### Listen for user auth state changes

Since a user's authentication state can change over the course of their use of the app (they can sign in or sign out, their JWT can expire, etc.), we want our `openDatabase()` constructor to be aware of that.

Add the following below the `if (isLoggedIn())` snippet:

```dart
Supabase.instance.client.auth.onAuthStateChange.listen((data) async {
    final AuthChangeEvent event = data.event;
    if (event == AuthChangeEvent.signedIn) {
      currentConnector = SupabaseConnector(db);
      db.connect(connector: currentConnector!);
    } else if (event == AuthChangeEvent.signedOut) {
      currentConnector = null;
      await db.disconnect();
    } else if (event == AuthChangeEvent.tokenRefreshed) {
      currentConnector?.prefetchCredentials();
    }
  });
```

This uses the `onAuthStageChange.listen()` to check a user's authentication state and disconnects the database if they are signed out or fetches new credentials if their token has been refreshed.

You've now completed implementation of the `openDatabase()` method 🎉

### Wire up in-app database calls to UI

#### Create data objects from in-app database

**In the messages model**

In `messages.dart`, import the global db object that we defined in the `powersync.dart` file as well some SQLite helpers from the **PowerSync** package, which we'll give the name `sqlite`:

```dart
import '../powersync.dart';
import 'package:powersync/sqlite3.dart' as sqlite;
```

Because we want to instantiate a message object from a database row, we'll need to add a factory constructor we'll name `Message.fromRow()` which will take a SQLite row and a user ID as parameters:

```dart
  factory Message.fromRow(sqlite.Row row, String myUserId) {
    return Message(
        id: row['id'],
        profileId: row['profile_id'],
        content: row['content'],
        createdAt: DateTime.parse(row['created_at']),
        isMine: myUserId == row['profile_id']);
  }
```

Now create a method we'll name `watchMessage()`. Here we'll use the `watch()` method ([docs](https://pub.dev/documentation/powersync/latest/sqlite_async/SqliteQueries/watch.html)) which executes a read query every time the source tables are modified. The `watchMessage()` method takes a User ID as a parameter and returns the `db.watch()` method which will execute a SQL statement to get the relevant messages for the user, ordered by their created date. We'll use the `fromRow` method to map results and cast them as a list with growable set to false:

```dart
  static Stream<List>Message>> watchMessages(String myUserId) {
    return db
        .watch('SELECT * FROM messages ORDER BY created_at DESC')
        .map((results) {
      return results
          .map((row) => Message.fromRow(row, myUserId))
          .toList(growable: false);
    });
  }
```

Lastly, create a static method that we'll name `create()` which takes `profileId` and `content` as parameters and uses the `execute()` method ([docs](https://pub.dev/documentation/powersync/latest/sqlite_async/SqliteQueries/execute.html)) to insert new messages into the messages table, like this:

```dart
  static Future<void> create(String profileId, String content) async {
    await db.execute(
        'INSERT INTO messages(id, created_at, profile_id, content) VALUES(uuid(), datetime(), ?, ?)',
        [profileId, content]);
  }
```

**In the profile model**

Go to `profile.dart` and import the global db object and SQLite helper package as above.

Now implement a similar factory constructor for the profile object. The main thing to remember here is that it must match the schema you defined:

```dart
  factory Profile.fromRow(sqlite.Row row) {
    return Profile(
        id: row['id'],
        username: row['username'],
        createdAt: DateTime.parse(row['created_at']));
  }
```

Create a method `findProfileByID()` that returns a Profile using the `get()` method ([docs](https://pub.dev/documentation/powersync/latest/sqlite_async/SqliteQueries/get.html)):

```javascript
  static Future<Profile> findProfileById(String id) async {
    final result = await db.get('SELECT * FROM profiles where id = ?', [id]);
    return Profile.fromRow(result);
  }
```

#### Wire up chat page to in-app database

**Stream messages**

In `chat_page.dart`, find the `_messageStream` snippet and replace the code from the Supabase demo with a call to the static method we defined above so that it looks like this:

```dart
  @override
  void initState() {
    final myUserId = supabase.auth.currentUser!.id;
    _messagesStream = Message.watchMessages(myUserId);
    super.initState();
  }
```

**Set profile**

Instead of extracting the profile from data, we can now set it directly. To do this, update the `_loadProfileCache` snippet to call the `findProfileById()` method, like this:

```dart
  Future<void> _loadProfileCache(String profileId) async {
    if (_profileCache[profileId] != null) {
      return;
    }
    final profile = await Profile.findProfileById(profileId);
    setState(() {
      _profileCache[profileId] = profile;
    });
  }
```

**Create message**

Find the `_submitMessage()` snippet and update it to use the static `create()` method that we defined above, like this:

```dart
  void _submitMessage() async {
    final text = _textController.text;
    final myUserId = supabase.auth.currentUser!.id;
    if (text.isEmpty) {
      return;
    }
    _textController.clear();
    await Message.create(myUserId, text);
  }
```

### Implement Logging

It's recommended to implement logging for your app but we won't go through the steps to implement that here. Refer to the complete example implementation below to see how.

### Complete Implementation Available

An example with everything above implemented (including logging) is available [here](https://github.com/powersync-ja/powersync.dart/tree/master/demos/supabase-simple-chat).

🎉🎉 That's it! You've completed the offline-first chat app tutorial 🎉🎉
