A PocketBase backend for Flutter and Dart

Suragch
16 min readJan 27, 2024

--

A step-by-step tutorial

Updated February 14, 2024.

I’ve been looking into solutions for self-hosting a backend server for my Flutter app. In addition to performing normal read-write operations, it also needs to support user authentication. To that end, I’ve been experimenting with Shelf, Supabase, and SuperTokens.

Then I discovered PocketBase.

Look at what this Backend as a Service (BaaS) offers:

  • Open source.
  • 30k stars on GitHub and under active development.
  • Has a Dart SDK.
  • Email-password or social auth (Google, Apple, etc).
  • SQLite database, configurable with rules and user roles.
  • Realtime updates.
  • File storage.
  • Admin dashboard.
  • Super easy deployment with a single file.

And that’s just a partial list.

The article that follows will walk you through the steps of connecting your Flutter app to PocketBase. I wrote it partly to teach myself how to do it and partly to teach you. If you still have any questions when you’re finished, please leave a comment. I’ll include some of my own thoughts at the end of the article as well as some reasons you might not choose PocketBase.

Note: This is an intermediate-level tutorial. I’ll assume you already know how to do basic Flutter tasks like building a UI and adding packages.

Getting started

Start by creating a new Flutter project.

Then build a UI like the one in the image below. It should include a Text widget to display the user’s logged-in status and eight buttons titled Sign up, Sign in, Refresh token, Sign out, Create, Read, Update, and Delete:

You’ll use the buttons to learn how to authenticate a user and perform CRUD operations with PocketBase.

Running PocketBase locally

During development, it will be more convenient to have the PocketBase server running on your local machine than on a remote one.

Go to the PocketBase documentation page and download the version for your local machine.

Extract the folder and run the following to start the server:

./pocketbase serve

Note: On Mac, you need to open the folder in Finder, right-click the pocketbase file, and choose Open to bypass the security settings for downloaded files. See more here. After that, run the ./pocketbase serve command again to start the server.

PocketBase will also create a folder named pb_data where it will store the data.

Go to http://127.0.0.1:8090/ in your browser and you should get a 404 response. In this case, that good. It means the server is working.

Creating an admin user

Next, go to http://127.0.0.1:8090/_/ to set up the admin account. You’ll see the following screen:

Fill in the fields and then log in. You’ll be greeted with the following admin dashboard:

You don’t need to do anything now, but keep this browser window open. You’ll come back here later to see the result of the changes you make from your Flutter client.

Adding the PocketBase Dart SDK to Flutter

Although you could communicate with the PocketBase server using its REST API, it will be much easier to directly use the official Dart SDK.

Back in your Flutter app, add the pocketbase package to pubspec.yaml:

dependencies:
pocketbase: ^0.18.0

You’ll import PocketBase wherever you need it like so:

import 'package:pocketbase/pocketbase.dart';

You’ll also need an instance of PocketBase to connect with the server. Create it wherever it makes sense for your state management approach:

final pb = PocketBase('http://127.0.0.1:8090/');

If you’re doing development using the Android emulator, then use port 10.0.2.2:

final pb = PocketBase('http://10.0.2.2:8090/');

I use the minimalist state management approach. Since I only need a reference to PocketBase in one file for today’s tutorial, I’ll initialize it directly in my state management class. However, if I needed it in multiple places throughout the app, I’d use GetIt.

Signing up a new user

PocketBase makes it very easy to perform the various authentication flows.

Add a method to your Flutter project that will run when the Sign up button is pressed:

Future<void> signUp() async {
final body = <String, dynamic>{
"username": "Bob",
"email": "bob@example.com",
"password": "12345678",
"passwordConfirm": "12345678",
"name": "Bob Smith"
};

final record = await pb.collection('users').create(body: body);
print(record);
}

Here are some notes about that code:

  • pb is the PocketBase instance that you created in the last step.
  • users is the default collection (table) that PocketBase uses for authentication.
  • create adds a new record (row) to the collection. In this case, that means a new user.
  • You pass the email, password, and other parameters as a map to the create body. You can read about the other auth record fields in the docs.

Run the code above and you should see something similar to the response below:

