FlutterServerpod

Fullstack Flutter with Serverpod: Getting Started Guide

20250410 1347 Serverpod And Flutter Icons Remix 01jrfrb7xmef0sj037yg6md28e 1024x683

Building fullstack applications in Flutter has become remarkably streamlined with Serverpod. This Dart-based backend framework allows Flutter developers to write both client and server code in the same language, with type-safe API generation, shared models, and built-in features like authentication, file storage, and real-time communication. In this comprehensive guide, you’ll learn how to set up Serverpod, create endpoints with database integration, build shared models, and connect everything to a Flutter client.

What Is Serverpod?

Serverpod is a Dart-based backend framework designed specifically for Flutter developers. It provides:

  • Shared Models – Define once, use on client and server
  • Auto-Generated APIs – Type-safe client code generated automatically
  • Database ORM – Native PostgreSQL integration with migrations
  • Authentication – Built-in email, Google, Apple sign-in
  • File Storage – Local or cloud file handling
  • Real-Time – WebSocket support for live updates
  • Caching – Redis integration for performance

Serverpod vs Serverpod Mini

Feature Serverpod Serverpod Mini
Database PostgreSQL None (add manually)
Docker Required Yes No
ORM Full ORM None
Caching Redis None
Use Case Production apps Prototypes, simple APIs

Project Setup

Prerequisites

# Install Flutter
flutter --version

# Install Serverpod CLI
dart pub global activate serverpod_cli

# Add to PATH
export PATH="$PATH":"$HOME/.pub-cache/bin"

# Verify installation
serverpod --version

# Install Docker Desktop (for full Serverpod)
# https://docs.docker.com/get-docker/

Create a New Project

# Full Serverpod project
serverpod create my_app

# Or Mini version (no database)
serverpod create my_app --mini

This generates three packages:

my_app/
├── my_app_server/      # Server-side Dart code
├── my_app_client/      # Auto-generated client library
└── my_app_flutter/     # Flutter app

Start the Server

# Start Docker containers (PostgreSQL, Redis)
cd my_app/my_app_server
docker compose up --build --detach

# Run the server with migrations
dart bin/main.dart --apply-migrations

# Server runs on:
# - 8080: Main API
# - 8081: Insights (admin)
# - 8082: Web server

Defining Models

Models are defined in YAML files in the lib/src/protocol/ directory:

# lib/src/protocol/task.yaml
class: Task
table: tasks
fields:
  title: String
  description: String?
  completed: bool
  priority: Priority
  dueDate: DateTime?
  createdAt: DateTime
  updatedAt: DateTime?
  userId: int
indexes:
  task_user_idx:
    fields: userId
  task_completed_idx:
    fields: completed
# lib/src/protocol/priority.yaml
enum: Priority
values:
  - low
  - medium
  - high
  - urgent
# lib/src/protocol/user.yaml
class: User
table: users
fields:
  email: String
  name: String
  avatarUrl: String?
  createdAt: DateTime
indexes:
  user_email_idx:
    fields: email
    unique: true

Generate Code

# Generate Dart classes, client code, and migrations
serverpod generate

# Apply database migrations
dart bin/main.dart --apply-migrations

Creating Endpoints

// lib/src/endpoints/task_endpoint.dart
import 'package:serverpod/serverpod.dart';
import '../generated/protocol.dart';

class TaskEndpoint extends Endpoint {
  
  /// Get all tasks for the authenticated user
  Future<List<Task>> getTasks(Session session) async {
    final userId = await _requireAuth(session);
    
    return Task.db.find(
      session,
      where: (t) => t.userId.equals(userId),
      orderBy: (t) => t.createdAt,
      orderDescending: true,
    );
  }
  
  /// Get a single task by ID
  Future<Task?> getTask(Session session, int id) async {
    final userId = await _requireAuth(session);
    
    final task = await Task.db.findById(session, id);
    if (task == null || task.userId != userId) {
      return null;
    }
    return task;
  }
  
  /// Create a new task
  Future<Task> createTask(
    Session session,
    String title,
    String? description,
    Priority priority,
    DateTime? dueDate,
  ) async {
    final userId = await _requireAuth(session);
    
    final task = Task(
      title: title,
      description: description,
      completed: false,
      priority: priority,
      dueDate: dueDate,
      createdAt: DateTime.now(),
      userId: userId,
    );
    
    return await Task.db.insertRow(session, task);
  }
  
