Skip to main content
Version: 2.2.0

Build your first app

You will build a simple note-taking app in this tutorial. You will learn the fundamental building blocks of Serverpod that enable you to create powerful and scalable server-side applications with ease.

We are assuming you have all the tools setup and ready to go. If not, please follow the Installing Serverpod guide to get up and running.

We will cover the following topics:

  • Creating serializable objects
  • Creating a database table
  • Creating API endpoints for CRUD operations
  • Using the serverpod code generator
  • Using the generated client library
  • Connecting a Flutter app to the server

Demo of what we will build: (Full code example).

Create a new project

Create a new project using the Serverpod CLI. Run the following command in your terminal:

$ serverpod create notes

To start the server:

$ cd notes/notes_server
$ docker compose up --build --detach
$ dart bin/main.dart --apply-migrations
info

Make sure you have Docker running on your machine before executing these commands.

Serialize objects

Serverpod comes with a convenient way to create serializable objects with the help of code generation. These objects can easily be sent back and forth between the server and the client. This is managed by defining our objects in a YAML file which the code generator then parses and generates the necessary code for.

To define the structure of our Note object, we will create a YAML file called note.yaml inside the lib/src/models directory in your Serverpod project (notes_server). Add the following content in note.yaml:

### Holds a note with a text written by the user.
class: Note
fields:
### The contents of the note.
text: String

Let's take a closer look at the content of the note.yaml file:

  • class: Specifies the name of the class to be generated, which in this case is Note.
  • fields: This keyword indicates the beginning of the field definitions for the Note class.
  • text: Defines a text field of type String in the Note class, in this minimal example we only have one field but you can add as many fields as you need.

Use the code generator to generate the code for the Note class from the definition in note.yaml. Run the following command from the root of your server project (notes_server):

$ serverpod generate

After the code generation process is complete, you can access the generated code for the Note class in lib/src/generated/note.dart inside your Serverpod project.

Serverpod Insights

By implementing the SerializableModel class, the Note object becomes capable of automatic serialization and deserialization. This makes the Note object transmittable between the server and the client.

In simple terms, we have created a class, Note, that can hold information and can be passed within the server. Additionally, we can send this object to the client side of our application. Serverpod takes care of handling the conversion between the object and its serialized representation, making it convenient to work with and transfer data seamlessly.

Create database tables

Serverpod provides built-in support for database integrations. By defining a database table named note in the YAML file using the table keyword, we can create database bindings for our Note class.

The updated content of the note.yaml file should look like this:

### Holds a note with a text written by the user.
class: Note
table: note
fields:
### The contents of the note.
text: String

Run the code generator again to generate the necessary code used to access the database table:

$ serverpod generate

Take a look at the updated lib/src/generated/note.dart file. You will notice that the code generator has added new methods for interacting with the database.

Create database migration

To create the new note table in the database we will use the Serverpod migration system. Run the following command to generate a new database migration:

$ serverpod create-migration

This creates a new migration that contains a description of the database schema and SQL code to add the table. These files can be found in the migrations directory in your server project (notes_server).

Apply database migration

To apply the database migration, start the server with the --apply-migrations command. We can also run the server in maintenance mode which will shut down the server as soon as its tasks are done.

$ dart run bin/main.dart --role maintenance --apply-migrations

Once command has executed successfully, the database table for storing the note data will have been created.

info

Any time you update the table definitions you have to create a migration and apply it to the database to update the database schema.

Create API endpoints

In Serverpod, endpoints are defined in the endpoints folder (lib/src) within your server project (notes_server). The code generator analyzes the code within these endpoints and generates a client library based on the defined functions. This client library is then used by your Flutter app (notes_flutter) to interact with your backend server.

Create a new file called notes_endpoint.dart inside lib/src/endpoints folder and add the following code:

import 'package:serverpod/server.dart';

import '../generated/protocol.dart';

class NotesEndpoint extends Endpoint {
// Endpoint implementation goes here
}

In the above code, we import the necessary dependencies and import the generated Note class from the protocol.dart file. We also define the NotesEndpoint class, which extends the Endpoint class provided by Serverpod. This is required for the endpoint to be recognized by Serverpod's code generator.

Define endpoints

To define an endpoint that can be called from the client, we need to create a method inside the NotesEndpoint class. This method must return a Future of a serializable object, primitive datatype, or void.

The first method parameter must be a Session object. This is a special object in Serverpod that contains information about the current session, as well as other helpful methods.

Future<void> example(Session session) async {
// Endpoint implementation goes here
}

