Consent sheet (GDPR / CCPA)
Set up Consent sheet (GDPR / CCPA) for privacy consent in an Expo app with ReadyNative: asks before analytics + crash start (EU/EEA, UK, Switzerland…
Expo Go: yes
Runs in Expo Go; no dev build needed for this module.
This asks before anything is sent: src/lib/consent.ts implements the @/lib/consent contract on top of the core consent store, and ConsentProvider (order 85, in src/components/consent-sheet.tsx) mounts the sheet above the app. The analytics/* and crash/* modules follow the consent store from import time on and only create their SDK clients once the matching category is consented to, so PostHog, Amplitude and Sentry send nothing - no events, no flags request, no crash report - until the user says yes where the law wants that. Setup picks this option automatically whenever an analytics or crash module is selected (and none when neither is); pass --consent to override.
Setup
bun run setup --consent consentSetup installs expo-localization (for the device region), registers ConsentProvider, and adds the sheet and its tests. The only knob is in readynative.config.ts:
privacy: { askEverywhere: false }, // true: ask every user, whatever the regionHow it decides
| Device | Undecided reads as | Sheet |
|---|---|---|
Any locale region (region setting or language region) in the EU/EEA, UK, Switzerland, Canada or Brazil, a Europe/* time zone, or no region at all | false | shown once, after onboarding |
| Anywhere else (California included) | true | never; Settings → Privacy has switches |
privacy.askEverywhere: true | false | shown to everyone |
CA is Canada - a device cannot tell US states apart, so California users get the CCPA opt-out ("Do not sell or share", below) rather than a sheet.
consent.needsPrompt() is true when the stored record is not for the current CONSENT_VERSION (src/stores/consent.ts) and the device is in the ask group. Once the sheet is dismissed (any way - "Only necessary" is the default for Android back / swipe down) the record is written and it does not show again. Raise CONSENT_VERSION when the sheet asks for something new: every user in the ask group is asked again, and until they answer their old choices read as undecided.
The record persists under readynative:consent and survives sign-out (wipeLocalData() keeps it):
interface ConsentState {
analytics: boolean | null; // null = undecided
crash: boolean | null;
doNotSell: boolean;
version: number | null; // CONSENT_VERSION the answer was given to
decidedAt: string | null; // ISO dates
updatedAt: string | null;
source: "sheet" | "settings" | "do-not-sell" | "legacy" | null;
}The sheet and the Settings switches only show the categories this build has (privacyCategories in src/lib/privacy-categories.ts: analytics when an analytics module is selected, crash when a crash module is). With neither, the sheet never opens.
Usage
import { consent } from "@/lib/consent";
if (consent.get().analytics) analytics.track("paywall_seen");
const { crash } = consent.useConsent(); // reactive, inside a component
consent.set({ analytics: false }); // what the Settings switches do
consent.setDoNotSell(true); // the CCPA switch: analytics reads as off while it is onconsent.reprompt() shows the sheet now, whatever the region and the stored answer, until it's answered; Settings → Developer tools uses it.
Adding a category (say marketing): add it to ConsentCategory and ConsentState in src/stores/consent.ts, a row in consent-sheet.tsx and Settings, include it in privacyCategories, and read it where you need it.
Gotchas
- The sheet waits 400 ms after the first screen so
useOnboardingRedirectcan win; on theonboardingroute it never opens.ConsentProviderloads the onboarding store itself, so withonboarding/offthe sheet opens on the first screen. - Region comes from the device (locales and time zone), not IP. A French user with a US region and a US time zone sees no sheet; the Settings switches are always there. Set
privacy.askEverywhereif that trade-off is not good enough for you. - With AsyncStorage the consent store loads asynchronously, so events fired before it has loaded (the first screen view) are dropped rather than sent without a known answer. kv-store and MMKV load it synchronously.
- This is a consent mechanism, not legal advice: your privacy policy still has to say what you collect (see
bun run gen:privacy).
Remove it
While modules/ exists (a tree set up with --keep-modules), setup does all of it:
bun run setup --consent none --yes --keep-modulesIn a finalized tree setup is a stub, so you undo it by hand. Here is everything this module added:
- Delete the files that are still there:
src/components/__tests__/consent-sheet.test.tsx,src/components/consent-sheet.tsx,src/lib/__tests__/consent.test.ts. - Replace, don't delete
src/lib/consent.ts: core code imports it, so swap in the no-op version frommodules/consent/none/files/of a fresh clone of your tier repo - same exports, nothing behind them. - Uninstall the dependencies:
bun remove expo-localization. Keep any ofexpo-localization(also used byi18n/i18next,i18n/lingui,analytics/posthog) that another module you picked still needs. - Drop the config plugin
expo-localizationfrom.readynative.json→modules.app.expo.plugins(that is whereapp.config.tsreads it from), then rebuild the dev build. - Unwrap the provider: delete
<ConsentProvider>and its import fromsrc/providers.tsx. - Check it:
bun run typecheckandbun run lintpoint at anything that still imports the removed files;bun run gen:graphrefreshesdocs/ARCHITECTURE.md.
Reference
Everything below is generated from modules/consent/consent/module.json - the same file bun run setup reads, so it is what actually lands in your repo.
Install
bun run setup --consent consentModule id: consent/consent (the default for this category).
Dependencies
| Package | Version | Kind |
|---|---|---|
expo-localization | ~57.0.2 | dependency (expo install) |
Config plugins
Merged into app.config.ts through .readynative.json (modules.app):
expo-localization
Providers
Rendered in src/providers.tsx (lower order = outermost):
| Order | Provider | From |
|---|---|---|
| 85 | ConsentProvider | @/components/consent-sheet |
After setup
- Consent: test it - set the simulator region to Germany (Settings → General → Language & Region) and relaunch: the sheet appears after onboarding. Set region and time zone to the US: no sheet, and the Settings → Privacy toggles are on.
privacy.askEverywhere: truein readynative.config.ts asks everyone. - Consent: the sheet text is yours to edit in src/components/consent-sheet.tsx; keep 'Only necessary' as easy to reach as 'Accept all' (that is what regulators check).
Files
4 files copied to the project root
src/components/__tests__/consent-sheet.test.tsxsrc/components/consent-sheet.tsxsrc/lib/__tests__/consent.test.tssrc/lib/consent.ts