Introduction
Creating an application that requires a strong backend and a slick mobile frontend can be challenging when you are a mobile developer who has minimal backend development knowledge. Also, setting up a backend server, database, securing, and scaling can consume much of the productive time used in development. This is where Strapi comes in, providing headless backend service and helping you spin up a backend faster, allowing focus on building and launching apps faster.
In this tutorial, I'll show you how to build a full-featured mobile CRUD application with Flutter and Strapi 5.
Prerequisites
Before we jump in, ensure you've installed the following tool on your machine:
- Node.js (v18 or later)
- Flutter SDK (latest version)
- VS Code or whatever code editor you like
- Some experience with Flutter and REST APIs
App Demo
Below is a demo of the project you'll build throughout this tutorial.
Github Code
The full project code is available on my Github repository. The backend code is in the main
branch, while the Flutter is in the mobile-app
branch.
What's New in Strapi 5
Strapi 5 was just launched recently, and trust me, it's packed with some seriously cool upgrades. Let me break down the good stuff:
Enhanced Content Organisation
Do you recall how complicated it was to manage drafts earlier? Strapi 5 gives you two neat tabs, one for what you are working on and the other for what's live, no more content clutter. Also, the new history feature lets you return to any previous version. It's like ctrl+z for your whole content system. You can see exactly how your content will look on your site before it goes live—no more surprises after publishing.
Technical Improvements
The team has completely rewritten the core API with a new Document Service. It is so much faster, easier to use, and less messy than the old Entitiy Service. REST and GraphQL API both had a facelift—responses are more refined and easier to understand. For those who work with GraphQL, you'll love the new Relay-style query support. The new Strapi SDK makes it easier for developers to build custom plugins.
Improved Workflow
They've also introduced an AI chatbot that will recall the previous questions you asked, faster navigation in the docs with new tags that enable you to skip straight to the location you were interested in, and tons of guides and examples so you can build better stuff.
You can learn more about what is new for developers in Strapi 5: top 10 changes.
Setting Up the Backend with Strapi 5
Let's get your backend up and running. Open your terminal and run the following command to scaffold a new Strapi 5 project.
npx create-strapi-app@latest my-project --quickstart
cd my-project
The above command will prompt you to select whether you want to Create a free account on Strapi Cloudor skip it for now. Then, the command will install the required project dependencies.
Creating the Content-Type
With your Strapi project created, Sign in to your admin panel.
From the admin panel, click on the *Create your first content type * button to create a new content type called **Product. "
Then add the following fields and click on the Save button:
-
name
(Text): Name of products on your app. -
Description
(Text): Description for each product. -
price
(Number): Price for products. -
seller
(One to Many relationship between User and Products):
Configure API Permissions
To allow your users access to the products from your Flutter application app, you need to configure your Strapi project permissions. Go to S*ettings → Users & Permissions plugin → Roles → Public* and enable the following permissions for Product:
-
find
: Access to see all the products. -
findOne
: Access to view individual products. -
create
: Access to create a new product. -
update
: Access update products they created. -
delete
: Access to delete products they created.
Next, locate the Users-permissions plugin, give the User collection, and give the Public role the following:
-
find
: Access to see who the seller of the product is. -
findOne
: Access to the individual product seller's details.
We are done with setting up your backend. Without any server setup, you created a fully functional CRUD application all of this without writing a single line of boilerplate code.
Now, you can focus on building your frontend, knowing that your backend is secure, scalable, and ready for production.
Testing the CRUD Endpoints
Let's test the backend using Postman to ensure everything works as expected. Send a GET
request to the /api/products
endpoint to get all products.
Repeat the steps to test the Create(POST
request), Update(PUT
request), and Delete
endpoints.
Now, you are set to start integrating your Strapi backend with your Flutter application.
Building the Flutter Application
With your Strapi application fully configured, let's create the Flutter application. First, create a new Flutter project with the following command:
flutter create flutter_strapi_crud
cd flutter_strapi_crud
Adding Dependencies
Once the project is successfully created, update your pubspec.yaml
file to add the dio package. The dio
package will allow you to make HTTP requests to your Strapi backend:
dependencies:
flutter:
sdk: flutter
dio: ^5.4.0
Now run the command flutter pub get
to install the package new package you added.
Creating the Product Model
Next, create lib/models/product.dart
file and add the code snippet below:
class Product {
final int? id;
final String? documentId;
final String name;
final String description;
final double price;
final DateTime? createdAt;
final DateTime? updatedAt;
final DateTime? publishedAt;
Product({
this.id,
this.documentId,
required this.name,
required this.description,
required this.price,
this.createdAt,
this.updatedAt,
this.publishedAt,
});
factory Product.fromJson(Map<String, dynamic> json) {
if (json['attributes'] != null) {
final attributes = json['attributes'];
return Product(
id: json['id'],
documentId: attributes['documentId'],
name: attributes['name'],
description: attributes['description'],
price: double.parse(attributes['price'].toString()),
createdAt: DateTime.parse(attributes['createdAt']),
updatedAt: DateTime.parse(attributes['updatedAt']),
publishedAt: DateTime.parse(attributes['publishedAt']),
);
}
return Product(
id: int.parse(json['id'].toString()),
documentId: json['documentId'],
name: json['name'],
description: json['description'],
price: double.parse(json['price'].toString()),
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null,
publishedAt: json['publishedAt'] != null ? DateTime.parse(json['publishedAt']) : null,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'description': description,
'price': price,
};
}
}
The above model code defines our Product
class with JSON serialization support. The @JsonSerializable()
annotation and part
directive tell the code generator to create the corresponding product.g.dart
file, which will contain the implementation of the fromJson
and toJson
methods.
Now, generate this file by running the following command in your terminal:
flutter pub run build_runner build
This will create the necessary serialization code for converting JSON data to Product objects and vice versa. If you change the model later, you must run this command again to regenerate the serialization code.
For faster development, you can use
flutter pub run build_runner watch
to automatically regenerate the code whenever you make changes.
Implementing the API Service
Now, let's create our API service to handle all our products' CRUD (Create, Read, Update, Delete) operations. This service will use Dio to make HTTP requests to our Strapi backend. Create lib/services/product_service.dart
file and add the code snippet below:
import 'package:dio/dio.dart';
import 'package:flutter_strapi_crud/models/product.dart';
class ProductService {
final Dio _dio;
ProductService()
: _dio = Dio(BaseOptions(
baseUrl: 'http://localhost:1337/api',
headers: {
'Content-Type': 'application/json',
},
));
Future<List<Product>> getProducts() async {
try {
final response = await _dio.get('/products');
final List<dynamic> data = response.data['data'];
return data.map((item) => Product.fromJson(item)).toList();
} catch (e) {
throw 'Failed to load products: ${e.toString()}';
}
}
Future<Product> createProduct(Product product) async {
try {
final response = await _dio.post('/products', data: {
'data': {
'name': product.name,
'description': product.description,
'price': product.price,
}
});
return Product.fromJson(response.data['data']);
} catch (e) {
throw 'Failed to create product: ${e.toString()}';
}
}
Future<Product> updateProduct(String id, Product product) async {
try {
print(id);
final response = await _dio.put('/products/$id', data: {
'data': {
'name': product.name,
'description': product.description,
'price': product.price,
}
});
return Product.fromJson(response.data['data']);
} catch (e) {
throw 'Failed to update product: ${e.toString()}';
}
}
Future<void> deleteProduct(String id) async {
try {
await _dio.delete('/products/$id');
} catch (e) {
throw 'Failed to delete product: ${e.toString()}';
}
}
}
The above code handles the CRUD (Create, Read, Update, Delete) operations using the Dio HTTP client for our Product
model. The ProductService
class is created with a baseURL
set to the Android emulator IP address (10.0.2.2)
and default headers set to accept the content type.
The service provides four main methods: getProducts()
, which retrieves and deserializes products into a List<Product>
createProduct()
, which is a POST request for creating the products using the data entered updateProduct()
which is a PUT request for updating existing products deleteProduct()
which is a delete request for removing the products.
Creating the UI
Now, we need to create the main product screen, which will be responsible for showing the products and also for creating, updating, and even deleting products.
This screen will utilize our ProductService
to communicate with the Strapi backend while offering easy functionality for the user to manage products. It supports form handling with TextEditingControllers
, create/edit
dialogs, and the swipe to-delete feature. Create lib/screens/product_screen.dart
file and add the code snippet below:
import 'package:flutter/material.dart';
import 'package:flutter_strapi_crud/models/product.dart';
import 'package:flutter_strapi_crud/services/product_service.dart';
class ProductsScreen extends StatefulWidget {
@override
_ProductsScreenState createState() => _ProductsScreenState();
}
class _ProductsScreenState extends State<ProductsScreen> {
final ProductService _productService = ProductService();
late Future<List<Product>> _productsFuture;
// Controllers for the form
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _priceController = TextEditingController();
@override
void initState() {
super.initState();
_refreshProducts();
}
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_priceController.dispose();
super.dispose();
}
Future<void> _refreshProducts() async {
setState(() {
_productsFuture = _productService.getProducts();
});
}
void _showProductForm({Product? product}) {
// If editing, populate the controllers
if (product != null) {
_nameController.text = product.name;
_descriptionController.text = product.description;
_priceController.text = product.price.toString();
} else {
// Clear the controllers if creating new
_nameController.clear();
_descriptionController.clear();
_priceController.clear();
}
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(product == null ? 'Create Product' : 'Edit Product'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _nameController,
decoration: InputDecoration(labelText: 'Name'),
),
TextField(
controller: _descriptionController,
decoration: InputDecoration(labelText: 'Description'),
maxLines: 3,
),
TextField(
controller: _priceController,
decoration: InputDecoration(labelText: 'Price'),
keyboardType: TextInputType.number,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'),
),
TextButton(
onPressed: () async {
try {
final newProduct = Product(
name: _nameController.text,
description: _descriptionController.text,
price: double.parse(_priceController.text),
);
if (product == null) {
await _productService.createProduct(newProduct);
} else {
await _productService.updateProduct(
product.documentId!, newProduct);
}
Navigator.pop(context);
_refreshProducts();
_showSnackBar(
'Product ${product == null ? 'created' : 'updated'} successfully');
} catch (e) {
_showSnackBar(e.toString());
}
},
child: Text(product == null ? 'Create' : 'Update'),
),
],
),
);
}
Future<void> _deleteProduct(Product product) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Delete Product'),
content: Text('Are you sure you want to delete ${product.name}?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'),
),
],
),
);
if (confirm == true) {
try {
await _productService.deleteProduct(product.documentId!);
_refreshProducts();
_showSnackBar('Product deleted successfully');
} catch (e) {
_showSnackBar(e.toString());
}
}
}
void _showSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Products'),
),
body: RefreshIndicator(
onRefresh: _refreshProducts,
child: FutureBuilder<List<Product>>(
future: _productsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
final products = snapshot.data!;
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return Dismissible(
key: Key(product.id.toString()),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: EdgeInsets.only(right: 16),
child: Icon(Icons.delete, color: Colors.white),
),
onDismissed: (_) => _deleteProduct(product),
child: ListTile(
title: Text(product.name),
subtitle: Text(
'${product.description}\nPrice: \$${product.price.toStringAsFixed(2)}',
),
trailing: Row(
mainAxisSize: MainAxisSize
.min, // Makes the Row take minimum space
children: [
IconButton(
icon: Icon(Icons.edit),
onPressed: () => _showProductForm(product: product),
),
IconButton(
icon: Icon(Icons.delete),
onPressed: () => _deleteProduct(product),
),
],
),
));
},
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showProductForm(),
child: Icon(Icons.add),
),
);
}
}
The following UI code creates a comprehensive product management screen in Flutter material design. The ProductsScreen
is a StatefulWidget
that stores the product data and employs the FutureBuilder
for the asynchronous computations.
For form input management, create/edit
forms, and delete confirmations, the widget uses TextEditingControllers
and AlertDialogs
; for delete functionality, it uses ListView.builder
with Dismissible
widgets. It uses RefreshIndicator
for pull-to-refresh
action, loading states with CircularProgressIndicator
and user feedback with SnackBar
notifications.
Now update your lib.main.dart
file to render the ProductsScreen
as the home screen:
import 'package:flutter_strapi_crud/screens/product_screen.dart';
//...
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
// Application name
title: 'Flutter Hello World',
// Application theme data, you can set the colors for the application as
// you want
theme: ThemeData(
// useMaterial3: false,
primarySwatch: Colors.blue,
),
// A widget which will be started on application startup
home: ProductsScreen(),
);
}
}
//...
Testing the Application
Now, let's test your Flutter CRUD application. Start your Flutter application by running the following commands:
flutter run
Then, create a new product using the floating action button.
Then, view the list of products on the main screen.
Next, Edit a product by tapping the edit icon.
Lastly, delete a product by clicking the delete icon.
Conclusion
Glad you made it to the end of this tutorial. Throughout this tutorial, you've learned how to create a CRUD application using Strapi 5 and Flutter. You started by creating a new Strapi 5 project, creating a collection, and defining permissions. Then, you went further to create a new Flutter application, installed dependencies and created a model, service, and product screens to perform CRUD operation by sending API requests to the endpoints generated with Strapi.
To learn more about building a backend with Strapi 5, visit the Strapi documentation and the Flutter official documentation if you which to add more features to your application. Happy coding!
Top comments (1)
Great ♥