The method is also allowed to have any number of extra parameters. These parameters will be passed from the client when the endpoint is called. The parameters follow the same type restrictions as the return type.

Store notes in the database

To store notes in the database we define a createNote method in the NotesEndpoint class. This method takes a Note object and stores it in the database. To make the method accessible from the app (notes_flutter), make sure that the first parameter passed to this method is a Session object.

Future<void> createNote(Session session, Note note) async {
await Note.db.insertRow(session, note);
}

In the above code, we use the Note.db.insertRow method, created by serverpod generate, to insert the specified Note object into the database.

Delete notes from the database

To delete notes from the database we define a deleteNote method in the NotesEndpoint class. The method takes a Note object which represents the note that needs to be deleted.

Future<void> deleteNote(Session session, Note note) async {
await Note.db.deleteRow(session, note);
}

In the above code, we use the Note.db.deleteRow method to delete the specified Note object from the database.

Fetch notes from the database

To retrieve all notes from the database we define the getAllNotes method in the NotesEndpoint class. This method retrieves all the notes from the database and returns them as a list of Note objects.

Future<List<Note>> getAllNotes(Session session) async {
// By ordering by the id column, we always get the notes in the same order
// and not in the order they were updated.
return await Note.db.find(
session,
orderBy: (t) => t.id,
);
}

In the code above, we use the Note.db.find method to retrieve all the notes from the database. By specifying orderBy: (t) => t.id, we ensure that the notes are always returned in the same order based on the id column.

Putting it all together you end up with a notes_endpoint.dart file that looks like this:

import 'package:serverpod/server.dart';

import '../generated/protocol.dart';

class NotesEndpoint extends Endpoint {
Future<List<Note>> getAllNotes(Session session) async {
// By ordering by the id column, we always get the notes in the same order
// and not in the order they were updated.
return await Note.db.find(
session,
orderBy: (t) => t.id,
);
}

Future<void> createNote(Session session, Note note) async {
await Note.db.insertRow(session, note);
}

Future<void> deleteNote(Session session, Note note) async {
await Note.db.deleteRow(session, note);
}
}

Generate the client library

Congratulations! You have now created all the endpoints needed for the notes app, complete with database integration that persistently stores the notes.

Now run the code generator again to generate the client library for our endpoints. This needs to be run from the server directory notes_server.

$ serverpod generate

You can find the newly generated code in the client directory notes_client. You normally don't need to touch this package, but it's good to know where it is located.

Build the Flutter app

It's time to build the notes_flutter app.

Open the main.dart file in your Flutter project (notes_flutter).

Locate the MyHomePageState class.

Update the MyHomePageState class by removing all the unnecessary code, so that it looks like this:

class MyHomePageState extends State<MyHomePage> {

Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
);
}
}

You can also remove the entire _ResultDisplay class from the file.

Your main.dart file should now look like this:

import 'package:notes_client/notes_client.dart';
import 'package:flutter/material.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';

// Sets up a singleton client object that can be used to talk to the server from
// anywhere in our app. The client is generated from your server code.
// The client is set up to connect to a Serverpod running on a local server on
// the default port. You will need to modify this to connect to staging or
// production servers.
var client = Client('http://localhost:8080/')
..connectivityMonitor = FlutterConnectivityMonitor();

void main() {
runApp(const MyApp());
}

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


Widget build(BuildContext context) {
return MaterialApp(
title: 'Serverpod Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Serverpod Example'),
);
}
}

class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);

final String title;


MyHomePageState createState() => MyHomePageState();
}

class MyHomePageState extends State<MyHomePage> {

Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
);
}
}

Fetch the notes from the server

To fetch all the notes from the server and handle potential connection failures, we first need to declare variables to hold the state inside the MyHomePageState class.

import 'package:notes_client/notes_client.dart';
...

class MyHomePageState extends State<MyHomePage> {

List<Note>? _notes;
Exception? _connectionException;

...
}

Let's create a method to handle the connection failures. Call it _connectionFailed, it updates the state to set _notes to null and stores the thrown exception in _connectionException.

void _connectionFailed(dynamic exception) {
setState(() {
_notes = null;
_connectionException = exception;
});
}

Next, let's add a method for fetching notes from the server endpoint we created earlier. The method updates the state with the notes we received. If the call fails, we catch an exception and call the _connectionFailed method instead.

Future<void> _loadNotes() async {
try {
final notes = await client.notes.getAllNotes();
setState(() {
_notes = notes;
});
} catch (e) {
_connectionFailed(e);
}
}

