Beautiful, pragmatic Flutter primitives for building Nostr-powered feeds and social UX
📖 Documentation • 🎯 Quick Start • 💡 Examples • 🔧 API Reference • 🤝 Contributing
flutter_nostr is a powerful Flutter package that provides building blocks for creating Nostr-powered social applications. It handles the complexity of fetching events, enriching them with related data, and rendering them in beautiful, performant Flutter widgets.
- 🔄 Parallel Data Fetching: Efficiently fetch related data (profiles, reactions, etc.) using typed parallel requests
- 📱 Flutter-Native: Built specifically for Flutter with proper state management and widget lifecycle
- 🎨 Customizable: Flexible builder patterns and adapters for any data structure
- ⚡ Performant: Smart caching, pagination, and visibility-based loading
- 🔗 Type-Safe: Full TypeScript-like type safety with Dart generics
- 📚 Example-Rich: Complete example app with multiple use cases
Add to your pubspec.yaml:
dependencies:
flutter_nostr: ^0.1.0import 'package:flutter/material.dart';
import 'package:flutter_nostr/flutter_nostr.dart';
void main() async {
// Initialize with one or more relays
await FlutterNostr.init(relays: [
'wss://relay.nostr.band',
'wss://nos.lol',
]);
runApp(const MyApp());
}Feeds are the core building block of social applications, it is that simple scrollable list of events enriched with related data customized to your needs, with support to all states like loading, error, empty, pull-to-refresh, infinite scroll and more.
The main component that you will always use to build a feed is FlutterNostrFeed, it is responsible for fetching the data and providing it to the builder function.
Inside the builder function, you can take full control on how/what you want to support in your feed, or you can simply use the pre-built FlutterNostrFeedList which abstract general handling for a feed like loading, error handling... and only prompt you to build the UI for your list of items.
The simple feed is refered to a feed that only loads requests sequentianlly only, and does require loading request events only
FlutterNostrFeed(
filters: [
NostrFilter(
limit: 10, // limit each loading to 10 events
kinds: [30402], // listings kinds
),
],.
builder: (context, data, options) {
return FlutterNostrFeedList(
data: data,
options: options,
itemBuilder: (context, event, index, data, options) {
return ListTile(
title: Text(event.content ?? 'No content'),
subtitle: Text('Author: ${event.pubkey.substring(0, 8)}...'),
);
},
);
},
),That's it! 🎉 You now have a fully functional Nostr listings feed with:
- ✅ Pull-to-refresh
- ✅ Infinite scroll
- ✅ Loading states
- ✅ Error handling
- ✅ Smooth and lag-free rendering (using
ListView.builderand some other techniques..) - ✅ Much more handling that you propably don't know about but happens under the hood.
In order for a Nostr Feed to be functional and usable, loading more entities that relates to what actually was requested initially is requested. as an example, creating a posts feed shoulf also fetch for these posts authors details such name, username, picture...
This is where the package also abstracts the complexity of doing so in a multi-layer feed, which basically have parallel loading for the initial requested events, so lets take this use case:
- When the posts feed is loaded. (Layer 1)
- For each post (Loaded in Layer 1) that the end-user sees, more parallel requests executes to get their authors user details, reactions, zaps, comments, referenced events/pubkeys if any with
nevent,npub,note,nprofile..., parent events if any... (Layer 2) - For each post (Loaded in Layer 1) reaction/comment (Loaded in Layer 2), more parallel requests execute to get their user details or more related data (Layer 3)
- More parallel fetching if needed...
With this mechanism, you will be basically be able to build your goal feed even if it will require much more fetching layers.
Note: I assume you read the Simple Feed section before continuing.
- The
FlutterNostrFeedstill the main component that you will use to build a feed, but now you will also use theparallelRequestRequestsHandlerparameter to define your parallel requests using the already loaded data from previous layers. - Each parallel request is represented by the
ParallelRequest<T>class, which is a typed request that holds the filters to be used to fetch the related data, and an adapter function to convert the fetchedNostrEventinto the desired typeT. - Each parallel request is identified from other parallel requests if any with the
ParallelRequestId<T>, where you basically create a uniqueidfor each request you want to make. - Results of each request is passed to the
buildervia theFeedBuilderData dataparameter, where you can access the results of each request by itsParallelRequestId<T>. - To execute more parallel requests based on the results of previous parallel requests, you can use the
.then<U>()method on theParallelRequest<T>instance to chain more requests, like:
ParallelRequest<T>(
//...
).then<U>((List<T> previousResults) {
return ParallelRequest<U>(
//...
);
}).then<V>((List<U> previousResults) {
return ParallelRequest<V>(
//...
);
}).then<W>((List<V> previousResults) {
return ParallelRequest<W>(
//...
);
}); final profileFetchRequestId = ParallelRequestId<UserInfo>(id: 'unique-id-1');
FlutterNostrFeed(
filters: [
NostrFilter(
limit: 25,
kinds: [1], // posts kinds
),
],
parallelRequestRequestsHandler: (_, List<NostrEvent> postEvents) {
return ParallelRequest(
id: profileFetchRequestId,
filters: [
NostrFilter(
kinds: [0], // user details kind
authors: postEvents.map((e) => e.pubkey).toList(),
),
],
adapter: (event) {
return UserInfo.fromEvent(event);
},
);
},
builder: (context, data, options) {
return FlutterNostrFeedList(
data: data,
options: options,
itemBuilder: (context, NostrEvent postEvent, index, data, options) {
final postContent = postEvent.content != null ? postEvent.content! : "";
// This is how we access the requests results for a specific parallel request by its id
final profileFetchResults = data.parallelRequestResultsFor(
profileFetchRequestId,
);
List<UserInfo> userResults = profileFetchResults?.adaptedResults ?? [];
UserInfo? user =
userResults
.where((element) => element.event.pubkey == postEvent.pubkey)
.firstOrNull;
final postOwnerName =
user?.name.isEmpty ?? true ? "Loading Or Unknown" : user!.name;
return ListTile(
title: Text(postOwnerName),
subtitle: Text(postContent),
);
},
);
},
), final profileFetchRequestId = ParallelRequestId<UserInfo>(id: 'unique-id-1');
final followingsFetchRequestId = ParallelRequestId<UserFollowings>(id: 'unique-id-2');
FlutterNostrFeed(
filters: [
NostrFilter(
limit: 25,
kinds: [1], // posts kinds
),
],
parallelRequestRequestsHandler: (_, List<NostrEvent> postEvents) {
return ParallelRequest(
id: profileFetchRequestId,
filters: [
NostrFilter(
kinds: [0], // user details kind
authors: postEvents.map((e) => e.pubkey).toList(),
),
],
adapter: (event) {
return UserInfo.fromEvent(event);
},
).then<UserFollowings>((List<UserInfo> users) {
return ParallelRequest(
id: followingsFetchRequestId,
filters: [
NostrFilter(
kinds: [3], // user followings kind
authors: users.map((u) => u.event.pubkey).toList(),
),
],
adapter: (event) {
return UserFollowings.fromEvent(event);
},
);
});
},
builder: (context, data, options) {
return FlutterNostrFeedList(
data: data,
options: options,
itemBuilder: (context, NostrEvent postEvent, index, data, options) {
final postContent = postEvent.content != null ? postEvent.content! : "";
final profileFetchResults = data.parallelRequestResultsFor(
profileFetchRequestId,
);
final followingsFetchResults = data.parallelRequestResultsFor(
followingsFetchRequestId,
);
List<UserInfo> userResults = profileFetchResults?.adaptedResults ?? [];
List<UserFollowings> followingsResults =
followingsFetchResults?.adaptedResults ?? [];
UserInfo? user =
userResults
.where((element) => element.event.pubkey == postEvent.pubkey)
.firstOrNull;
UserFollowings? userFollowings =
followingsResults
.where((element) => element.pubkey == postEvent.pubkey)
.firstOrNull;
final postOwnerName =
user?.name.isEmpty ?? true ? "Loading Or Unknown" : user!.name;
final postOwnerFollowingsCount = userFollowings?.followings.length ?? 0;
return ListTile(
title: Text(postOwnerName),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(postContent),
SizedBox(height: 4),
Text(
'Followings: $postOwnerFollowingsCount',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
);
},
);
},
)Sometimes you don't need a full feed with pagination and infinite scroll—you just need to fetch a specific event or a small set of events once. The package provides two simple widgets for these use cases.
Use OneTimeEventBuilder when you need to fetch a single event based on a filter. This is perfect for displaying a specific user profile, a referenced note, or any single Nostr event.
- 🎯 Single Event: Fetches one event matching your filter
- ⚡ FutureBuilder-based: Built on Flutter's
FutureBuilderfor simple state management - 🔄 Loading States: Built-in handling for loading, error, and success states
- 🎨 Flexible Builder: Full control over how to render the event
OneTimeEventBuilder(
filter: NostrFilter(
kinds: [0], // metadata/profile kind
authors: ['npub1...'], // specific user's pubkey
limit: 1,
),
builder: (context, isLoading, error, subscriptionId, event) {
if (isLoading) {
return Center(child: CircularProgressIndicator());
}
if (error != null) {
return Center(
child: Text('Error loading profile: $error'),
);
}
if (event == null) {
return Center(child: Text('Profile not found'));
}
final metadata = Metadata.fromEvent(event);
return Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
CircleAvatar(
radius: 40,
backgroundImage: metadata.picture != null
? NetworkImage(metadata.picture!)
: null,
),
SizedBox(height: 16),
Text(
metadata.name ?? 'Unknown',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
Text(
metadata.about ?? 'No bio',
style: TextStyle(color: Colors.grey[600]),
),
],
),
),
);
},
)Use OneTimeEventsBuilder when you need to fetch multiple events at once. This is ideal for displaying a small collection of events, such as the latest posts from a user or a list of referenced events.
- 📚 Multiple Events: Fetches multiple events matching your filters
- ⚡ FutureBuilder-based: Built on Flutter's
FutureBuilderfor simple state management - 🔄 Loading States: Built-in handling for loading, error, and success states
- 🎨 Flexible Builder: Full control over how to render the events list
OneTimeEventsBuilder(
filters: [
NostrFilter(
kinds: [1], // text note kind
authors: ['npub1...'], // specific user's pubkey
limit: 10,
),
],
builder: (context, isLoading, error, subscriptionId, events) {
if (isLoading) {
return Center(child: CircularProgressIndicator());
}
if (error != null) {
return Center(
child: Text('Error loading posts: $error'),
);
}
if (events == null || events.isEmpty) {
return Center(child: Text('No posts found'));
}
return ListView.builder(
itemCount: events.length,
itemBuilder: (context, index) {
final event = events[index];
final timestamp = DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
);
return ListTile(
title: Text(event.content ?? 'No content'),
subtitle: Text(
'Posted: ${timestamp.toString().substring(0, 16)}',
),
leading: Icon(Icons.note),
);
},
);
},
)OneTimeEventsBuilder(
filters: [
NostrFilter(
ids: [
'event_id_1',
'event_id_2',
'event_id_3',
],
),
],
builder: (context, isLoading, error, subscriptionId, events) {
if (isLoading) {
return Center(child: CircularProgressIndicator());
}
if (error != null) {
return Center(child: Text('Error: $error'));
}
final eventMap = {
for (var event in events ?? []) event.id: event
};
return Column(
children: [
_buildEventCard(eventMap['event_id_1']),
_buildEventCard(eventMap['event_id_2']),
_buildEventCard(eventMap['event_id_3']),
],
);
},
)The package includes a comprehensive example app you can run and explore visually different use cases and implementations, simply run:
cd example
flutter pub get
flutter runFlutterNostrFeed(
builder: (context, data, options) {
if (options.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 64, color: Colors.red),
SizedBox(height: 16),
Text('Failed to load feed'),
SizedBox(height: 8),
ElevatedButton(
onPressed: options.refresh,
child: Text('Retry'),
),
],
),
);
}
return FlutterNostrFeedList(/* ... */);
},
)We welcome contributions! Here's how you can help:
- Use the issue template
- Include steps to reproduce
- Provide Flutter/Dart version info
- Describe the use case
- Explain why it would be valuable
- Consider contributing a PR
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Update documentation
- Submit a PR with a clear description
- Chat primitives (NIP-44)
- Identity helpers and key management
- Enhanced error handling
- Performance improvements
- Payment integration (Lightning)
- NostrConnect support
- Relay moderation tools
- Advanced caching strategies
- WebSocket connection pooling
This project is licensed under the MIT License - see the LICENSE file for details.
- Built on top of dart_nostr package
- Inspired by the Nostr protocol's simplicity and power
- Community feedback and contributions
Made with ❤️ for the Nostr community