BUILT INTO EVERY GOODSPEED APP
Native Share Sheet + Referral System
A share() helper wraps the native share sheet cross-platform and returns whether the user completed the share (not just opened it); a referral system generates per-user codes and attributes sign-ups and purchases server-side on the referred user's first matching event.
- Tier: Common
- Status: Config-toggled
- Config: features.growth.referral.enabled
WHY IT MATTERS
Sharing is one of those features that looks trivial until you try to build it correctly across platforms. On iOS, the native share sheet appears and then vanishes. On web, navigator.share() is only available in secure contexts and on a subset of browsers. On Android, the intent picker has its own idiosyncrasies. Most share implementations just fire the dialog and move on, leaving the app with no signal about whether anything actually happened. The share() helper in services/share.ts delegates to lib/sharing.shareContent, which handles the web fallback, clipboard copy path, and native iOS URL handling, and returns a structured result with a success boolean. The share() wrapper adds one layer on top: it embeds the caller's referral code into the message text before passing it to shareContent, and if success is true it emits an EVENTS.share_completed event to PostHog with the code attached. That PostHog event is the signal your growth dashboard uses to track shares by cohort. You know not only that a user tapped the share button, but that they completed the action and which specific referral code went out into the world.
The referral system in services/referrals.ts closes the attribution loop server-side. generateReferralCode() calls randomBase32() for an 8-character code (length is configurable in gasConfig.growth.referralCodeLength) and writes it to the referrals table with the referrer's user ID. When a referred user signs up or completes a purchase, the app calls recordAttribution(code, event) at the right moment in the flow. The function runs a conditional Supabase update: it updates the referrals row where code matches and referred_user_id is still null, which is an atomic check-and-set. If two tabs race to attribute the same code, only one wins; the other gets back an empty array from .select() and skips the event emit. A confirmed attribution fires EVENTS.referral_attributed to PostHog with the code and the triggering event name, giving you the full funnel: share sent, code distributed, sign-up attributed. The universal-deeplinks companion carries the referral code parameter from the share URL into the freshly-installed app for the cases where the user does have it installed.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from services/referrals.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// services/referrals.ts -- atomic attribution (race-safe)
export async function recordAttribution(code: string, event: string): Promise<boolean> {
const referredUserId = await getCurrentUserId();
if (!referredUserId) return false;
// Conditional update: only matches rows where referred_user_id is still null.
// Race losers receive an empty array from .select() and skip the event emit.
const { data, error } = await supabase
.from('referrals')
.update({
referred_user_id: referredUserId,
attribution_event: event,
attributed_at: new Date().toISOString(),
})
.eq('code', code)
.is('referred_user_id', null)
.select('id, code');
if (error) {
captureException(error, { service: 'referrals', op: 'recordAttribution' });
return false;
}
const attributed = !!data && data.length > 0;
if (attributed) {
captureEvent(EVENTS.referral_attributed, { code, event });
}
return attributed;
}Source: goodspeed-apps/gas-template → services/referrals.ts
HONEST LIMITS
When Native Share Sheet + Referral System is the wrong choice
The referral attribution model in services/referrals.ts assumes the referred user installs the app and reaches the attribution call-site while the referral code is still present in their session. This works well for mobile-first apps where the share URL carries the code as a deep-link parameter and the user taps the link from a device with the app already installed or installs it immediately after tapping. It does not work for deferred deep-link scenarios, where a user taps the share link on a device that does not yet have the app installed, installs from the App Store or Play Store, and only then opens the app. In that cold-install path, the OS drops the original link context: the app opens fresh with no URL parameters, and there is no code to pass to recordAttribution(). If deferred deep-link attribution is critical to your growth model, you need a dedicated service such as Branch or Adjust that uses device fingerprinting or a click-token mechanism to survive the install bounce. The template does not include this and it is not a trivial add-on. For web-only apps, the share() helper calls navigator.share() where available and falls back to clipboard copy, so the share action itself works. However, the referral attribution logic requires the receiving user to open the app and call recordAttribution() with the code. A web-only context has no install bounce to worry about, but a clipboard-copied URL has meaningfully lower completion rates than a tapped share card in iMessage or WhatsApp. If you ship a web-only product and want referral attribution, you can wire recordAttribution() to fire on page load after reading the ref query parameter from the URL. The server-side logic is unchanged; only the delivery mechanism differs.
Tier: Common · Config-toggled
Evaluate your use case
Check whether native share sheet + referral system aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
The `features.growth.referral.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Every generated Goodspeed app includes native share sheet + referral system. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
Native Share Sheet + Referral System capability breakdown
Concrete dimensions of what the built-in native share sheet + referral system implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Share completion signal | Whether the template can detect that the user actually completed the share action (not just opened the sheet). | Yes, on iOS, Android, and navigator.share() web |
| Attribution model | How the referral system matches referred users to the referrer's code. | Server-side conditional update (race-safe) |
| Cold-install attribution | Whether the code survives a fresh App Store / Play Store install bounce. | Not supported (requires Branch or Adjust) |
| Code generation | Where referral codes are generated and stored. | Client-initiated, Supabase Postgres backed |
| Analytics integration | How share and attribution events are surfaced in your analytics dashboard. | PostHog events (share_completed, referral_attributed) |
COMMON QUESTIONS
How do I know the share actually completed, and not just that the sheet opened?
This is the core problem the share() helper is designed to solve. On iOS, the native share sheet fires a completion callback when the user picks a destination and successfully sends the content; it fires a cancellation callback if the user swipes the sheet away. The lib/sharing.shareContent function wraps this callback pair and resolves its promise with { success: true } only on genuine completion. The same pattern applies to navigator.share() on the web, which returns a resolved promise on success and a rejected promise with an AbortError on cancel. The services/share.ts wrapper exposes this as { shared: boolean } so the calling component can branch cleanly. On Android the behavior matches iOS: the system intent picker returns a result code to the Activity, and the Expo React Native bridge surfaces that as the same resolved/rejected promise. The one platform where you cannot get a completion signal is the clipboard fallback on web: copying to the clipboard always reports success because it only means the text was written to the OS clipboard, not that the recipient ever received or acted on it. The share() function returns { shared: true } in this case too, because the user did complete the copy action, but PostHog events let you distinguish the delivery method if needed.
What happens if two users try to claim the same referral code at the same time?
A referral code is generated per-user and stored in the referrals table with a unique constraint on the code column, so two different users cannot hold the same code. The race condition the template guards against is different: one user shares their code with multiple people, two of those recipients sign up around the same time, and both call recordAttribution() within milliseconds of each other. The conditional update in recordAttribution() uses .is('referred_user_id', null) as a server-side guard: Supabase executes this as an atomic UPDATE WHERE referred_user_id IS NULL. Only the first concurrent update wins; the second arrives to find referred_user_id already set, matches zero rows, and gets back an empty array. No duplicate attribution event is fired. The database row records exactly one referred_user_id for the lifetime of that code. If your growth model requires tracking multiple conversions from the same shared code, you would need to change the schema to a one-to-many referrals_conversions table and update recordAttribution accordingly. The current model is intentionally one-code, one-attribution.
Does the referral code survive the iOS App Store install bounce?
No, and this limitation is documented explicitly in the when-not-to-use section above. The referral attribution in the template assumes the app is already installed on the device receiving the share. When a user taps the share URL on a device that does not have the app installed, they are redirected to the App Store or Play Store. After installation, the app opens to its default entry point with no URL parameters. The referral code that was in the original URL is lost. This is sometimes called the deferred deep-link gap. Solving it requires a service like Branch or Adjust that captures the click-token before the App Store redirect and re-injects the parameters into the first app open via device fingerprinting or a custom URL scheme that the install process preserves. The template does not bundle any of those services. If your growth model depends on attributing installs from referral shares, plan to integrate a deferred deep-link provider on top of this template's referral infrastructure.
Can I pass a custom referral landing page URL in the share message?
Yes. The share() function accepts an optional url parameter alongside code, subject, and message. If you provide a url, shareContent appends it to the native share payload as the URL field, which iOS renders as a live link card in iMessage and WhatsApp rather than plain text. A typical setup is to generate a link such as https://yourapp.com/join?ref=ABCD1234, where /join is a marketing landing page that detects the ref query parameter, stores it in a cookie or localStorage, and then redirects to the App Store or Play Store. If you also integrate universal deep links, a device with the app already installed will bypass the landing page entirely and open the app directly with the code intact. The gasConfig.growth.referralLandingPage setting is where you configure the base URL for referral links; generateReferralCode() does not auto-generate the URL, so you construct the full link in your component before passing it to share(). The landing page and web layer are outside the template scope, but the share() contract is designed to accept them without modification.
GET IT BUILT INTO YOUR APP