Building an E-commerce App in Flutter: Architecture from Catalog to Checkout
Building an e-commerce app in Flutter: catalog and search, cart state, secure payment gateway checkout, order tracking, push updates and image-heavy performance.

A shopping app looks simple from the outside. Some product cards, a cart icon with a badge, a checkout button. Then you start building it and discover that every one of those pieces has an edge case that costs someone money: a price that changed while the item sat in the cart, a payment that succeeded but the app crashed before showing the confirmation, a product photo that’s 4 MB because someone uploaded it straight from a camera.
I built Sol Store, a complete e-commerce app in Flutter covering everything from product browsing through checkout with a secure payment gateway. This post is the architecture I’d use for a mobile storefront, screen by screen, and the decisions that matter most at each step.
The shape of the app
Before any screens, I settle three things:
- The server is the source of truth for prices, stock and orders. The app displays and requests. It never decides what something costs.
- Payments go through a gateway’s SDK. Card details never touch my code or my servers.
- Order state is driven by the backend, confirmed by the payment provider, not by what the app thinks happened.
Everything else follows from those. In code, I structure it as features (catalog, search, product, cart, checkout, orders, account), each with its own repository and state, roughly as I describe in pragmatic clean architecture in Flutter.
Catalog and search
The catalog is where users spend most of their time, so it needs to feel instant.
Paginate everything. Load a page of products, fetch the next page as the user nears the bottom of the list. ListView.builder or GridView.builder only build the visible items, so the widget side scales fine. Your API needs to support cursor or page-based pagination from day one, because retrofitting it later means touching every screen.
Cache the first page. Show the last catalog the user saw immediately on launch, then refresh in the background. Showing slightly stale products for a second is far better than showing a spinner.
Debounce search. Don’t fire a request on every keystroke. Wait until the user pauses typing for a moment, and cancel (or ignore) results from older queries so a slow response doesn’t overwrite a newer one. For serious catalogs, full-text search belongs on the server or a dedicated search service, not in a database LIKE query.
Filters as state, not as screens. Category, price range, sort order and search text together form one query object. Store it in your state layer and the product list just reacts to it. It also makes it easy to put filters in a URL for sharing, which pairs nicely with deep links into categories and products.
Product detail
The product page has one job: get the user to “Add to cart” with confidence. Things that help:
- Hero transition from the list image, using the already-loaded thumbnail as a placeholder while the high-resolution image loads.
- Variants that can’t be invalid. If the red shirt has no size L, disable it, don’t let the user pick it and then show an error at checkout.
- Stock and price fetched fresh when the page opens, even if the list had a cached version.
Cart state
The cart is the most interesting piece of state in the app. It’s shared across many screens (badge, product page, cart screen, checkout), and it has derived values everywhere: line totals, subtotal, discounts, shipping, item count.
I model the cart as an immutable value and compute everything from it:
class CartLine {
const CartLine({
required this.productId,
required this.variantId,
required this.unitPriceCents,
required this.quantity,
});
final String productId;
final String variantId;
final int unitPriceCents;
final int quantity;
int get totalCents => unitPriceCents * quantity;
CartLine copyWith({int? quantity}) => CartLine(
productId: productId,
variantId: variantId,
unitPriceCents: unitPriceCents,
quantity: quantity ?? this.quantity,
);
}
class Cart {
const Cart(this.lines);
final List<CartLine> lines;
int get itemCount => lines.fold(0, (sum, l) => sum + l.quantity);
int get subtotalCents => lines.fold(0, (sum, l) => sum + l.totalCents);
}
Money is integer cents, always. Floating-point math and currency do not get along.
A few rules on top of the model:
- Persist the cart locally so it survives the app being killed. For signed-in users, sync it to the server so it follows them across devices.
- Treat cart prices as display only. When checkout starts, the server recalculates everything from product IDs and quantities. If a price or stock level changed, tell the user clearly before they pay.
- Update optimistically. Tapping “+” should change the number immediately, with a quiet rollback if the server rejects it.
Riverpod suits this well because so much is derived. A cart provider holds the lines, and the badge count, totals and checkout eligibility are just other providers watching it.
Checkout and payments
This is the part to be most conservative about. The flow I use with any modern payment gateway looks like this:
- The app sends the cart (product IDs, variants, quantities, shipping choice) to your backend.
- The backend recalculates the total, creates a payment session or intent with the gateway using its secret key, and returns only a client-side token.
- The app presents the gateway’s own payment UI (a payment sheet or hosted form). The user enters card details there, or uses Apple Pay or Google Pay.
- The gateway notifies your backend through a webhook when the payment actually succeeds.
- The backend marks the order as paid. The app shows the confirmation based on the order’s server-side status.
Step 4 is the one people skip. The app saying “payment succeeded” is not proof. The webhook is what you trust. Make order creation idempotent too, so a retried request doesn’t produce two orders.
Physical goods are also worth noting here. Apple and Google require in-app purchase for digital content, but goods and services consumed outside the app use a regular payment gateway, which is exactly what a storefront needs. If your store has multiple sellers who each need to get paid, that’s a different problem, and I walk through it in Stripe Connect for marketplace payments in Flutter.
Guest checkout
Forcing account creation before the first purchase loses buyers. I offer guest checkout by default: collect an email for the receipt and order updates, and offer to create an account on the confirmation screen, when the user has just had a good experience and all their details are already filled in.
On the backend, a guest order is linked to an email, and if that person signs up later with the same email, their past orders can be attached to the new account.
Order status and push updates
After payment, users want to know one thing: where is my order? Model the order lifecycle explicitly, and let the UI handle every case:
enum OrderStatus { pending, paid, packed, shipped, delivered, cancelled, refunded }
The orders screen reads status from the server. Push notifications tell the user when it changes: shipped, out for delivery, delivered. Each notification should deep link straight to that order’s detail screen, not to the home screen. The setup for that is in push notifications in Flutter with FCM.
Keep these notifications transactional. Mix in daily promotions and users turn them off, shipping updates included.
Performance with an image-heavy catalog
E-commerce apps are mostly images, and images are where performance goes to die:
- Serve the right size from the server. A grid thumbnail doesn’t need a 3000-pixel original. Use an image CDN or resize on upload so the app can request a width that matches the layout.
- Decode at display size. Flutter’s
Image.networkacceptscacheWidthandcacheHeight, andcached_network_imagehasmemCacheWidth, so a large image doesn’t get decoded into memory at full resolution. - Cache on disk so scrolling back up doesn’t download everything again.
- Use placeholders with fixed aspect ratios, so the grid doesn’t jump around as images arrive.
- Profile on a mid-range Android phone while scrolling the catalog quickly. That’s where memory and jank problems show up first.
Analytics that answer real questions
At minimum, track the funnel: product viewed, added to cart, checkout started, purchase completed. Firebase Analytics has dedicated e-commerce events for this (logViewItem, logAddToCart, logBeginCheckout, logPurchase), and using the standard names gets you the built-in reports. Log the purchase from confirmed order data, not from the moment the user taps “Pay”.
The drop between “checkout started” and “purchase completed” is the number to watch. If it’s high, the checkout flow has a problem worth finding.
Takeaways
- The server owns prices, stock and order state. The app displays and requests.
- Model the cart as an immutable value in integer cents, persist it, and revalidate everything when checkout starts.
- Use the payment gateway’s SDK for card entry, and confirm payments with webhooks, not app-side callbacks.
- Offer guest checkout and invite account creation after the first purchase.
- Paginate catalogs, size and cache images properly, and send transactional push updates that deep link to the order.
- Track the purchase funnel with standard e-commerce events to find where buyers drop off.
If you’re planning a Flutter storefront and want it built properly from catalog to checkout, get in touch.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).