Back to blog
Design Notes

Designing a Community Deck Marketplace — Free Browsing, Paid Downloads

bloomcardmarketplacemonetizationcommunity

The Problem With Flashcard Content

Building a flashcard app is straightforward. Building one that people actually keep using is hard. The number one reason users abandon flashcard apps isn't bad algorithms or ugly UI — it's the blank deck problem. They download the app, stare at an empty screen, and realize they need to create all their own content before they can start learning. Most never make it past that point.

The obvious solution is a community marketplace where users share decks with each other. But community marketplaces come with their own set of problems. Make everything free and you get a tragedy of the commons — no incentive to create quality content, no way to sustain the platform. Lock everything behind a paywall and you kill the discovery experience that makes marketplaces feel alive. The challenge is finding the line between these extremes.

For BloomCard, I landed on a model I call "free browsing, paid downloads" — though "paid" is a simplification. The actual system involves download quotas, in-app currency exchanges, and a creator reward economy. Let me walk through how I designed each layer.

Exploration: Completely Free, No Exceptions

The first principle I committed to was that browsing should never cost anything. Users should be able to explore the entire marketplace — searching, filtering, reading descriptions, seeing ratings — without hitting any paywall or login requirement. The marketplace should feel like walking through a bookstore, not standing outside a locked door.

The explore experience is built around four sections on the ExploreScreen: trending decks, editor's picks, a category grid, and newest uploads. This structure gives users multiple entry points depending on their intent. Someone looking for Japanese vocabulary can jump straight to the language category. Someone just browsing can scroll through trending decks and see what the community is making.

Categories and Difficulty

I settled on 7 categories after analyzing what people actually make flashcard decks for: language, exam prep, science, history, IT, daily life, and other. These are defined as shared constants (kCategoryKeys) used across the entire app — from the upload flow to the browse filters to the database schema. Having them as constants prevents the category drift you see in platforms that let users create arbitrary categories.

Difficulty uses BloomCard's existing growth metaphor with 4 levels — seed, sprout, bud, bloom — mapped through kDifficultyKeys. This reuse was deliberate. Users already understand that seed means beginner and bloom means advanced from the garden system. Applying the same vocabulary to community decks creates immediate comprehension without additional onboarding.

Tags and Filtering

Each deck can have up to 5 tags, stored in a deck_tags join table. The 5-tag limit is a UX constraint more than a technical one — I found that decks with too many tags tend to have vague, unhelpful tags. Fewer tags forces creators to be specific about what their deck covers.

The filter system is encapsulated in a DeckFilter model that combines category, difficulty, tags, and sort order. Users can filter by any combination and sort by newest, most hearts, most downloads, or highest rating. The filter UI lives in a BottomSheet, keeping the browse screen clean while offering deep filtering when you need it.

Cursor Pagination

For pagination, I went with cursor-based pagination via a Supabase RPC called browse_community_decks_v2. Offset pagination breaks down in community content because new uploads constantly shift the page boundaries — you end up seeing duplicates or missing decks as you scroll. Cursor pagination uses the last item's sort value as the anchor point, giving consistent results regardless of new uploads happening between page loads.

The browseV2() method handles this cleanly. Each page returns a cursor that the next request uses as its starting point. Combined with Flutter's scroll-based lazy loading, this creates an infinite scroll experience that stays fast even as the deck library grows to thousands of entries.

The Download Quota: Where Monetization Begins

Here's where the design gets interesting. Browsing is free, but downloading decks to your local library costs quota. Free users get 200 cards per month. Pro users get unlimited downloads.

Notice the unit: cards, not decks. This was a critical design decision. If I limited downloads by deck count, creators would have an incentive to split content into tiny decks to game the system, and users would feel cheated downloading a 5-card deck that consumed the same quota as a 500-card deck. Card-count quotas align incentives — bigger, more comprehensive decks are genuinely more valuable, and the quota reflects that.

200 cards per month is generous enough that casual users can download a few decks without feeling restricted, but limited enough that heavy users will either upgrade to Pro or engage with the water drop exchange system.

Water Drop Exchange

BloomCard has an in-app currency called water drops, earned through daily activities like studying, completing quests, and maintaining streaks. Users can exchange 50 water drops for an additional 100 download cards via the increment_bonus_cards RPC. This creates a secondary path to downloads that doesn't require money — just engagement.

The exchange rate was tuned through playtesting. 50 drops represents roughly 3-5 days of regular study, so an active free user can effectively earn an extra 100 cards every week. Combined with the base 200-card monthly quota, a dedicated free user has access to around 600 cards per month. That's more than enough for most learning scenarios.

Quota Tracking

The download_quota table tracks three values per user per month: base quota (200 for free, unlimited for Pro), bonus cards from water drop exchanges, and used cards. The increment_used_cards RPC atomically increments the used count when a download completes and checks against the total available quota.