  /// Update an existing task
  Future<Task?> updateTask(
    Session session,
    int id,
    String? title,
    String? description,
    bool? completed,
    Priority? priority,
    DateTime? dueDate,
  ) async {
    final userId = await _requireAuth(session);
    
    final task = await Task.db.findById(session, id);
    if (task == null || task.userId != userId) {
      return null;
    }
    
    final updatedTask = task.copyWith(
      title: title ?? task.title,
      description: description ?? task.description,
      completed: completed ?? task.completed,
      priority: priority ?? task.priority,
      dueDate: dueDate ?? task.dueDate,
      updatedAt: DateTime.now(),
    );
    
    return await Task.db.updateRow(session, updatedTask);
  }
  
  /// Delete a task
  Future<bool> deleteTask(Session session, int id) async {
    final userId = await _requireAuth(session);
    
    final task = await Task.db.findById(session, id);
    if (task == null || task.userId != userId) {
      return false;
    }
    
    return await Task.db.deleteRow(session, task);
  }
  
  /// Get tasks filtered by completion status
  Future<List<Task>> getTasksByStatus(
    Session session,
    bool completed,
  ) async {
    final userId = await _requireAuth(session);
    
    return Task.db.find(
      session,
      where: (t) => t.userId.equals(userId) & t.completed.equals(completed),
      orderBy: (t) => t.priority,
    );
  }
  
  /// Helper to require authentication
  Future<int> _requireAuth(Session session) async {
    final authInfo = await session.authenticated;
    if (authInfo == null) {
      throw AuthenticationRequiredException();
    }
    return authInfo.userId;
  }
}

User Endpoint

// lib/src/endpoints/user_endpoint.dart
import 'package:serverpod/serverpod.dart';
import '../generated/protocol.dart';

class UserEndpoint extends Endpoint {
  
  /// Get current user profile
  Future<User?> getCurrentUser(Session session) async {
    final authInfo = await session.authenticated;
    if (authInfo == null) return null;
    
    return User.db.findById(session, authInfo.userId);
  }
  
  /// Update user profile
  Future<User?> updateProfile(
    Session session,
    String? name,
    String? avatarUrl,
  ) async {
    final authInfo = await session.authenticated;
    if (authInfo == null) return null;
    
    final user = await User.db.findById(session, authInfo.userId);
    if (user == null) return null;
    
    final updatedUser = user.copyWith(
      name: name ?? user.name,
      avatarUrl: avatarUrl ?? user.avatarUrl,
    );
    
    return await User.db.updateRow(session, updatedUser);
  }
  
  /// Get task statistics for user
  Future<TaskStats> getStats(Session session) async {
    final authInfo = await session.authenticated;
    if (authInfo == null) {
      throw AuthenticationRequiredException();
    }
    
    final total = await Task.db.count(
      session,
      where: (t) => t.userId.equals(authInfo.userId),
    );
    
    final completed = await Task.db.count(
      session,
      where: (t) => t.userId.equals(authInfo.userId) & t.completed.equals(true),
    );
    
    return TaskStats(
      totalTasks: total,
      completedTasks: completed,
      pendingTasks: total - completed,
    );
  }
}

Flutter Client Integration

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:my_app_client/my_app_client.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';

late Client client;

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  client = Client(
    'http://localhost:8080/',
    authenticationKeyManager: FlutterAuthenticationKeyManager(),
  )
    ..connectivityMonitor = FlutterConnectivityMonitor();
  
  runApp(const MyApp());
}
// lib/services/task_service.dart
import '../main.dart';
import 'package:my_app_client/my_app_client.dart';

class TaskService {
  
  Future<List<Task>> getTasks() async {
    try {
      return await client.task.getTasks();
    } catch (e) {
      throw Exception('Failed to load tasks: $e');
    }
  }
  
  Future<Task> createTask({
    required String title,
    String? description,
    required Priority priority,
    DateTime? dueDate,
  }) async {
    return await client.task.createTask(
      title,
      description,
      priority,
      dueDate,
    );
  }
  
  Future<Task?> updateTask({
    required int id,
    String? title,
    String? description,
    bool? completed,
    Priority? priority,
    DateTime? dueDate,
  }) async {
    return await client.task.updateTask(
      id,
      title,
      description,
      completed,
      priority,
      dueDate,
    );
  }
  
  Future<bool> deleteTask(int id) async {
    return await client.task.deleteTask(id);
  }
  