{
"id":"79yvk2r0lxnt6ob",
"created":"2024-01-26 03:02:11.967Z",
"updated":"2024-01-26 03:02:11.967Z",
"collectionId":"_pb_users_auth_",
"collectionName":"users",
"expand":{},
"avatar":"",
"emailVisibility":false,
"name":"Bob Smith",
"username":"Bob",
"verified":false
}

Back in the browser dashboard, press the Refresh button and you’ll see that a new user record has been added:

Signing up a new user doesn’t sign them in yet. That’s another step.

Note: I won’t cover it in this tutorial, but before you allow a new user to sign in, you’ll probably want to verify their email. Think about what might happen if you don’t. Some malicious hacker could write a script to register thousands of fake users and then use those accounts to spam your site. PocketBase supports sending email verifications. Here is a video about it. You’ll either want to set up a quality SMTP server or use a third-party email service. Otherwise, your emails will probably be flagged as spam.

Signing in

Next add the code that corresponds with the Sign in button in your app:

Future<void> signIn() async {
final authData = await pb
.collection('users')
.authWithPassword('bob@example.com', '12345678');
print(authData);
}

authWithPassword sends the email and password for PocketBase to check.

Refresh your app and press the Sign in button. You should see a result similar to the following:

{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3MDc0NDk1OTIsImlkIjoiNzl5dmsycjBseG50Nm9iIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.uee--OI5bagsyVjLHq0kqbrbKQfyQ9UxpVV9g6eR8vc",
"record":{
"id":"79yvk2r0lxnt6ob",
"created":"2024-01-26 03:02:11.967Z",
"updated":"2024-01-26 03:02:11.967Z",
"collectionId":"_pb_users_auth_",
"collectionName":"users",
"expand":{},
"avatar":"",
"email":"bob@example.com",
"emailVisibility":false,
"name":"Bob Smith",
"username":"Bob",
"verified":false
},
"meta":{}
}

That token is a JWT, which if you were to decode, would contain the following information in its payload:

{
"collectionId": "_pb_users_auth_",
"exp": 1707449592,
"id": "79yvk2r0lxnt6ob",
"type": "authRecord"
}

The exp field is when the token expires. The default duration is two weeks. You can change the default duration in the PocketBase dashboard under Settings > Token Options.

Note: PocketBase doesn’t use refresh tokens. It only uses long-lived access tokens. The reason is that PocketBase is a monolith. The data and the auth all happen on the same server. PocketBase also doesn’t store the token on the server. It just verifies the access tokens that it has previously issued. You can read more here.

After you’ve signed in, you can use the authStore property. This will give you access to token and methods like isValid.

It’s time to update the UI now that the user has signed in. Feel free to use setState or your own preferred state management method. With the minimalist approach, I use Flutter’s built-in ValueNotifier and ValueListenableBuilder.

final statusNotifier = ValueNotifier('Logged out');

Then I update the notifier at the end of the signIn method:

statusNotifier.value = (pb.authStore.isValid) ? 'Logged in' : 'Logged out';

pb.authStore.isValid returns true now that you’ve logged in.

Restart the app and press the Sign in button. Your status text should indicate that you’ve logged in:

Note: Rather than manually updating the notifier, another option would be to listen to the authStore.onChange stream and pair that with a StreamBuilder to update the UI. In a larger app, you could combine this with go_router to automatically redirect to the login screen.

Sign-in is great, but you don’t want to make your users enter their email and password every time they open your app. What you should do is automatically refresh the access token. You’ll learn how to do that in the next step.

Refreshing the token

When you sign in, PocketBase gives you an access token. This token is valid for two weeks by default. It’s your responsibility to manage the token on the Flutter side. If you lose the token or it expires, then you’ll have to ask the user to sign in again.

Add a method that will run when the Refresh token button is pressed:

Future<void> refresh() async {
if (!pb.authStore.isValid) {
// TODO: The token has expired. Ask the user to sign in again.
return;
}
final authData = await pb.collection('users').authRefresh();
print(authData);
}

authRefresh requests a new token with an updated expiration date from PocketBase.

Hot reload your app so that you are still logged in, and then press the Refresh token button. You should get a new JWT token back. If you decode it, you’ll see that the exp expiration timestamp has also been updated.

Persisting the token (optional)

The default AuthStore only keeps the access token in memory, so when you close the app the access token is lost. If you want the user to stay logged in the next time they open your app, you need to provide PocketBase with a custom AuthStore. You can do that with PocketBase’s AsyncAuthStore class.