Since, we want to call the _loadNotes method when the app is started, we override the initState method and call the _loadNotes method from there.

Add the initState method inside the MyHomePageState class.


void initState() {
super.initState();
_loadNotes();
}

Render the notes

To render the fetched notes in the app's main screen, we will update the build method inside the MyHomePageState class. Here is the modified code:


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: _notes == null
? Container()
: ListView.builder(
itemCount: _notes!.length,
itemBuilder: ((context, index) {
return ListTile(
title: Text(_notes![index].text),
);
}),
),
);
}

The _notes variable is checked to determine if notes have been fetched from the server. If _notes is null, an empty Container widget is displayed as a placeholder.

If _notes is not null, a ListView.builder widget is used to render the notes. The itemCount property is set to the length of the _notes list, and the itemBuilder callback is responsible for building the individual ListTile widgets.

Inside the itemBuilder callback, each note's text is displayed in a ListTile using the Text widget.

Create new notes

To create new notes we need to access the createNote method of our notes endpoint. To do this, we create a helper method called _createNote inside MyHomePageState class that takes a Note object as a parameter.

Future<void> _createNote(Note note) async {
try {
await client.notes.createNote(note);
await _loadNotes();
} catch (e) {
_connectionFailed(e);
}
}

The _createNote method calls the createNote method (lib/src/endpoints/notes_endpoint.dart in notes_server) to store the note in the database on the server. Then _loadNotes is called to refresh our list of notes.

The user needs graphical interface to create notes, so let's create a dialog for this. Create a new file called note_dialog.dart in the lib directory of your Flutter app. Add the following code:

Code: note_dialog.dart

import 'package:flutter/material.dart';

void showNoteDialog({
required BuildContext context,
String text = '',
required ValueChanged<String> onSaved,
}) {
showDialog(
context: context,
builder: (context) => NoteDialog(
text: text,
onSaved: onSaved,
),
);
}

class NoteDialog extends StatefulWidget {
const NoteDialog({
required this.text,
required this.onSaved,
super.key,
});

final String text;
final ValueChanged<String> onSaved;


NoteDialogState createState() => NoteDialogState();
}

class NoteDialogState extends State<NoteDialog> {
final TextEditingController controller = TextEditingController();


void initState() {
super.initState();
controller.text = widget.text;
}


Widget build(BuildContext context) {
return Dialog(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Expanded(
child: TextField(
controller: controller,
expands: true,
maxLines: null,
minLines: null,
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Write your note here...',
),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
widget.onSaved(controller.text);
Navigator.of(context).pop();
},
child: const Text('Save'),
),
],
),
),
);
}
}

The dialog is needed but we will skip going into details of how it works as this is just normal Flutter code. The gist is that we have a function that triggers an input dialog and a callback for when the user saves the input.

We need a button to trigger this dialog and a floating action button would be great for this. Add the following code to the build method inside the MyHomePageState class:


Widget build(BuildContext context) {
return Scaffold(
...
floatingActionButton: _notes == null
? null
: FloatingActionButton(
onPressed: () {
showNoteDialog(
context: context,
onSaved: (text) {
var note = Note(
text: text,
);
_notes!.add(note);

_createNote(note);
},
);
},
child: const Icon(Icons.add),
),
);
}

Also import the dialog:

import 'package:notes_flutter/note_dialog.dart';

In the above code, we trigger the note dialog when the action button is pressed and then save the note in the onSaved callback. To make the UI feel more responsive, we add the changes to the notes list before calling _createNote. This way the note will be added to the list immediately and then updated when the server responds.

Finally, add the loading screen to the project. Create a new file called loading_screen.dart in the lib directory of your Flutter app and add the following code.

Code: loading_screen.dart

import 'package:flutter/material.dart';

class LoadingScreen extends StatelessWidget {
const LoadingScreen({
this.exception,
required this.onTryAgain,
super.key,
});

final Exception? exception;
final VoidCallback onTryAgain;


Widget build(BuildContext context) {
if (exception != null) {
return Center(
child: ElevatedButton(
onPressed: onTryAgain,
child: const Text('Try again'),
),
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
}
}

Replace the empty Container with the loading screen in the build method.

Code: main.dart

import 'package:notes_client/notes_client.dart';
import 'package:flutter/material.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';

import 'note_dialog.dart';

// Sets up a singleton client object that can be used to talk to the server from
// anywhere in our app. The client is generated from your server code.
// The client is set up to connect to a Serverpod running on a local server on
// the default port. You will need to modify this to connect to staging or
// production servers.
var client = Client('http://localhost:8080/')
..connectivityMonitor = FlutterConnectivityMonitor();

void main() {
runApp(const MyApp());
}

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


Widget build(BuildContext context) {
return MaterialApp(
title: 'Notes',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Notes'),
);
}
}

class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);