  Future<List<Task>> getPendingTasks() async {
    return await client.task.getTasksByStatus(false);
  }
  
  Future<List<Task>> getCompletedTasks() async {
    return await client.task.getTasksByStatus(true);
  }
}

Task List Screen

// lib/screens/task_list_screen.dart
import 'package:flutter/material.dart';
import 'package:my_app_client/my_app_client.dart';
import '../services/task_service.dart';

class TaskListScreen extends StatefulWidget {
  const TaskListScreen({super.key});

  @override
  State<TaskListScreen> createState() => _TaskListScreenState();
}

class _TaskListScreenState extends State<TaskListScreen> {
  final _taskService = TaskService();
  List<Task> _tasks = [];
  bool _isLoading = true;
  String? _error;

  @override
  void initState() {
    super.initState();
    _loadTasks();
  }

  Future<void> _loadTasks() async {
    setState(() => _isLoading = true);
    try {
      _tasks = await _taskService.getTasks();
      _error = null;
    } catch (e) {
      _error = e.toString();
    }
    setState(() => _isLoading = false);
  }

  Future<void> _toggleComplete(Task task) async {
    final updated = await _taskService.updateTask(
      id: task.id!,
      completed: !task.completed,
    );
    if (updated != null) {
      _loadTasks();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Tasks'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: _loadTasks,
          ),
        ],
      ),
      body: _buildBody(),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showCreateDialog(context),
        child: const Icon(Icons.add),
      ),
    );
  }

  Widget _buildBody() {
    if (_isLoading) {
      return const Center(child: CircularProgressIndicator());
    }
    if (_error != null) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Error: $_error'),
            ElevatedButton(onPressed: _loadTasks, child: const Text('Retry')),
          ],
        ),
      );
    }
    if (_tasks.isEmpty) {
      return const Center(child: Text('No tasks yet. Create one!'));
    }
    return RefreshIndicator(
      onRefresh: _loadTasks,
      child: ListView.builder(
        itemCount: _tasks.length,
        itemBuilder: (context, index) => _buildTaskTile(_tasks[index]),
      ),
    );
  }

  Widget _buildTaskTile(Task task) {
    return ListTile(
      leading: Checkbox(
        value: task.completed,
        onChanged: (_) => _toggleComplete(task),
      ),
      title: Text(
        task.title,
        style: TextStyle(
          decoration: task.completed ? TextDecoration.lineThrough : null,
        ),
      ),
      subtitle: Text(task.priority.name.toUpperCase()),
      trailing: IconButton(
        icon: const Icon(Icons.delete),
        onPressed: () async {
          await _taskService.deleteTask(task.id!);
          _loadTasks();
        },
      ),
    );
  }

  void _showCreateDialog(BuildContext context) {
    // Show dialog to create new task
  }
}

Common Mistakes to Avoid

1. Forgetting to Generate Code

# Wrong - code not regenerated after model changes
# Client will have outdated types

# Correct - always regenerate after changes
serverpod generate

2. Not Applying Migrations

# Wrong - database schema out of sync
dart bin/main.dart

# Correct - apply migrations
dart bin/main.dart --apply-migrations

3. Exposing Sensitive Fields

# Wrong - password in model
class: User
fields:
  email: String
  passwordHash: String  # Exposed to client!

# Correct - use serverOnly scope
class: User
fields:
  email: String
  passwordHash: String, scope=serverOnly

4. Missing Authentication Checks

// Wrong - no auth check
Future<List<Task>> getTasks(Session session) async {
  return Task.db.find(session);  // Returns ALL tasks!
}

// Correct - verify user
Future<List<Task>> getTasks(Session session) async {
  final auth = await session.authenticated;
  if (auth == null) throw AuthenticationRequiredException();
  return Task.db.find(session, where: (t) => t.userId.equals(auth.userId));
}

Final Thoughts

Serverpod empowers Flutter developers to build fullstack applications entirely in Dart. The type-safe API generation, shared models, and built-in features like authentication and file storage significantly reduce boilerplate and potential bugs. Start with Serverpod Mini for prototypes, then upgrade to the full version when you need database persistence. The investment in learning Serverpod pays off with faster development cycles and a more cohesive codebase.

For more Flutter development patterns, read Flutter Login/Register Flow with Riverpod and Flutter Build Once Run Anywhere. For official documentation and advanced features, visit the Serverpod Documentation.

1 Comment

Leave a Comment