Skip to content
Back to developers
SAMPLE GUIDE · REACT NATIVE

Your React Native paywall, server-driven.

@onlang/react-native renders ONLang flows with your own React components: no web views, no downloaded code. This sample takes the paywall you ship today and serves it through ONLang, with today’s screen as the fallback.

YOU NEED
  • An ONLang project: onlang init in your app’s repository
  • Your app’s publishable key (onl_pk_…) and the delivery and ingest URLs
  • React Native 0.74+ (Expo or bare), with AsyncStorage
  • The paywall you ship today: it becomes the fallback
  1. 01

    Install the SDK

    Pure JavaScript: no native module, no prebuild, no pod install. It renders with your own React components.

    terminal
    npm install @onlang/react-native @react-native-async-storage/async-storage

    The package is distributed to early-access teams; it is not on the public npm registry yet.

  2. 02

    Describe your components

    The registry is the contract between your app and the server: which components exist, which props they take, and how long a text may be. The compiler checks every hypothesis against it.

    onlang/registry.json
    {
      "registry": "acme_fitness",
      "version": "1.0.0",
      "products": ["annual", "monthly"],
      "components": {
        "PaywallIntro": { "props": {
          "headline": { "type": "token", "max_chars": 40 },
          "body": { "type": "token", "max_chars": 90 } } },
        "PlanToggle": { "props": {
          "products": { "type": "products", "min_items": 2, "max_items": 2 },
          "default": { "type": "product" } },
          "interactive": true, "emits": ["select"] },
        "PurchaseButton": { "props": {
          "title": { "type": "token", "max_chars": 28 } } }
      }
    }
  3. 03

    Register them in the app

    Each registered component is your own React component. It receives the props the server chose, already resolved: store prices filled in, copy in the user’s locale.

    src/onlang/registry.tsx
    import { OnlangRegistry, type ComponentProps } from "@onlang/react-native";
    
    function PaywallIntro({ props }: ComponentProps<{ headline: string; body: string }>) {
      return <Intro title={props.headline} body={props.body} />;
    }
    
    // Interactive: handles its own touches, and reports them to ONLang
    function PlanToggle({ props, context }: ComponentProps<{ products: string[]; default: string }>) {
      return <Toggle plans={props.products} initial={props.default}
        onChange={(plan) => context.fire("onlang://select?product=" + plan)} />;
    }
    
    // Not interactive: no Pressable of its own. ONLang wraps it and routes the tap
    function PurchaseButton({ props }: ComponentProps<{ title: string }>) {
      return <View style={styles.button}><Text>{props.title}</Text></View>;
    }
    
    export const registry = new OnlangRegistry()
      .register("PaywallIntro", PaywallIntro)
      .register("PlanToggle", PlanToggle, { interactive: true })
      .register("PurchaseButton", PurchaseButton);
  4. 04

    Configure once, at startup

    Billing and prices stay yours. ONLang asks your purchase handler to buy a product and records the outcome; prices come from your store, never from the server.

    app/_layout.tsx
    import AsyncStorage from "@react-native-async-storage/async-storage";
    import { Onlang } from "@onlang/react-native";
    import { registry } from "@/onlang/registry";
    
    Onlang.configure({
      app: "acme_fitness",
      baseUrl: "https://delivery.example.com",
      ingestUrl: "https://ingest.example.com",
      sdkKey: "onl_pk_…",                       // publishable: it ships in the app
      registry,
      storage: AsyncStorage,
      purchases: {
        purchase: async (product) => ((await buy(product)) ? "purchased" : "cancelled"),
        restore: async () => hasActiveSubscription(),
      },
      // {{price.annual}} and friends; give it a timeout, the flow waits for it
      store: async (products) => ({
        price: await localizedPrices(products),  // e.g. { annual: "$59.99" }
        period: { annual: "year", monthly: "month" },
        trial: { annual: "7 days" },
      }),
      legalUrls: { terms: "https://acme.example/terms", privacy: "https://acme.example/privacy" },
    });
  5. 05

    Show a placement

    Your current paywall becomes the fallback: shown when nothing can load (first launch offline) and when the placement is switched off.

    src/screens/paywall.tsx
    import { OnlangFlow } from "@onlang/react-native";
    
    export function PaywallScreen() {
      return (
        <OnlangFlow
          placement="paywall"
          onFinished={(result) => {
            // "purchased" | "restored" | "closed" | "completed" | "failed" | "killed"
            if (result.outcome !== "failed" && result.outcome !== "killed") navigation.goBack();
          }}
          renderFallback={() => <ClassicPaywall />}
          renderLoading={() => <Spinner />}
        />
      );
    }
  6. 06

    Write a hypothesis, publish, experiment

    Hypotheses are code in your repository. onlang lint compiles every combination and checks it (lengths, price disclosure, legal links) before anything ships.

    terminal
    onlang hypothesis create --id paywall.cta.copy --kind tokens --screen paywall \
      --key cta.title --rationale "Name the outcome, not the step" \
      --variant "outcome=Get my plan" --variant "trial=Try Pro free for {{trial.annual}}"
    onlang lint                                   # every assignment compiled and checked
    ONLANG_TOKEN=onl_sk_… onlang publish          # delivery serves the release at once
    ONLANG_TOKEN=onl_sk_… onlang experiment create --placement paywall --holdout 0.1 --start
    ONLANG_TOKEN=onl_sk_… onlang experiment status <id>   # uplift vs holdout, 95% interval
  7. 08

    Know where the off switch is

    One command takes the placement off ONLang. Delivery answers within a second, and the SDK shows your fallback, even on devices that go offline afterwards, until you switch it back on.

    terminal
    onlang kill paywall --reason "INC-12: CTA does nothing on Android 15"
    onlang kill --status
    onlang kill paywall --lift

What the SDK does for you

  • Loads network → cache → your fallback; bundles are content-addressed and verified (SHA-256)
  • Records the same events as the iOS and Android SDKs: impressions, scroll depth, taps, rage and dead clicks, plan switches, purchases
  • Measures visibility on your ScrollView with DivKit’s rules, and pauses dwell in the background
  • Queues events on the device, batched and retried until accepted; ingest drops duplicates

Rules of thumb

  • Billing, prices and entitlement stay in your app; ONLang never charges anyone
  • A component that isn’t interactive must not capture touches
  • Copy that has to be true about the device or the account belongs in a component, not a token
  • Give the store provider a timeout: the flow waits for it

Verified on an Expo SDK 57 app (React Native 0.86, new architecture) on Android. The SDK’s suite runs the same 45 golden and fuzz bundles and the same event streams as the iOS and Android SDKs.

Bring your React Native paywall.

Early-access teams get the SDK, the CLI and a hand with the first placement.

Request early access