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
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.
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.
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.