I’m not going to go into the implementation details of how to store the token string in local storage, but the following code shows how to handle things on the PocketBase side.

Replace your pb instance in your state management class with the following:

late final PocketBase pb;

// 1
Future<void> init() async {
// 2
final storage = getIt<LocalStorage>();
// 3
final token = await storage.getToken();
final customAuthStore = AsyncAuthStore(
initial: token,
save: storage.setToken,
clear: storage.deleteToken,
);
// 4
pb = PocketBase(
'http://$_host:8090/',
authStore: customAuthStore,
);

if (pb.authStore.isValid) {
statusNotifier.value = 'Logged in';
// 5
final authRecord = await pb.collection('users').authRefresh();
print(authRecord);
} else {
statusNotifier.value = 'Logged out';
}
}

String get _host => (Platform.isAndroid) ? '10.0.2.2' : '127.0.0.1';

Here are a few notes:

  1. You should call the init method when the app starts. In the demo project, I called it in the initState method of the home page.
  2. In the demo project, I used get_it to provide a reference to my LocalStorage service. Feel free to use provider or some form of dependency injection.
  3. You can implement LocalStorage any way you like as long as it has a way to save, read and delete the token string. Check out flutter_secure_storage as one option for encrypted storage. In the demo project for this tutorial, I used shared_preferences only for its simplicity. A production app shouldn’t use shared_preferences because it isn’t secure. If a hacker were to get the token, they could do anything the user can do.
  4. Provide your custom auth store to PocketBase when you initialize it.
  5. You can also refresh the auth token when the app first starts up. As you saw earlier, this will update the expiration date, minimizing the chance that the user will need to sign in again. Your custom auth store automatically takes care of saving the token.

Close the app and restart it. You should still be logged in.

Read this and this for a few more pointers on implementing a custom AuthStore.

Signing out

The last authentication-related step this tutorial will cover is signing out. It’s just as easy as the other steps have been.

PocketBase doesn’t store a user’s session data or even their access token on the server. So, in order to log a user out, all you need to do is clear the auth data on the client side.

Add a method that will run when the user presses the Sign out button:

Future<void> signOut() async {
pb.authStore.clear();
statusNotifier.value = 'Logged out';
}

That was easy, huh?

Refresh the app and test it out:

There are other auth tasks that you may want to do in the future, such as resetting a forgotten password or requesting an email change. You can check out the PocketBase documentation for details. In fact, if you choose API Preview from the admin dashboard, it will give you code samples of how to do everything.

In the next section, you’ll add a regular collection so that you can perform CRUD operations on it from your Flutter client.

Creating a new collection

In the admin dashboard, click the New collection button:

Adding fields

A new window will pop up. Follow these directions to match the image below:

  • You’ll make a collection to save user exam scores, so write scores for the collection name.
  • There are three different types of collections in PocketBase. Since we want users to be able to edit the contents of this collection, choose Base from the drop-down menu in the top right. (This is the default.)
  • On the Fields tab, click New field to add two new fields.
  • Choose Relation and name it user. Under Select collection, choose the users collection. This will link the user field to the users collection. (It’s like adding a foreign key to a table in SQL.)
  • Click New field again and this time add a Number field. Call it score.

Adding API rules

For security reasons, only admin users can read and write to the collection by default. However, you want to allow the users of your Flutter app to also edit their own data. PocketBase handles this with API Rules.

Click the API Rules tab:

There are five different categories. List/Search rules are for requesting a list of records, while View rules are for requesting a single record. You only want a user to be able to see their own exam scores, not other people’s scores. So for both of these categories, add the following rule:

user = @request.auth.id

This means that the user field of your scores collection must match the authenticated user ID in the request. This effectively filters out all other users.

The same is true for Create and Update. However, you also want to enforce the exam score to be within the range of 0–100. Add the following rule to those two categories:

user = @request.auth.id && 
@request.data.score >= 0 &&
@request.data.score <= 100

This ensures that the score field of the incoming request is within the proper bounds.

Finally, users should only be allowed to delete their own scores, so add the following to the Delete rules:

user = @request.auth.id

When you’re done, save your changes by clicking the Create button in the bottom right corner of the admin dashboard.

Creating a record from Flutter

