A powerful Flutter package for effortless API pagination with shimmer effects, error handling, and smooth scrolling
GitHub β’ pub.dev β’ Report Bug β’ Request Feature
Pagy is a plug-and-play pagination solution for Flutter apps that makes implementing paginated lists incredibly easy:
- β Smart API Integration - Query params, body payloads, and custom headers support
- π« Auto-cancellation - Duplicate API calls automatically cancelled
- π¨ Beautiful UI - Built-in shimmer effects, error states, and empty state handling
- π§ Built-in Parsers - Laravel, Django, and 5+ common API formats supported
- π Advanced Metadata - Progress tracking, page indicators, and load status
- π Interceptors - Custom auth tokens, retries, and request modification
- π§© State Management - Works with Bloc, Riverpod, Provider, or standalone
- π Theme Support - Automatic light/dark theme adaptation
- ποΈ Clean Architecture - Dependency injection friendly
- β‘ Performance - Optimized scrolling with lazy loading
Add this to your pubspec.yaml:
dependencies:
pagy: ^1.3.0Then run:
flutter pub getIn your main.dart, configure Pagy before running your app:
import 'package:flutter/material.dart';
import 'package:pagy/pagy.dart';
void main() {
// Initialize Pagy with your API configuration
PagyConfig().initialize(
baseUrl: "https://api.example.com/",
pageKey: 'page', // Your API's page param name
limitKey: 'limit', // Your API's limit param name
enableLogs: true, // Enable debug logs
payloadMode: PaginationPayloadMode.queryParams, // or .payload for body
);
runApp(const MyApp());
}class Product {
final int id;
final String name;
final String image;
final double price;
Product({
required this.id,
required this.name,
required this.image,
required this.price,
});
// Factory for JSON parsing
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'],
name: json['name'],
image: json['image'],
price: json['price'].toDouble(),
);
}
// Empty constructor for shimmer placeholder
Product.empty()
: id = 0,
name = 'Loading...',
image = '',
price = 0.0;
}import 'package:flutter/material.dart';
import 'package:pagy/pagy.dart';
class ProductListScreen extends StatefulWidget {
const ProductListScreen({super.key});
@override
State<ProductListScreen> createState() => _ProductListScreenState();
}
class _ProductListScreenState extends State<ProductListScreen> {
late PagyController<Product> pagyController;
@override
void initState() {
super.initState();
pagyController = PagyController(
endPoint: "products",
requestType: PagyApiRequestType.get,
fromMap: Product.fromJson,
limit: 20,
// Use built-in parser for common response structures
responseParser: PagyParsers.dataWithPagination,
// Or custom parser:
// responseParser: (response) => PagyResponseParser(
// list: response['data'],
// totalPages: response['pagination']['totalPages'],
// ),
);
// Load initial data
pagyController.loadData();
}
@override
void dispose() {
pagyController.controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: PagyListView<Product>(
controller: pagyController,
itemSpacing: 10,
padding: const EdgeInsets.all(16),
shimmerEffect: true,
placeholderItemCount: 10,
placeholderItemModel: Product.empty(),
// Use itemBuilderWithIndex to access item's position
itemBuilderWithIndex: (context, product, index) {
return ProductCard(
product: product,
position: index + 1, // Show item number
);
},
),
);
}
}π‘ New in v1.3.0: Persistence is now handled automatically. Filters are kept during retries and can optionally be kept during refreshes.
Pagy now automatically persists your filters across loadMore() and retry() calls. You can also control whether filters are kept when the user pulls to refresh.
Global Config:
PagyConfig().initialize(
baseUrl: "...",
preserveFiltersOnRefresh: true, // Keep filters when pulling to refresh
);Manual Refresh with Control:
// Keep filters for this refresh only
pagyController.refresh(preserveFilters: true);
// Clear filters manually
pagyController.clearFilters();You can use any third-party refresh indicator (like liquid_pull_to_refresh or custom_refresh_indicator) by providing the refreshIndicatorBuilder.
PagyListView<Product>(
controller: pagyController,
refreshIndicatorBuilder: (context, child, onRefresh) {
return MyCustomRefreshIndicator(
onRefresh: onRefresh,
child: child,
);
},
itemBuilder: (context, product) => ProductCard(product: product),
)For better developer experience, PagyError now captures the stacktrace of the failure.
if (pagyController.controller.value.error != null) {
final error = pagyController.controller.value.error!;
print(error.message);
print(error.stackTrace); // Access the full stacktrace
}Pagy now supports a more structured approach for enterprise apps using repositories and use cases.
PagyPageRepository is an abstract class β use the built-in PagyPageRepositoryImpl or create your own implementation.
// 1. Use the built-in repository implementation
// (PagyPageRepository is abstract β implement your own or use the default)
final repository = PagyPageRepositoryImpl(remoteDataSource);
// 2. Wrap in a Use Case (optional but recommended)
final useCase = GetPaginatedPageUseCase(repository);
// 3. Pass to Controller
pagyController = PagyController(
endPoint: "products",
fromMap: Product.fromJson,
responseParser: PagyParsers.dataWithPagination,
pageUseCase: useCase,
);Not all APIs return totalPages. Pagy supports multiple fallback strategies via PagyResponseParser:
responseParser: (response) => PagyResponseParser(
list: response['data'],
totalPages: response['total_pages'], // Primary: explicit total
totalItems: response['total_count'], // Fallback: computed from count
hasMore: response['has_more'], // Fallback: boolean flag
),Resolution order: totalPages β totalItems / pageSize β hasMore β assume more (if configured).
If your API provides none of these, enable the global fallback:
PagyConfig().initialize(
baseUrl: "...",
assumeHasMoreWhenTotalPagesNull: true, // Keep loading until an empty page
);When using payloadData in the controller constructor (e.g., for POST requests), that payload is automatically reused for all subsequent loadMore() calls. You can override it per-call:
pagyController = PagyController(
endPoint: "orders",
requestType: PagyApiRequestType.post,
payloadData: {'user_id': 123}, // Reused on every loadData() / loadMore()
// ...
);
// Override for a specific call
await pagyController.loadData(payloadData: {'user_id': 456});Access the item's position in the list:
PagyListView<Product>(
controller: pagyController,
itemBuilderWithIndex: (context, product, index) {
return Card(
color: index.isEven ? Colors.white : Colors.grey[100],
child: ListTile(
leading: CircleAvatar(child: Text('#${index + 1}')),
title: Text(product.name),
),
);
},
)If you don't need the index, the old signature still works but is deprecated:
PagyListView<Product>(
controller: pagyController,
itemBuilder: (context, product) { // β οΈ Deprecated
return ProductCard(product: product);
},
)Reduce boilerplate with pre-built parsers for common API response structures:
// For: { "data": [...], "pagination": { "totalPages": 10 } }
responseParser: PagyParsers.dataWithPagination
// For: { "items": [...], "total_pages": 10 }
responseParser: PagyParsers.itemsWithTotal
// For: { "results": [...], "page_count": 10 }
responseParser: PagyParsers.resultsWithCount
// For Laravel: { "data": [...], "last_page": 10 }
responseParser: PagyParsers.laravel
// For Django: { "results": [...], "count": 100 }
responseParser: (response) => PagyParsers.django(response, itemsPerPage: 20)
// Custom key names
responseParser: (response) => PagyParsers.customKey(
response,
itemKey: 'users',
totalKey: 'totalPages',
)// Simple search
await pagyController.search('laptop computers');
// Apply filters
await pagyController.applyFilters({
'category': 'electronics',
'price_max': 1000,
'in_stock': true,
});
// Custom query parameters
await pagyController.loadData(
queryParameter: {
'sort': 'price_desc',
'brand': 'Apple',
},
);RefreshIndicator(
onRefresh: () async {
await pagyController.refresh();
},
child: PagyListView<Product>(
controller: pagyController,
itemBuilder: (context, product) => ProductCard(product: product),
),
)pagyController = PagyController(
endPoint: "private/orders",
requestType: PagyApiRequestType.post,
fromMap: Order.fromJson,
token: "Bearer YOUR_AUTH_TOKEN",
headers: {
'X-Custom-Header': 'value',
},
payloadData: {
'user_id': 123,
'status': 'active',
},
responseParser: PagyParsers.dataWithPagination,
);PagyGridView<Product>(
controller: pagyController,
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
shimmerEffect: true,
placeholderItemModel: Product.empty(),
itemBuilder: (context, product) {
return ProductGridCard(product: product);
},
)Perfect for category carousels, featured products, or horizontal galleries:
SizedBox(
height: 200,
child: PagyHorizontalListView<Category>(
controller: categoryController,
itemBuilderWithIndex: (context, category, index) {
return CategoryCard(category: category);
},
itemSpacing: 12,
shimmerEffect: true,
placeholderItemModel: Category.empty(),
),
)π‘ Note: By default, wrap
PagyHorizontalListViewin aSizedBoxorContainerwith a fixed height since horizontal lists need constrained height.
Use useDynamicHeight: true when you want the height to be determined by content (intrinsic sizing). This is useful inside Column, ListView, or any layout where you don't want a fixed height:
Column(
children: [
Text('Featured Categories'),
PagyHorizontalListView<Category>(
controller: categoryController,
useDynamicHeight: true, // Uses Row + SingleChildScrollView
itemBuilderWithIndex: (context, category, index) {
return CategoryCard(category: category);
},
itemSpacing: 12,
),
],
)π‘ Note: When
useDynamicHeightistrue, all items are built upfront (not lazily), so use with caution for very large lists.
// Display current page info
Text('Page ${pagyController.metadata.currentPage} of ${pagyController.metadata.totalPages}')
// Progress indicator
LinearProgressIndicator(value: pagyController.metadata.progress)
// Show loading state
if (pagyController.metadata.hasMore)
TextButton(
onPressed: pagyController.loadMore,
child: const Text('Load More'),
)PagyObserver<Product>(
controller: pagyController,
builder: (context, state) {
if (state.error != null) {
return Column(
children: [
Text('Error: ${state.error!.message}'),
if (state.error!.suggestion != null)
Text('Suggestion: ${state.error!.suggestion}'),
ElevatedButton(
onPressed: pagyController.retry,
child: const Text('Retry'),
),
],
);
}
return PagyListView<Product>(...);
},
)PagyListView<Product>(
controller: pagyController,
errorBuilder: (message, retry) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(
onPressed: retry,
child: const Text('Try Again'),
),
],
),
);
},
itemBuilder: (context, product) => ProductCard(product: product),
)PagyListView<Product>(
controller: pagyController,
emptyStateRetryBuilder: (retry) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.inbox, size: 64),
const Text('No products found'),
TextButton(
onPressed: retry,
child: const Text('Refresh'),
),
],
),
);
},
itemBuilder: (context, product) => ProductCard(product: product),
)Version 1.0.0+ introduces better naming while maintaining backward compatibility. Old parameter names still work but are deprecated and will be removed in v2.0.0.
| Old (Deprecated) | New (Recommended) |
|---|---|
responseMapper |
responseParser |
additionalQueryParams |
query |
paginationMode |
payloadMode |
| Old (Deprecated) | New (Recommended) |
|---|---|
apiLogs |
enableLogs |
paginationMode |
payloadMode |
| Old (Deprecated) | New (Recommended) |
|---|---|
itemBuilder: (context, item) => ... |
itemBuilderWithIndex: (context, item, index) => ... |
Why? Access to the item's index enables features like:
- Item numbering ("#1", "#2", etc.)
- Alternating row colors
- Position-based styling
- Analytics tracking by position
Old (still works, shows deprecation warnings):
PagyController(
responseMapper: (response) => PagyResponseParser(...),
additionalQueryParams: {'sort': 'latest'},
paginationMode: PaginationPayloadMode.queryParams,
);
PagyListView(
itemBuilder: (context, item) => ItemWidget(item), // No index access
)New (recommended):
PagyController(
responseParser: PagyParsers.dataWithPagination,
query: {'sort': 'latest'},
payloadMode: PaginationPayloadMode.queryParams,
);
PagyListView(
itemBuilderWithIndex: (context, item, index) => ItemWidget(
item: item,
position: index + 1,
),
)Check out the complete working example in the example directory, which includes:
- ListView and GridView implementations
- Search and filtering
- Custom error/empty states
- Shimmer loading effects
- Pull-to-refresh
- State management with Riverpod
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/AmazingFeature) - Commit your changes (
git commit -m 'Add some AmazingFeature') - Push to the branch (
git push origin feature/AmazingFeature) - Open a Pull Request
This project is licensed under the MIT License - see the LICENSE file for details.
Flutter Developer & Open Source Contributor
If you found this package helpful, please give it a β on GitHub!
Made with β€οΈ by Hayat Khan

