Stream's Activity Feed V3 SDK enables teams of all sizes to build scalable activity feeds. This SDK is designed to enable you to get a feed application up and running quickly and efficiently while supporting customization for complex use cases.
In this tutorial, we will use Stream's Activity Feed V3 SDK for Flutter to:
- Set up a simple activity feed application and connect it to Stream's Activity Feed V3 SDK.
- Create user and timeline feeds.
- Add activities, reactions and comments.
- Explore new content with "For you" feed.
Here is a quick visual overview of the application we're building:

This page works for both humans and AI coding agents: every step is a file operation or a shell command, every step has a verification checkpoint, and the few steps that need a human are explicitly marked.
This tutorial has been tested with the following versions:
- Flutter 3.44.2 with Dart 3.12.2
- Stream Feeds Flutter SDK
Choose your path
This tutorial has two paths. In Path A you let your AI agent build the app for you. In Path B you build it yourself, step by step, starting from a fresh Flutter project. Both paths end with a working activity feed app - Path A's is shaped by what you ask for, Path B's by the steps below - so pick one instead of working through both.
Path A - Let your AI agent build it (recommended)
Install the Stream CLI once, then add the skills. This gives Claude Code, Cursor, or Codex the Flutter integration patterns and current SDK APIs, so it builds against real docs instead of stale training data.
123456789# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder) for your agent. Pick the # target: --universal for Cursor, Codex, and other AGENTS-ecosystem tools, # or --claude for Claude Code. The Flutter pack installs on demand the first # time it's needed, or add it explicitly: getstream skills --universal getstream skills stream-flutter --universal
Then ask your agent:
1234/stream-flutter Build a Flutter activity feed app: connect a StreamFeedsClient, create user and timeline feeds, render the timeline with an activity composer, and add reactions, comments, and follow/unfollow. Provision credentials with the CLI: create or select my org and app, then mint a token. If you can't mint one, ask me to provide fallback credentials.
Only the first line matters - /stream-flutter Build a Flutter activity feed app is enough to get going. The rest just steers the result: ask for a different tab split, your own theme, custom activity cards, or a notification feed instead of the Explore tab. Describe the app you want rather than the app the tutorial builds.
If the CLI can't mint credentials, provide the fallback ones from Step 1, Option 2 when the agent asks.
Where you come in. The agent handles credentials and code itself. It stops for you twice: when getstream init opens your browser to sign in, where a brand-new account creates your organization and first app in that same flow (if you already have an org or app, it lets you pick them instead), and when the app first runs on a simulator or emulator - launching one is a human call.
Want to get the sign-in out of the way first? Run getstream init before you prompt the agent - it picks up the initialized project from there.
Checkpoint: run the app if the agent hasn't (flutter run), then open it on your simulator or emulator. You should see your timeline and be able to post an activity.
Skim Important Building Blocks if you want to understand what the agent wrote before you extend it.
Path B - Build it manually
Choose this path if you'd rather write the code yourself, or you want to understand what the SDK is doing before you hand it to an agent. Some code blocks are complete files, others are edits to a file you already have - the text before each block says which.
For AI assistants reading this page: append
.mdto any Stream docs URL for a clean Markdown version. A condensed index for the Flutter Feeds SDK is athttps://getstream.io/activity-feeds/docs/flutter/llms.txt. Prefer these over parsing HTML.
Important Building Blocks
Five pieces, one mental model:
- Activities and feeds - an activity is the atomic unit of content (a post, a photo, a poll, or any custom type you define), and a feed is a collection of activities.
- Feed groups - templates that decide how a feed behaves. Every app starts with the built-in
user,timeline,foryou,notification, andstory/storiesfeed groups. This tutorial uses the first three:- User Feed: A feed that contains all activities (posts) created by a specific user. Each user has their own user feed (e.g.,
user:alice). - Timeline Feed: A feed that contains activities from all the feeds that you follow. When you follow someone's user feed, their activities automatically appear in your timeline feed (this concept is called fan-out).
- Follow Relationship: When you follow a user's feed, your timeline feed subscribes to their user feed. This means new activities from followed users automatically appear in your timeline.
- User Feed: A feed that contains all activities (posts) created by a specific user. Each user has their own user feed (e.g.,
StreamFeedsClient- the low-level client. It is bound to one user for its lifetime (theUserandtokenProviderare fixed at construction). Built once, at app launch, andconnect()ed before any feed call.FeedandActivity- handles to a single feed or a single activity, obtained withclient.feedFromId(FeedId.user(userId))andclient.activity(activityId: ..., fid: ...). CallgetOrCreate()orget()to load them, anddispose()when you no longer need them.- Reactive state -
feed.stategives you a snapshot (activities,following,canLoadMoreActivities, ...), whilefeed.notifier(withStateNotifierBuilderfromflutter_state_notifier) orfeed.streamkeep your widgets in sync as real-time events arrive.
One difference from Stream's other SDKs: the Activity Feeds SDKs don't ship UI components. The SDK owns the client, the state, and the real-time updates; you own the views. That's why the steps below build small Flutter widgets of their own instead of dropping in ready-made ones.
You'll connect a client, create feeds, render a timeline, then layer on posting, discovery, follows, reactions, comments, and images.
Prerequisites
To follow the tutorial make sure you've installed Flutter and your IDE of choice. The tutorial is focused on Android and/or iOS, but can run on other platforms as well.
- Flutter 3.27.4 or later on the stable channel, with Dart 3.6.2 or later - the minimum the
stream_feedspackage supports - Android Studio or VS Code
- An iOS Simulator (via Xcode) or an Android emulator (via Android Studio)
Checkpoint: both commands succeed.
12flutter --version # 3.27.4 or later flutter doctor # no blocking issues for your target platform
Step 0 - Set up the project
As a first step, you need to create a new Flutter project and install the dependencies we'll use in the tutorial.
If you wish to see the finished source code, it's the latest commit in the stream-feeds-flutter-tutorial repository.
123flutter create --empty stream_feeds_flutter_tutorial cd stream_feeds_flutter_tutorial flutter pub add stream_feeds image_picker flutter_state_notifier
Agents: detect the project shape first (
find . -maxdepth 2 -name "pubspec.yaml"). If a Flutter project already exists, work in place instead of scaffolding a second one. If there is no project, stop and ask the human to create it - don't scaffold it yourself.
In the pubspec.yaml file you should see the stream_feeds, image_picker and flutter_state_notifier dependencies:
1234dependencies: stream_feeds: ^latest image_picker: ^latest flutter_state_notifier: ^latest
Checkpoint:
12flutter pub get # resolves stream_feeds, image_picker, flutter_state_notifier flutter analyze # No issues found!
Step 1 - Get your credentials
The code in Step 2 needs a few values:
API_KEY- an API key that is used to identify your Stream application by our serversidandtoken- authorization information of the current username- optional, used as a display name of the current user
There are two ways to get them. Pick one, then finish with the "Store the credentials" step.
Option 1 - Your own Stream app, via the Stream CLI
The getstream CLI provisions all of it in one flow. Run these from the stream_feeds_flutter_tutorial directory you just created.
1. Install the CLI (skip if you did this in Path A):
1curl -fsSL https://getstream.io/cli.sh | bash
2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials. New to Stream? The sign-in flow creates your organization and your first app as you go. Already have an org or an app? It lets you pick them. The tutorial instructions assume an empty application so we suggest creating a new one:
1getstream init
Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish signing in before continuing. It's required first - env, token, and api commands fail with "stream project is not initialized" until it runs.
3. Write your API key into the Flutter project. This writes the public API key to dart_defines.json (which it adds to .gitignore for you). The API secret is never printed or written into the app.
1getstream env --target flutter
4. Mint a user token for a user in your app (never expiring by default; add a TTL for production-like testing):
12getstream token alice getstream token alice --ttl 1d
Checkpoint: dart_defines.json contains a STREAM_API_KEY entry, and you have a token printed by the CLI and the user id you minted it for, all belonging to your own app.
Learn more in the Tokens & Authentication documentation.
Option 2 - Pre-filled tutorial credentials, no account
Want to skip account setup entirely? To make the tutorial as easy as possible, we generated credentials for you to pick up and use. The block in "Store the credentials" below is already pre-filled with working credentials against Stream's shared, pre-seeded feeds tutorial environment - copy it as-is and it runs.
Swap in your own credentials from Option 1 before building anything real. Tutorial credentials are shared and short lived.
Store the credentials
To start using credentials, replace the contents of the main.dart file. You can remove everything that's already there and add the following:
1234const String apiKey = 'REPLACE_WITH_API_KEY'; const String userId = 'REPLACE_WITH_USER_ID'; const String userToken = 'REPLACE_WITH_TOKEN'; const String name = 'REPLACE_WITH_USER_NAME';
On Option 1, paste the values the CLI gave you: the API key from dart_defines.json, and the user id and token from getstream token. If you'd rather not hardcode the key, read it from the define instead and launch with flutter run --dart-define-from-file=dart_defines.json:
1const String apiKey = String.fromEnvironment('STREAM_API_KEY');
Security Note: In production applications, never expose your API secret or generate tokens on the client side. Tokens should always be generated on your backend server to ensure security. The credentials in this tutorial are for development purposes only.
Checkpoint: main.dart holds non-placeholder values, and the API key and token belong to the same Stream app. The app won't run yet - main.dart is just these constants until Step 2 adds a main() function.
Step 2 - Connect the user
Let's create and connect the demo user to the Stream API.
To achieve this, we're creating a StreamFeedsClient and connect it while starting the app.
12345678910111213141516171819202122232425262728293031import 'package:flutter/material.dart'; import 'package:stream_feeds/stream_feeds.dart'; const String apiKey = 'REPLACE_WITH_API_KEY'; const String userId = 'REPLACE_WITH_USER_ID'; const String userToken = 'REPLACE_WITH_TOKEN'; const String name = 'REPLACE_WITH_USER_NAME'; final client = StreamFeedsClient( apiKey: apiKey, user: User(id: userId, name: name), tokenProvider: TokenProvider.static(UserToken(userToken)), ); Future<void> main() async { runApp(const Center(child: CircularProgressIndicator())); await client.connect(); runApp(MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); Widget build(BuildContext context) { return MaterialApp( title: 'Stream Feeds Tutorial', home: const Placeholder(), ); } }
For simplicity, the tutorial doesn't handle errors. In a real application, you should always make sure to handle errors.
Checkpoint: the app launches and shows the placeholder screen instead of a stuck spinner. Still spinning? client.connect() failed - check the console for an auth error, which usually means the API key and token belong to different apps.
Step 3 - Create feeds
In this step we're creating a few feeds using built-in feed groups. The core concepts - user feeds, timeline feeds, and the follow relationship between them - are covered in Important Building Blocks.
Let's see what the concept looks like in code (no need to add this to your app yet):
1234567// Using user id for the feed id, but you can use any id you want to final userFeed = client.feedFromId(FeedId.user(userId)); await userFeed.getOrCreate(); // This is our timeline feed where we want to see posts from people we follow final timelineFeed = client.feedFromId(FeedId.timeline(userId)); await timelineFeed.getOrCreate();
Creating a
Feedobject sets up watching by default, so the feed receives real-time updates without any extra flag. To read a feed without subscribing, build it from a query instead:client.feedFromQuery(FeedQuery(fid: fid, watch: false)).
To ensure our own posts are part of our timeline, we need to set up the follow relationship:
12345678910// You typically create these relationships on your server-side, we do this here for simplicity final followsSelf = userFeed.state.feed?.ownFollows?.any( (follow) => follow.sourceFeed.fid == timelineFeed.fid, ) ?? false; if (!followsSelf) { await timelineFeed.follow(targetFid: userFeed.fid); }
The two main screens of our app, Home and Explore, are going to be part of MyHomePage, so let's add code to create the feeds and dispose them there:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283class MyApp extends StatelessWidget { const MyApp({super.key}); Widget build(BuildContext context) { return MaterialApp( title: 'Stream Feeds Tutorial', home: const MyHomePage(), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key}); State<MyHomePage> createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { bool isLoading = true; late final Feed userFeed; late final Feed timelineFeed; late final Feed exploreFeed; void initState() { super.initState(); _loadFeeds(); } Future<void> _loadFeeds() async { userFeed = client.feedFromId(FeedId.user(userId)); await userFeed.getOrCreate(); timelineFeed = client.feedFromId(FeedId.timeline(userId)); await timelineFeed.getOrCreate(); final followsSelf = userFeed.state.feed?.ownFollows?.any( (follow) => follow.sourceFeed.fid == timelineFeed.fid, ) ?? false; if (!followsSelf) { await timelineFeed.follow(targetFid: userFeed.fid); } setState(() { isLoading = false; }); } void dispose() { userFeed.dispose(); timelineFeed.dispose(); super.dispose(); } int _index = 0; Widget build(BuildContext context) { if (isLoading) { return Center(child: CircularProgressIndicator()); } return Scaffold( appBar: AppBar(title: Text('Stream Feeds Tutorial')), body: _index == 0 ? Placeholder() : Placeholder(), bottomNavigationBar: BottomNavigationBar( items: [ BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Explore'), ], currentIndex: _index, onTap: (index) => setState(() => _index = index), ), ); } }
Checkpoint: the app renders the bottom navigation bar with Home and Explore tabs. Seeing a "feed group doesn't exist" error? Feed group ids are case-sensitive and must match the groups on your dashboard.
Step 4 - Activity list
Now that we created feeds, we can create UI components to display the activities. To achieve this we're creating an ActivityItem component.
For now, the
ActivityItemdisplays only the most basic activity information (for exampleactivity.text) and parameters that will be relevant in a bit, when we'll extend it with more features. We'll create new files:activity_item.dartandactivity_list_view.dartand add the following code:
1234567891011121314151617181920212223242526import 'package:flutter/material.dart'; import 'package:stream_feeds/stream_feeds.dart'; class ActivityItem extends StatelessWidget { const ActivityItem({ super.key, required this.currentUserId, required this.activity, required this.onFollow, required this.onUnfollow, required this.onCommentPressed, required this.onLikePressed, }); final String currentUserId; final ActivityData activity; final ValueSetter<FeedId> onFollow; final ValueSetter<FeedId> onUnfollow; final VoidCallback onCommentPressed; final VoidCallback onLikePressed; Widget build(BuildContext context) { return Text(activity.text ?? ''); } }
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950import 'package:flutter/material.dart'; import 'package:flutter_state_notifier/flutter_state_notifier.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'activity_item.dart'; class ActivityListView extends StatelessWidget { const ActivityListView({ super.key, required this.client, required this.feed, required this.onFollow, required this.onUnfollow, }); final StreamFeedsClient client; final Feed feed; final ValueSetter<FeedId> onFollow; final ValueSetter<FeedId> onUnfollow; Widget build(BuildContext context) { return StateNotifierBuilder( stateNotifier: feed.notifier, builder: (context, state, child) { if (state.activities.isEmpty) { return Center(child: const Text('No posts yet')); } return ListView( children: [ ...state.activities.map( (activity) => ActivityItem( currentUserId: client.user.id, activity: activity, onFollow: onFollow, onUnfollow: onUnfollow, onCommentPressed: () {}, onLikePressed: () {}, ), ), if (feed.state.canLoadMoreActivities) TextButton( onPressed: () => feed.queryMoreActivities(), child: Text('Load more'), ), ], ); }, ); } }
1import 'activity_list_view.dart';
123456789101112131415161718192021222324252627282930313233class _MyHomePageState extends State<MyHomePage> { /// (... initState and dispose methods ...) Widget build(BuildContext context) { if (isLoading) { return Center(child: CircularProgressIndicator()); } return Scaffold( appBar: AppBar(title: Text('Stream Feeds Tutorial')), body: _index == 0 ? ActivityListView( feed: timelineFeed, onFollow: onFollow, onUnfollow: onUnfollow, client: client, ) : Placeholder(), bottomNavigationBar: BottomNavigationBar( items: [ BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Explore'), ], currentIndex: _index, onTap: (index) => setState(() => _index = index), ), ); } void onFollow(FeedId feed) {} void onUnfollow(FeedId feed) {} }
The activity list is currently empty. We'll change that in the next step. Before doing that, let's recap what we did in this step:
- We created an instance of
StreamFeedsClientfor our user (1 instance maps to 1 user) - We used the client to create
userFeedandtimelineFeed, representing the feeds of the user's own activities and followed users' activities respectively - We created an
ActivityItemcomponent and used it to display the activities from the timeline feed's statefeed.notifieris aStateNotifierthat automatically updates when activities are updated
Checkpoint: the Home tab renders "No posts yet" instead of a placeholder. Still on the loading spinner? _loadFeeds didn't finish - search the console for ClientException to see which getOrCreate() failed.
Step 5 - Activity composer
Let's add an ActivityComposer component and add it with a Column to the first tab of MyHomePage:
As mentioned previously: users post on their
userfeed and their posts automatically appear in theirtimelinefeed via follow relationship.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768import 'package:flutter/material.dart'; import 'package:stream_feeds/stream_feeds.dart'; class ActivityComposer extends StatefulWidget { const ActivityComposer({super.key, required this.userFeed}); final Feed userFeed; State<ActivityComposer> createState() => _ActivityComposerState(); } class _ActivityComposerState extends State<ActivityComposer> { final TextEditingController _controller = TextEditingController(); bool _hasText = false; bool _isSending = false; void initState() { super.initState(); _controller.addListener(() { final hasText = _controller.text.trim().isNotEmpty; if (_hasText != hasText) { setState(() => _hasText = hasText); } }); } void dispose() { _controller.dispose(); super.dispose(); } Widget build(BuildContext context) { return Padding( padding: EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _controller, decoration: InputDecoration( hintText: 'What is happening?', border: OutlineInputBorder(), ), ), SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ ElevatedButton( onPressed: _hasText && !_isSending ? _createActivity : null, child: Text('Post'), ), ], ), ], ), ); } Future<void> _createActivity() async { // TODO: implement posting logic } }
1import 'activity_composer.dart';
123456789101112131415161718192021222324252627282930313233Widget build(BuildContext context) { if (isLoading) { return Center(child: CircularProgressIndicator()); } return Scaffold( appBar: AppBar(title: Text('Stream Feeds Tutorial')), body: _index == 0 ? Column( children: [ ActivityComposer(userFeed: userFeed), Expanded( child: ActivityListView( client: client, feed: timelineFeed, onFollow: onFollow, onUnfollow: onUnfollow, ), ), ], ) : Placeholder(), bottomNavigationBar: BottomNavigationBar( items: [ BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Explore'), ], currentIndex: _index, onTap: (index) => setState(() => _index = index), ), ); }
With the UI ready, we can implement the posting logic in the ActivityComposer. To do that, we just need to replace the _createActivity function stub with the actual implementation:
1234567891011121314Future<void> _createActivity() async { setState(() => _isSending = true); await widget.userFeed.addActivity( request: FeedAddActivityRequest( feeds: [widget.userFeed.fid.toString()], type: 'post', text: _controller.text, ), ); _controller.clear(); setState(() { _isSending = false; }); }
Go ahead and post something! It'll automatically appear on your timeline.
Checkpoint: the composer appears above the activity list, the Post button is disabled until you type, and your post shows up in the list without restarting the app. Posted but nothing appeared? The activity went to your user feed but your timeline doesn't follow it - see the follow relationship set up in Step 3.
Step 6 - Explore page
The "Explore" page uses the foryou feed to explore new content by showing popular activities.
Just like we did for other feeds, we are going to add an exploreFeed to _MyHomePageState and initialize it accordingly in the loadFeeds function:
12345678910111213141516171819202122232425262728293031Future<void> _loadFeeds() async { userFeed = client.feedFromId(FeedId.user(userId)); await userFeed.getOrCreate(); timelineFeed = client.feedFromId(FeedId.timeline(userId)); await timelineFeed.getOrCreate(); final followsSelf = userFeed.state.feed?.ownFollows?.any( (follow) => follow.sourceFeed.fid == timelineFeed.fid, ) ?? false; if (!followsSelf) { await timelineFeed.follow(targetFid: userFeed.fid); } exploreFeed = client.feedFromId(FeedId(group: 'foryou', id: userId)); await exploreFeed.getOrCreate(); setState(() { isLoading = false; }); } void dispose() { userFeed.dispose(); timelineFeed.dispose(); exploreFeed.dispose(); super.dispose(); }
Now we're ready to implement the "Explore" page UI. The layout is similar to Home tab, except that we're not adding the activity composer:
1234567891011121314151617181920212223242526272829303132333435363738Widget build(BuildContext context) { if (isLoading) { return Center(child: CircularProgressIndicator()); } return Scaffold( appBar: AppBar(title: Text('Stream Feeds Tutorial')), body: _index == 0 ? Column( children: [ ActivityComposer(userFeed: userFeed), Expanded( child: ActivityListView( client: client, feed: timelineFeed, onFollow: onFollow, onUnfollow: onUnfollow, ), ), ], ) : ActivityListView( client: client, feed: exploreFeed, onFollow: onFollow, onUnfollow: onUnfollow, ), bottomNavigationBar: BottomNavigationBar( items: [ BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Explore'), ], currentIndex: _index, onTap: (index) => setState(() => _index = index), ), ); }
Note that the
foryoufeed uses the "popular" activity selector, which doesn't support real-time updates. The documentation details how real-time updates work.
Seed the Explore page on your own app
On the pre-filled tutorial credentials the Explore page already has activities to show - that environment is shared and pre-seeded. On your own app from the Stream CLI it will be empty: the foryou feed selects popular content, and a brand-new app has no activities, no users other than yours, and nothing to rank.
Seed it with the CLI. This lowers the popularity threshold so a single interaction is enough to qualify, creates a second user, and gives them a post:
123456789101112131415161718192021222324252627# 1. Lower the popularity threshold on foryou getstream api feeds UpdateFeedGroup --id foryou --request '{ "activity_selectors": [ { "type": "popular", "cutoff_window": "2d", "min_popularity": 1 }, { "type": "following", "cutoff_window": "2d" }, { "type": "follow_suggestion", "cutoff_window": "7d", "min_popularity": 5, "params": { "activities_per_feed": 2, "max_suggested_feeds": 10, "min_feed_score": 0.3 } } ] }' # 2. Create a new user: ben getstream api common UpdateUsers --request '{"users":{"ben":{"id":"ben","name":"Ben","role":"user"}}}' # 3. Set up Ben's feeds - auto-creates both timeline:ben and user:ben getstream api feeds Follow --request '{"source":"timeline:ben","target":"user:ben"}' # 4. Post an activity getstream api feeds AddActivity --request '{ "feeds": ["user:ben"], "type": "post", "text": "Hello from Ben!", "user_id": "ben", "id": "ben-first-post" }' # 5. Bookmark to boost popularity getstream api feeds AddBookmark --activity-id ben-first-post --request '{"user_id":"ben"}'
Feed group changes can take up to 30 seconds to propagate to all API nodes, so give step 1 a moment before restarting the app.
Checkpoint: the Explore tab lists at least one activity you didn't post - Ben's on your own app, or the pre-seeded ones on the tutorial credentials. Don't skip this: the next step adds a follow button to activities from other users.
Step 7 - Follow and unfollow
To implement following and unfollowing feeds we're:
- Performing the actual follow/unfollow operation in
main.dart - Extending the
ActivityItemcomponent by adding the follow/unfollow button
12345678910111213Future<void> onFollow(FeedId value) async { await timelineFeed.follow(targetFid: value); // Ensure the feeds are up to date after follow/unfollow await timelineFeed.getOrCreate(); await exploreFeed.getOrCreate(); } Future<void> onUnfollow(FeedId value) async { await timelineFeed.unfollow(targetFid: value); // Ensure the feeds are up to date after follow/unfollow await timelineFeed.getOrCreate(); await exploreFeed.getOrCreate(); }
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899import 'package:flutter/material.dart'; import 'package:stream_feeds/stream_feeds.dart'; class ActivityItem extends StatelessWidget { const ActivityItem({ super.key, required this.currentUserId, required this.activity, required this.onFollow, required this.onUnfollow, required this.onCommentPressed, required this.onLikePressed, }); final String currentUserId; final ActivityData activity; final ValueSetter<FeedId> onFollow; final ValueSetter<FeedId> onUnfollow; final VoidCallback onCommentPressed; final VoidCallback onLikePressed; Widget build(BuildContext context) { return Card( margin: EdgeInsets.all(8), child: Padding( padding: EdgeInsets.all(8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only(top: 4), child: CircleAvatar( child: Text(activity.user.name?[0].toUpperCase() ?? ''), ), ), SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ActivityHeader( currentUserId: currentUserId, activity: activity, onFollow: onFollow, onUnfollow: onUnfollow, ), Text(activity.text ?? ''), ], ), ), ], ), ), ); } } class ActivityHeader extends StatelessWidget { const ActivityHeader({ super.key, required this.currentUserId, required this.activity, required this.onFollow, required this.onUnfollow, }); final String currentUserId; final ActivityData activity; final ValueSetter<FeedId> onFollow; final ValueSetter<FeedId> onUnfollow; Widget build(BuildContext context) { final isFollowing = activity.currentFeed?.ownFollows?.isNotEmpty ?? false; return Row( children: [ Expanded( child: Text( activity.user.name ?? '', style: Theme.of(context).textTheme.titleMedium, ), ), if (!isFollowing && activity.user.id != currentUserId) TextButton( onPressed: () => onFollow(activity.currentFeed!.fid), child: Text('Follow', style: TextStyle(color: Colors.green)), ), if (isFollowing && activity.user.id != currentUserId) TextButton( onPressed: () => onUnfollow(activity.currentFeed!.fid), child: Text('Unfollow', style: TextStyle(color: Colors.red)), ), ], ); } }
Let's walk through the steps:
feed.followandfeed.unfollowlet us follow/unfollow feeds.- To immediately see the results of the follow/unfollow, we're reloading the feeds with
getOrCreate. - We're using
activity.currentFeed.ownFollowsto know if the user's timeline feed follows the feed or not.activity.currentFeedhas information about the feed the activity was posted to- It's useful if you're building Reddit-style applications where there is no 1:1 mapping between feeds and users
- It lets you display name/image of the feed the activity belongs to
- The Stream API also supports follow requests where approval from the feed owner is required to follow
Now that the follow button is working, you can start following other users using the "Explore" page.
Checkpoint: every activity that isn't yours shows a Follow button, tapping it flips the label to Unfollow, and the followed user's activities appear on your Home timeline. Your own activities show no button at all - that's the activity.user.id != currentUserId check.
Step 8 - Reactions
To make our application more interactive, we'll add reactions for activities.
To achieve this, we'll follow the same approach we used for the follow button:
- Implementing the "like" operation in
activity_list_view.dart - Extend the
ActivityItemcomponent by adding the button to toggle a "like" reaction
12345678910111213141516171819202122232425262728293031return ListView( children: [ ...state.activities.map( (activity) => ActivityItem( currentUserId: client.user.id, activity: activity, onFollow: onFollow, onUnfollow: onUnfollow, onCommentPressed: () {}, onLikePressed: () { if (activity.ownReactions.isEmpty) { feed.addActivityReaction( activityId: activity.id, request: AddReactionRequest(type: 'like'), ); } else { feed.deleteActivityReaction( activityId: activity.id, type: 'like', ); } }, ), ), if (feed.state.canLoadMoreActivities) TextButton( onPressed: () => feed.queryMoreActivities(), child: Text('Load more'), ), ], );
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051Widget build(BuildContext context) { return Card( margin: EdgeInsets.all(8), child: Padding( padding: EdgeInsets.all(8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only(top: 4), child: CircleAvatar( child: Text(activity.user.name?[0].toUpperCase() ?? ''), ), ), SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ActivityHeader( currentUserId: currentUserId, activity: activity, onFollow: onFollow, onUnfollow: onUnfollow, ), Text(activity.text ?? ''), Row( children: [ TextButton.icon( onPressed: onLikePressed, icon: Icon(Icons.favorite), label: Text( '${activity.reactionGroups["like"]?.count ?? 0}', ), style: TextButton.styleFrom( foregroundColor: activity.ownReactions.isEmpty ? Colors.black54 : Colors.red, ), ), ], ), ], ), ), ], ), ), ); }
Let's do a recap of this step:
- We used
feed.addActivityReactionandfeed.deleteActivityReactionto toggle reactions- We used "like" as reaction
type, but it can be any string you'd like - Since the Flutter SDK provides reactive state management, the UI is automatically updated anytime anything on the activity changes
- We used "like" as reaction
- We use
activity.ownReactionsandactivity.reactionGroupsto get real-time reaction data for the activity - Some advanced features not shown in the tutorial:
- A single user can add multiple reactions to an activity
- Comments can have reactions too
- Check out the activity reactions and comment reactions pages in the documentation for more information
See a reaction arrive from someone else
Reacting to your own posts only proves the button works. To watch a reaction land in real time, you need a second user reacting to your activity.
On the pre-filled tutorial credentials, you can use the web demo app to follow your tutorial user, and to react to their activities.
On your own app from the Stream CLI, have Ben do it. This reads Alice's user feed as Ben, grabs the most recent activity, and likes it:
12345678910# Read alice's feed as ben, grab the first activity's id and react with like ACTIVITY_ID=$(getstream api feeds GetOrCreateFeed \ --feed-group-id user --feed-id alice \ --request '{"user_id":"ben","limit":1}' \ --jq '.activities[0].id' | tr -d '"') echo "$ACTIVITY_ID" getstream api feeds AddActivityReaction --activity-id "$ACTIVITY_ID" \ --request '{"type":"like","user_id":"ben","enforce_unique":true}'
Swap alice for the user id your own token belongs to - the one from Step 1 - so the like lands on an activity you can see in your running app. ben comes from the seeding commands in Step 6, so run those first if you skipped them. enforce_unique keeps a re-run from stacking duplicate likes.
Checkpoint: tapping the heart increments the count and turns it red; tapping again removes it. Ben's like from the CLI (or the demo app's) appears on your activity without restarting the app.
Step 9 - Comments
Comments are another good way to add interactivity to an app. To add this feature to the tutorial project we need to:
- Implement
CommentsPageand the components to display and post comments - Implement
CommentComposerto post comments - Extend the
ActivityItemcomponent with a button to navigate to the comments screen - Extend the
ActivityListViewcomponent to implement the navigation.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127import 'package:flutter/material.dart'; import 'package:flutter_state_notifier/flutter_state_notifier.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'comment_composer.dart'; class CommentsPage extends StatefulWidget { static MaterialPageRoute<void> route( StreamFeedsClient client, String activityId, FeedId feedId, ) { return MaterialPageRoute( builder: (context) => CommentsPage(client: client, activityId: activityId, feedId: feedId), ); } const CommentsPage({ super.key, required this.client, required this.activityId, required this.feedId, }); final StreamFeedsClient client; final String activityId; final FeedId feedId; State<CommentsPage> createState() => _CommentsPageState(); } class _CommentsPageState extends State<CommentsPage> { late Activity activity; void initState() { super.initState(); _loadActivity(); } void didUpdateWidget(covariant CommentsPage oldWidget) { if (oldWidget.activityId != widget.activityId) { activity.dispose(); _loadActivity(); } super.didUpdateWidget(oldWidget); } void dispose() { activity.dispose(); super.dispose(); } Future<void> _loadActivity() async { activity = widget.client.activity( activityId: widget.activityId, fid: widget.feedId, ); await activity.get(); } Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Stream Feeds Tutorial')), body: Column( children: [ Expanded( child: StateNotifierBuilder( stateNotifier: activity.notifier, builder: (context, state, child) { if (state.comments.isEmpty) { return Center(child: Text('No comments yet')); } return ListView( children: [ ...state.comments.map( (comment) => CommentItem(comment: comment), ), if (activity.state.canLoadMoreComments) TextButton( onPressed: () => activity.queryMoreComments(), child: Text('Load more'), ), ], ); }, ), ), CommentComposer(activity: activity), ], ), ); } } class CommentItem extends StatelessWidget { const CommentItem({super.key, required this.comment}); final CommentData comment; Widget build(BuildContext context) { return Card( margin: EdgeInsets.all(8), child: Padding( padding: EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( comment.user.name ?? '', style: TextStyle(fontWeight: FontWeight.bold), ), SizedBox(height: 8), Text(comment.text ?? ''), ], ), ), ); } }
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970import 'package:flutter/material.dart'; import 'package:stream_feeds/stream_feeds.dart'; class CommentComposer extends StatefulWidget { const CommentComposer({super.key, required this.activity}); final Activity activity; State<CommentComposer> createState() => _CommentComposerState(); } class _CommentComposerState extends State<CommentComposer> { final TextEditingController _controller = TextEditingController(); bool _hasText = false; bool _isSending = false; void initState() { super.initState(); _controller.addListener(() { final hasText = _controller.text.trim().isNotEmpty; if (_hasText != hasText) { setState(() => _hasText = hasText); } }); } void dispose() { _controller.dispose(); super.dispose(); } Widget build(BuildContext context) { return SafeArea( child: Padding( padding: EdgeInsets.all(8), child: TextField( controller: _controller, decoration: InputDecoration( hintText: 'Write a comment...', border: OutlineInputBorder(), suffixIcon: IconButton( onPressed: _hasText && !_isSending ? _createComment : null, icon: Icon(Icons.send), color: Theme.of(context).colorScheme.primary, disabledColor: Colors.grey, ), ), ), ), ); } Future<void> _createComment() async { setState(() => _isSending = true); await widget.activity.addComment( request: ActivityAddCommentRequest( comment: _controller.text, activityId: widget.activity.activityId, ), ); _controller.clear(); setState(() { _isSending = false; }); } }
123456789101112131415161718192021222324Row( children: [ TextButton.icon( onPressed: onCommentPressed, icon: Icon(Icons.comment), label: Text(activity.commentCount.toString()), style: TextButton.styleFrom( foregroundColor: Colors.black54, ), ), TextButton.icon( onPressed: onLikePressed, icon: Icon(Icons.favorite), label: Text( '${activity.reactionGroups["like"]?.count ?? 0}', ), style: TextButton.styleFrom( foregroundColor: activity.ownReactions.isEmpty ? Colors.black54 : Colors.red, ), ), ], ),
1import 'comments_page.dart';
12345onCommentPressed: () { Navigator.of( context, ).push(CommentsPage.route(client, activity.id, feed.fid)); },
Let's recap what happened in this step:
- We instantiated an
Activityobject usingclient.activityand called.get()to load the activity data, including the comments- Notice this is very similar to what we did for
Feedobjects
- Notice this is very similar to what we did for
- We posted new comments by calling
activity.addComment - We use
activity.commentCountto display the total number of comments on an activity
See a reply arrive from someone else
As with reactions, replying to yourself only proves the composer works. To watch a reply land in real time, you need a second user commenting on your activity.
On the pre-filled tutorial credentials, use the web demo app to reply to your tutorial user's activities.
On your own app from the Stream CLI, have Ben do it. This reads Alice's user feed as Ben, grabs the most recent activity, and comments on it:
1234567891011121314# Read alice's feed as ben, grab the first activity's id and comment ACTIVITY_ID=$(getstream api feeds GetOrCreateFeed \ --feed-group-id user --feed-id alice \ --request '{"user_id":"ben","limit":1}' \ --jq '.activities[0].id' | tr -d '"') echo "$ACTIVITY_ID" getstream api feeds AddComment --request '{ "object_id": "'"$ACTIVITY_ID"'", "object_type": "activity", "comment": "Nice post, Alice!", "user_id": "ben" }'
Same substitutions as in Step 8: swap alice for the user id your own token belongs to, and make sure ben exists from the Step 6 seeding commands.
Checkpoint: tapping the comment button opens the comments screen, submitting a reply renders it in the list, and the comment counter on the activity goes up. Ben's reply from the CLI (or the demo app's) appears without restarting the app.
Comments can be threaded/nested too (not shown in the tutorial).
Step 10 - Posting images
Stream API allows attaching files to activities and comments. Let's extend our app with attaching images to activities. To achieve this we need to:
- Extend the
ActivityComposercomponent to let users pick an image to attach and display a preview - Update the activity posting logic to include the attachment
- Extend the
Activitycomponent to display the attachment
123import 'dart:io'; import 'package:image_picker/image_picker.dart';
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192class _ActivityComposerState extends State<ActivityComposer> { final TextEditingController _controller = TextEditingController(); StreamAttachment? _attachment; bool _hasText = false; bool _isSending = false; void initState() { super.initState(); _controller.addListener(() { final hasText = _controller.text.trim().isNotEmpty; if (_hasText != hasText) { setState(() => _hasText = hasText); } }); } void dispose() { _controller.dispose(); super.dispose(); } Widget build(BuildContext context) { return Padding( padding: EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _controller, decoration: InputDecoration( hintText: 'What is happening?', border: OutlineInputBorder(), ), ), SizedBox(height: 8), if (_attachment != null) Image.file(File(_attachment!.file.path), height: 100, width: 100), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( onPressed: () { _pickImage(); }, icon: Icon(Icons.image_outlined), ), SizedBox(width: 8), ElevatedButton( onPressed: (_hasText || _attachment != null) && !_isSending ? _createActivity : null, child: Text('Post'), ), ], ), ], ), ); } Future<void> _createActivity() async { setState(() => _isSending = true); await widget.userFeed.addActivity( request: FeedAddActivityRequest( feeds: [widget.userFeed.fid.toString()], type: 'post', text: _controller.text, attachmentUploads: _attachment != null ? [_attachment!] : null, ), ); _controller.clear(); setState(() { _attachment = null; _isSending = false; }); } Future<void> _pickImage() async { final image = await ImagePicker().pickImage(source: ImageSource.gallery); if (image != null) { final attachment = StreamAttachment( type: AttachmentType.image, file: AttachmentFile.fromXFile(image), ); setState(() => _attachment = attachment); } } }
123456789101112131415161718192021222324ActivityHeader( currentUserId: currentUserId, activity: activity, onFollow: onFollow, onUnfollow: onUnfollow, ), Text(activity.text ?? ''), ...activity.attachments .where((attachment) => attachment.imageUrl != null) .map( (attachment) => Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), image: DecorationImage( image: NetworkImage(attachment.imageUrl!), fit: BoxFit.cover, ), ), constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.width * 0.5, minWidth: double.infinity, ), ), ),
Go ahead and post an image! Or send an image URL, as the Stream API can automatically attach URL metadata as an attachment.
Checkpoint: the image button opens the system picker, the selected image previews in the composer, and after posting it renders inside the activity.
Verify the whole build
1234flutter analyze flutter run # or, if you used `getstream env --target flutter`: flutter run --dart-define-from-file=dart_defines.json
Human checkpoint: launching on a simulator or device and confirming the UI is a human step. Agents: run flutter analyze and build, then hand back to the human to run it. Confirm the full loop:
- Post an activity on the Home tab and watch it appear on your timeline.
- Attach an image to a post, and paste a URL into another one to see the metadata attachment.
- Like your own activity and reply to it on the comments screen - the reaction count and comment count both move.
- Open the Explore tab, follow a user, and go back to Home to see their activities in your timeline.
- Unfollow them and confirm their activities leave your timeline.
Troubleshooting
stream project is not initialized- CLI onboarding hasn't run. Rungetstream initin the project directory first.token is invalid/ auth error - the token was minted for a different app, or it expired. Re-mint withgetstream token <user_id>and confirm the API key matches.- Stuck on the loading spinner - either
client.connect()failed (Step 2) or one of thegetOrCreate()calls in_loadFeedsdid (Step 3), soisLoadingnever flips. Check the console; an auth error means the API key and token belong to different apps. Did not find the file passed to "--dart-define-from-file"-dart_defines.jsononly exists aftergetstream env --target flutterruns. On the pre-filled tutorial credentials, launch with plainflutter run.- Timeline stays empty after posting - the
timelinefeed doesn't follow theuserfeed. The follow relationship in Step 3 sets this up; confirm it ran. - Explore tab is empty - the
foryoufeed surfaces popular content, so it won't populate on a brand-new app with no reactions or comments yet. Seed it with the Step 6 commands. - Feed group not found / empty feed - feed group ids are case-sensitive and must match the feed groups on your app. New apps have the built-in
user,timeline, andforyougroups enabled; you can review or add them in the dashboard (Feeds -> Feed Groups). - Follow or unfollow doesn't change the timeline - the feeds are reloaded with
getOrCreateafter the toggle; without that reload the existing activities stay put until you restart the app. - Nothing updates in real time - note that only the
current_feedandfollowingselectors deliver WebSocket events, which is why theforyoufeed doesn't update live. - The UI doesn't rebuild when data changes - the widget read
feed.stateonce instead of binding tofeed.notifierthroughStateNotifierBuilder. Snapshots don't rebuild; the notifier does. - APIs in this tutorial don't exist on the package you installed - check you added
stream_feeds(plural). The similarly namedstream_feedis the discontinued V2 package, and pub.dev listsstream_feedsas its replacement.
Next steps
Even though this was a long tutorial, Activity Feed V3 has even more features:
- Activity selectors and ranking for customizing what content to show for users
- Activity processors for extracting topics from activity content
- Notification feeds (with aggregation)
- Story feed (activity expiration)
- Custom feed groups
- Feed and activity visibility including premium activities with feed memberships
- Moderation and fine-grained permission system
- Polls
- For more examples, checkout the stream-feeds-flutter repository
Beyond the SDK itself:
- Sample app - the complete demo: stream-feeds-flutter-tutorial on GitHub
- Chat and video - Stream also powers chat and video, and they share the same client-side patterns
- Build with an AI agent - the Stream agent skills give your coding agent the current SDK APIs
Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.
Final Thoughts
In this tutorial, we built a fully-functioning Flutter activity feed application with Stream's Activity Feed V3 SDK. We showed how easy it is to:
- Set up a simple activity feed application and connect it to Stream's Activity Feed V3 SDK.
- Create user and timeline feeds.
- Add activities, reactions and comments.
- Explore new content with "For you" feed.
Machine-readable resources
For AI agents and coding assistants working with this SDK:
- CLI + skills:
curl -fsSL https://getstream.io/cli.sh | bash, thengetstream skills --universalandgetstream skills stream-flutter --universalfor the Flutter pack (use--claudeinstead for Claude Code). (Alternative:npx skills add GetStream/agent-skills -s stream.) - Provisioning:
getstream init(auth + create/select org & app) ->getstream env --target flutter(API key todart_defines.json) ->getstream token <user_id>(mint a token); feed groups are configured in the dashboard - Data & config from the CLI:
getstream api <Endpoint> --request '{...}'for users, feeds, activities, and follows -AddActivity,Follow,QueryFeeds,QueryActivities,UpdateFeedGroup.CastPollVoteandDeletePollVoteexist in both Chat and Feeds, so namespace those:getstream api feeds CastPollVote - Flutter integration skill: invoke
/stream-flutterin your agent for Feeds setup patterns;/stream-docssearches live SDK docs with citations - Docs index for LLMs:
https://getstream.io/activity-feeds/docs/flutter/llms.txt - Markdown endpoints: append
.mdto any docs URL for a clean, token-efficient version - Source of truth for APIs: the SDK repository - Feeds V3 is pre-1.0; check the pinned version's source rather than assuming APIs from training data