Back in your Flutter app, add a method that will be called when a user presses the Create button on your Flutter app:

Future<void> create() async {
final body = <String, dynamic>{
"user": pb.authStore.model.id,
"score": 89,
};

final record = await pb.collection('scores').create(body: body);
print(record);
}

You’re again using the create method just like when you added a new user. This time, though, you’re creating a record in the scores collection. Notice that the keys (user and score) of the body map match the field names you chose when you created the collection.

Refresh your app and press the Create button. You should see the following result for record:

{
"id":"k4oaijxf47jroc0",
"created":"2024-01-26 09:39:27.934Z",
"updated":"2024-01-26 09:39:27.934Z",
"collectionId":"0q5mu8dg1ohw7iz",
"collectionName":"scores",
"expand":{},
"score":89,
"user":"79yvk2r0lxnt6ob"
}

Great, it’s working.

You need some more user data for the future steps. Let’s create that now. Change the contents of the signUp and signIn methods to add a new user named Mary.

Future<void> signUp() async {
final body = <String, dynamic>{
"username": "Mary", // updated
"email": "mary@example.com", // updated
"password": "12345678",
"passwordConfirm": "12345678",
"name": "Mary Smith" // updated
};

final record = await pb.collection('users').create(body: body);
print(record);
}

Future<void> signIn() async {
final authData = await pb
.collection('users')
.authWithPassword('mary@example.com', '12345678'); // updated
print(authData);

statusNotifier.value = (pb.authStore.isValid) ? 'Logged in' : 'Logged out';
}

Now refresh the app and press Sign up, Sign in, and Create in that order.

Next, change the score in the create method to 92. Then refresh the app and press the Create button again.

Now you should have three records in the scores table: one from Bob and two from Mary. Go to the admin dashboard and press the Refresh button to check it out:

Nice! It worked. You’ve created three new records.

Reading a list of records

Next you’ll try to get a list of all the records for one user. You’re currently logged in as Mary so you would expect to get both of Mary’s exam scores. And if you set up the rules correctly, you shouldn’t get Bob’s score.

Add a method that will be run when the user presses the Read button:

Future<void> read() async {
final records = await pb.collection('scores').getFullList(
sort: '-score',
);
print(records);
}

Here are a few notes:

  • getFullList returns all of the records in the collection (filtered by the API rules you defined). If that would be too many records, you can paginate the results using getList.
  • sort defines which field you want to sort by, in this case by score. The - dash in front of the field name means that you want to sort in reverse order, in this case from high to low score.
  • There are a number of additional parameters you can include in addition to sort. Others are batch, filter, and fields.

Refresh your app and press the Read button. You should see Mary’s two exam scores:

[
{
"id":"m1fkfqdv8r20oif",
"created":"2024-01-26 09:51:04.343Z",
"updated":"2024-01-26 09:51:04.343Z",
"collectionId":"0q5mu8dg1ohw7iz",
"collectionName":"scores",
"expand":{},
"score":92,
"user":"eokvshdupfla4v4"
},
{
"id":"3fkk1f4sgwqgzje",
"created":"2024-01-26 09:49:07.460Z",
"updated":"2024-01-26 09:49:07.460Z",
"collectionId":"0q5mu8dg1ohw7iz",
"collectionName":"scores",
"expand":{},
"score":89,
"user":"eokvshdupfla4v4"
}
]

It’s good to see that Bob (user ID 79yvk2r0lxnt6ob) isn’t there. Your API rule worked. Also, Mary’s scores are sorted from highest to lowest.

There’s a lot more data there than you really need, though. All you want are the score IDs and the scores themselves. You can tell PocketBase which fields you want using the fields parameter of getFullList. Add the following line after sort: ‘-score’,:

fields: 'id,score',

When combining multiple field names, you split them with a comma.

Press the Read button again, and this time, this is what you see:

[
{
"id":"m1fkfqdv8r20oif",
"created":"",
"updated":"",
"collectionId":"",
"collectionName":"",
"expand":{},
"score":92
},
{
"id":"3fkk1f4sgwqgzje",
"created":"",
"updated":"",
"collectionId":"",
"collectionName":"",
"expand":{},
"score":89
}
]

I don’t know that PocketBase needed to give you all the empty fields, but at least you saved it the work of passing in useless values.

Updating a record