The DownloadQuotaService exposes three methods: getQuota() to check remaining cards, recordDownload() to deduct from the quota, and exchangeDropsForQuota() to convert water drops into bonus cards. The service handles the edge case where a deck's card count would exceed the remaining quota — users see exactly how many cards they can still download before the exchange dialog appears.

I discuss the broader paywall strategy in this post, but the download quota specifically was designed to feel like a natural resource limit rather than an artificial gate. You're not being blocked — you've used your monthly allowance, and here are two clear paths to get more.

Social Features: Free, But Login Required

Everything in the social layer — hearts, reactions, bookmarks — is free. But hearts and reactions require a login. This is a deliberate conversion funnel. Users can browse anonymously, but the moment they want to engage socially, they need an account. The login_guard.dart module intercepts these actions for anonymous users and presents a login dialog.

Hearts

Hearts are the primary quality signal. One heart per user per deck, stored in the deck_hearts table. The implementation uses optimistic UI — when you tap the heart, the UI updates immediately while the network request happens in the background. If the request fails, the HeartNotifier rolls back the UI state. This makes hearts feel instant even on slow connections.

A DB trigger called prevent_self_heart prevents creators from hearting their own decks. This was added after I noticed during testing that the first thing creators did after uploading was heart their own deck. Preventing it at the database level means no client-side workaround can bypass it.

Reactions

Reactions add more nuance than a simple like/dislike. I chose 4 types — petal, lightning, book, and star — each conveying a different sentiment. Petal means "beautiful deck," lightning means "challenging," book means "educational," and star means "essential." These are stored in the deck_reactions table with the same optimistic UI pattern as hearts.

Like hearts, a prevent_self_reaction trigger stops creators from reacting to their own decks. Both triggers live in the database layer because client-side validation alone is never trustworthy for competitive features.

Bookmarks

Bookmarks are purely local, stored in SharedPreferences via BookmarkService. This was a deliberate choice — bookmarks are a personal organizational tool, not a social signal. Keeping them local means they work offline, they're instant, and they don't pollute the social metrics. Users can bookmark decks without logging in, which serves as another gentle hook into the ecosystem.

Creator Rewards: Making Content Creation Worth It

A marketplace is only as good as its content, and content comes from creators. If creating and sharing decks feels thankless, people won't do it. The creator reward system is designed to make every upload feel valued and to give creators progressive milestones to work toward.

First Upload Bonus

The moment you publish your first deck to the community, you receive 50 water drops and 30 XP. This instant reward serves two purposes: it validates the effort of creating and sharing, and it gives the creator a taste of the reward system that encourages them to keep going.

Event-Based Rewards

Every time someone hearts your deck, you earn 3 water drops and 2 XP. Every download earns you 5 water drops and 3 XP. These are small but frequent rewards that create a steady dopamine drip — you open the app and see "Your deck 'Japanese N5 Vocabulary' was downloaded 4 times today," along with the corresponding drops and XP.

To prevent gaming, daily caps are in place: 50 drops from hearts and 100 drops from downloads. Pro users get a 1.5x multiplier on all creator rewards, creating an additional incentive for active creators to subscribe.

Milestone System

Beyond event-based rewards, there are 7 milestones that mark significant creator achievements:

  • Downloads: 10, 50, 100, 500
  • Hearts: 10, 50, 100

Each milestone awards a chunk of water drops and bonus download cards. The MilestoneCelebrationOverlay presents a 3-stage animation when you hit one — it's designed to feel like a genuine achievement, not a popup to dismiss.

Milestone claims are tracked in the creator_milestone_claims table to prevent double-claiming. The check happens server-side via Supabase, so there's no way to exploit milestone rewards through client manipulation.

Creator Tiers