final String title;


MyHomePageState createState() => MyHomePageState();
}

class MyHomePageState extends State<MyHomePage> {
// This field holds the list of notes that we've received from the server or
// null if no notes have been received yet.
List<Note>? _notes;

// If the connection to the server fails, this field will hold the exception
// that was thrown.
Exception? _connectionException;


void initState() {
super.initState();
_loadNotes();
}

Future<void> _loadNotes() async {
try {
final notes = await client.notes.getAllNotes();
setState(() {
_notes = notes;
});
} catch (e) {
_connectionFailed(e);
}
}

Future<void> _createNote(Note note) async {
try {
await client.notes.createNote(note);
await _loadNotes();
} catch (e) {
_connectionFailed(e);
}
}

void _connectionFailed(dynamic exception) {
// If the connection to the server fails, we clear the list of notes and
// store the exception that was thrown. This will make the loading screen
// appear and show a button to try again.

// In a real app you would probably want to do more complete error handling.
setState(() {
_notes = null;
_connectionException = exception;
});
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: _notes == null
? LoadingScreen(
exception: _connectionException,
onTryAgain: _loadNotes,
)
: ListView.builder(
itemCount: _notes!.length,
itemBuilder: ((context, index) {
return ListTile(
title: Text(_notes![index].text),
);
}),
),
floatingActionButton: _notes == null
? null
: FloatingActionButton(
onPressed: () {
// When we tap the floating action button we want to show a
// dialog where we can create a new note.
showNoteDialog(
context: context,
onSaved: (text) {
var note = Note(
text: text,
);

// Add the note to the list of notes before we've received
// a response from the server which makes the UI feel more
// responsive.
_notes!.add(note);

// Actually create the note on the server.
_createNote(note);
},
);
},
child: const Icon(Icons.add),
),
);
}
}

Also import the loading screen:

import 'package:notes_flutter/note_dialog.dart';

Run the app

Start the database and server: Make sure you reboot the server if you started it earlier.

$ cd notes_server
$ docker compose up --build --detach
$ dart bin/main.dart --apply-migrations

Start the Flutter app in Chrome (or the platform of your choice):

$ cd notes_flutter
$ flutter run -d chrome

Delete notes

Implementing the delete functionality is very similar to the create functionality. We will add a delete button to each note and then call the delete endpoint when the button is pressed. First, let's add a helper method, _deleteNote inside the MyHomePageState class to call the endpoint:

Future<void> _deleteNote(Note note) async {
try {
await client.notes.deleteNote(note);
await _loadNotes();
} catch (e) {
_connectionFailed(e);
}
}

The _deleteNote method calls the deleteNote endpoint to delete the note from the database on the server. Then _loadNotes is called to refresh the notes list.

Next in our ListTile we add a delete button and call the _deleteNote method when the button is pressed. Just as before we update the local state first to make the UI feel more responsive.

ListTile(
...
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
var note = _notes![index];

setState(() {
_notes!.remove(note);
});

_deleteNote(note);
},
),
),

We can now delete all the notes we have created.

Edit notes

We leave this part as an exercise for the reader. Try to see if you can implement the edit functionality. You can use the showNoteDialog method we created earlier to show the dialog for editing the note. If you followed along so far you have all the tools you need to implement this feature!

Give it a go, in case you need some help you can look at the (full code example).

Summary

In this tutorial, you learned how to build a simple note-taking app using the Serverpod backend framework. You started by setting up the necessary tools and environment to work with Serverpod. Then, you covered various aspects of building an app, including creating serializable objects, creating database tables, and creating API endpoints for performing CRUD operations.

You also learned how to use Serverpod's code generator and how to use the generated client library to connect the Flutter app to the server. By establishing this connection, you were able to fetch data from the server and display it in the app.

Throughout the tutorial, you gained an understanding of how Serverpod simplifies the development process by automatically handling the serialization and deserialization of objects, managing database tables, and seamlessly integrating with Flutter. You have acquired the foundational knowledge to build powerful and scalable server-side applications using Serverpod.

Congratulations on completing this tutorial. You are now equipped with the skills to build your own server-side applications using Serverpod. Happy coding!

Want to learn more? Check out some of our other tutorials, or the tutorials created by our community.