Mary isn’t satisfied with her score of 89. She wants a higher grade. In this step, you’ll replace the lowest score with 100.

Create a method that will be called when the Update button is pushed:

Future<void> update() async {
// Find the record with the lowest score
final recordList = await pb.collection('scores').getList(
page: 1,
perPage: 1,
skipTotal: true,
sort: 'score',
fields: 'id,score',
);
print(recordList);
final record = recordList.items.first;

// Update the record
final body = <String, dynamic>{"score": 100};
final updatedRecord = await pb.collection('scores').update(
record.id,
body: body,
);
print(updatedRecord);
}

Here are some notes:

  • This time you use getList rather than getFullList because you only need one record.
  • For the same reason, you also only select 1 page and 1 record perPage. You don’t care about the total number of pages or records, so you can skipTotal. All of these are performance optimizations.
  • Once you have the record, you can update the score field using the record ID.

Refresh the app and press the Update button. Here’s what you’ll see:

{
"page":1,
"perPage":1,
"totalItems":-1,
"totalPages":-1,
"items":[
{
"id":"3fkk1f4sgwqgzje",
"created":"",
"updated":"",
"collectionId":"",
"collectionName":"",
"expand":{},
"score":89
}
]
}

{
"id":"3fkk1f4sgwqgzje",
"created":"2024-01-26 09:49:07.460Z",
"updated":"2024-01-27 04:10:09.986Z",
"collectionId":"0q5mu8dg1ohw7iz",
"collectionName":"scores",
"expand":{},
"score":100,
"user":"eokvshdupfla4v4"
}

The score of 89 was updated to 100.

You can also see the same result by refreshing the scores collection in the dashboard:

Hmm, come to think of it, allowing students to update their own exam scores probably isn’t such a great idea. You may want to change the API rules and even introduce user roles like teacher and student.

Deleting a record

The last task you’ll implement in this tutorial is how to delete a record. The only thing you need to know is the record ID.

Add a method that will be called when the user presses the Delete button:

Future<void> delete() async {
// Find the record with the lowest score
final recordList = await pb.collection('scores').getList(
page: 1,
perPage: 1,
skipTotal: true,
sort: 'score',
fields: 'id,score',
);
final record = recordList.items.first;

// Delete the record
await pb.collection('scores').delete(record.id);
}

Refresh the app and press the Delete button. There is no return value, but you can refresh the scores collection in the dashboard to see that Mary is missing one value.

That brings you to the end of the tutorial. If you successfully followed along this far, you should have a good idea of how PocketBase works.

Going on

The tutorial didn’t implement any error handling. You can handle that by wrapping the PocketBase calls in try-catch blocks.

Read the Going to Production section of the documentation for how to deploy your server.

Final thoughts

I really like PocketBase. It’s by far the easiest way of setting up a self-hosted auth backend that I’ve found. However, there are a few caveats that I discovered along the way.

  • Currently, bulk create, update, and delete operations are not supported. That means if a user wants to import a lot of data or update or delete many records at once, the only way to do it is by handling one record at a time. If you need to do that for thousands of records, it could be a deal breaker. There is an issue open on GitHub for this topic, but the solution is apparently non-trivial and currently the status is On Hold in the 1.0 roadmap.
  • It’s possible to extend PocketBase and do things like define additional API routes. However, you have to use Go or JavaScript. Unfortunately, Dart isn’t an option.

Both of these drawbacks make me wonder, what if I just used PocketBase as the auth server and ran a Dart Shelf server on the same machine to handle database operations and other logic? It’s not quite as ideal, but I think it could work. To do this, the Flutter client app would first authenticate with PocketBase and get an access token. Then the client would pass the token in the Authorization header to the Dart server. The Dart server would verify the token with PocketBase and if valid would proceed to perform whatever task the user requested. This functionality isn’t designed into PocketBase, but the author acknowledges that it is possible to verify a token by calling authRefresh. Read this GitHub discussion for more.

Update: I’ve written a tutorial about using PostgreSQL on a Dart server. The only other step would be to authenticate the calls with a PocketBase access token. Read that article here: Using PostgreSQL on a Dart server.

Full code

If you’d like to support me, I’m selling the project source code as a download:

Thank you for your support!

(If you can’t afford to pay for it, though, no worries. Send me an email, and I’ll give it to you for free.)

--

--