As creators accumulate achievements, they progress through three tiers: none, verified, and featured. Each tier is represented by a CreatorBadge rendered with CustomPainter (consistent with BloomCard's no-emoji design language). Featured creators get additional visibility in the explore screen's editor picks section.

Weekly Creator Report

Every Monday, active creators receive a notification (ID=200) with their weekly stats. The CreatorReportCard shows downloads, hearts, and drops earned over the past week, along with trends compared to the previous week. This regular feedback loop keeps creators engaged even between milestone achievements.

The materialized views weekly_popular_decks (refreshed hourly) and creator_leaderboard (refreshed weekly) power these reports and the trending sections without putting load on the main tables.

Deep Linking: Making Decks Shareable

Every published deck gets a unique share URL in the format bloomcard.9-87.org/deck/{share_slug}, where share_slug is an auto-generated 8-character string. When someone opens this link, DeckDeepLinkHandler routes them to DeckPreviewBySlugScreen, where they can preview the deck and download it (subject to their quota).

Deep linking was essential for the marketplace to grow organically. Creators share their deck links on social media, forums, and study groups. Each shared link is effectively free marketing that brings new users into the ecosystem. The 8-character slug keeps URLs short enough for social sharing while providing enough entropy (36^8 = 2.8 trillion combinations) to avoid collisions.

After completing a study session, users are also prompted to share a study card for the deck — a visual summary of their session that includes the deck's share link. This turns every study session into a potential share moment.

The Economics: Why This Model Works

Let me lay out the economic logic behind the system. The marketplace has three participant types: browsers, downloaders, and creators. The system needs to serve all three while generating revenue.

Browsers cost almost nothing to serve. They're hitting cached browse endpoints and materialized views. They see ads (BloomCard shows ads to free users), so they generate some revenue even without converting. More importantly, browsers create the perception of an active, vibrant marketplace, which encourages creators to upload and downloaders to engage.

Downloaders are the conversion engine. The 200-card monthly quota is the primary Pro conversion trigger for power users. Someone who downloads 3-4 decks in a month will hit the limit and face a clear choice: upgrade to Pro for unlimited downloads, or exchange water drops for more quota. Either way, they're deeply engaged.

Creators are the content engine. The reward system costs water drops (free to mint) and XP (free to award), but it produces the content that attracts browsers and downloaders. The Pro 1.5x multiplier on creator rewards creates a virtuous cycle — the most active creators are also the most likely to subscribe, and their subscription funds the platform that hosts their content.

The water drop exchange acts as a pressure relief valve. Users who can't or won't pay for Pro can still access more downloads through engagement. This prevents the frustration that drives users away from hard-paywall systems. But the exchange rate is calibrated so that Pro is always the better deal for anyone downloading more than a few hundred cards per month.

Database Architecture

The community feature adds several tables to the Supabase schema: deck_categories, tags, deck_tags, deck_hearts, deck_reactions, creator_milestone_claims, and download_quota. The decks table was extended with columns for category_id, difficulty, heart_count, share_slug, is_featured, report_count, and description.

Two materialized views handle the heavy analytical queries: weekly_popular_decks refreshes every hour for the trending section, and creator_leaderboard refreshes weekly for creator rankings. Using materialized views here was essential — running these aggregations on every browse request would be unsustainably expensive as the deck library grows.

All community RPCs run through Supabase's Row Level Security. The browse_community_decks_v2 RPC only returns published, non-reported decks. The increment_used_cards RPC checks quota limits atomically. The milestone claim RPCs verify eligibility server-side before granting rewards.

Routing

The community section has its own route namespace:

  • /community — the main browse screen (CommunityBrowseScreen)
  • /community/bookmarks — your bookmarked decks (BookmarkedDecksScreen)
  • /community/share/:slug — deck preview via deep link (DeckPreviewBySlugScreen)
  • /community/creator/:userId — creator profile (CreatorProfileScreen)
  • /explore — the explore screen with trending, editor picks, and categories

The separation between /community and /explore is intentional. Explore is the discovery surface — curated, editorial, designed to surface the best content. Community browse is the full catalog — filterable, sortable, comprehensive. Different intents, different screens.

What I'd Do Differently

Looking back, there are a few things I'd reconsider. The 200-card monthly quota feels right for now, but I should have built in A/B testing infrastructure from the start to experiment with different limits. Is 200 optimal, or would 150 convert more users to Pro without increasing churn? I don't have data to answer that yet.

The bookmark system being purely local means users lose their bookmarks when they switch devices. I chose local storage for simplicity and offline access, but a hybrid approach — local-first with optional cloud sync for logged-in users — would have been better.

The reaction types (petal, lightning, book, star) are fixed in the codebase. Adding new reactions requires an app update. A configuration-driven approach where reaction types come from the server would have been more flexible, especially for seasonal or event-specific reactions.

The Bigger Picture

The community marketplace isn't just a feature — it's the flywheel that makes BloomCard sustainable. Creators upload decks, which attract learners, who download decks and generate rewards for creators, who upload more decks. The download quota creates a natural conversion point for Pro subscriptions, which fund the platform. Water drops create a secondary economy that keeps free users engaged and producing content.

Every piece of the system — free browsing, card-count quotas, creator milestones, deep linking, the water drop exchange — exists to keep this flywheel spinning. Remove any one piece and the system weakens. The marketplace design isn't about any single feature; it's about how all the features interact to create a self-sustaining ecosystem.


BloomCard is a garden-themed flashcard app built with Flutter. The community marketplace is designed to balance open exploration with sustainable monetization — if you're building a UGC platform, I hope this breakdown of the trade-offs is useful.

Designing a Community Deck Marketplace — Free Browsing, Paid Downloads