npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-native-remote-paywall

v0.1.0

Published

Update your React Native paywall without shipping an app update. Zero dependencies.

Readme

react-native-remote-paywall

Update your React Native paywall without shipping an app update. Zero dependencies.

Editing the paywall JSON in VS Code, then reopening the running iOS app to see the new title, button text and colors

Left: the paywall JSON, edited and saved. Right: the running app, closed and reopened — new title, button text and colors. No rebuild, no reload, no app update.

Your paywall is a JSON document you host. The SDK fetches it, validates it, caches it, and renders it into React Native primitives. Change the headline, the trial framing, the order of the plans — publish the JSON, and the next person who opens the paywall sees it. No release, no review, no staged rollout.

  • Zero runtime dependencies. react and react-native are peers. Nothing else ships, so nothing else can break your build.
  • No code crosses the wire. Nodes are data; buttons carry named intents, not handlers. There is no expression language and nothing to eval — which is what keeps this on the right side of App Store rule 2.5.2.
  • Never a blank screen. Fresh document → last cached document → the copy you shipped in the binary. In that order, every time.
  • Your purchase stack stays yours. The SDK never talks to StoreKit or Play Billing. It calls your callbacks and gets out of the way.

Installation

npm install react-native-remote-paywall

Quick start

import {
  createPaywallClient,
  RemotePaywall,
} from 'react-native-remote-paywall';
import defaultPaywall from './paywalls/default.json';

const paywalls = createPaywallClient({
  // → https://cdn.example.com/paywalls/default.json
  endpoint: 'https://cdn.example.com/paywalls',

  // Shipped inside the binary. The last line of defence — see below.
  bundled: { default: defaultPaywall },

  host: {
    resolveProducts: async (productIds) => askYourStore(productIds),
    onPurchase: async (productId) => purchases.buy(productId),
    onRestore: async () => purchases.restore(),
    onDismiss: () => navigation.goBack(),
  },
});

// At app start: warms the document, its products and its images, so the
// paywall opens instantly later. Failures here are swallowed by design.
paywalls.prefetch('default');

// Wherever the paywall belongs.
<RemotePaywall client={paywalls} id="default" />;

createPaywallClient takes:

| Option | Default | Notes | |---|---|---| | endpoint | — | Document URL is `${endpoint}/${id}.json`. Required unless buildUrl is given. | | buildUrl | — | (id) => string. Full override when your URLs don't follow that shape. | | host | — | Required. Your purchase stack — see below. | | bundled | {} | { [id]: PaywallDocument } shipped in the binary. | | storage | in-memory | Anything with get/set — see below. | | requestTimeoutMs | 10000 | Aborts the fetch. | | coldStartTimeoutMs | 700 | Bounds the first-show wait. Never aborts the fetch. |

It throws if you supply neither endpoint nor buildUrl, and id must match ^[a-zA-Z0-9_-]{1,64}$. Those are the only two throws in the SDK, and both fire on a developer's first run rather than in a user's hands. Everything else degrades.

<RemotePaywall> takes client, id, and optional renderLoading / renderError render props. Both default to rendering nothing.

What renders, and when

The whole point of the library is the answer to "what does the user see while the network is being the network". There are three cases, and the SDK picks between them for you:

| State on open | What shows | What happens behind it | |---|---|---| | A cached document exists | The cached document, immediately | Refreshes in the background; the new revision lands in cache for the next open | | No cache, but bundled has this id | The network document if it arrives within coldStartTimeoutMs, otherwise the bundled one | The losing fetch keeps going and writes cache for the next launch | | Neither | renderLoading() until the fetch lands | On success, the document; on failure, renderError() and one onError call |

A paywall never waits on the network longer than coldStartTimeoutMs when it has anything better to show. That is the entire design.

Wiring your purchase stack

interface PaywallHost {
  resolveProducts(productIds: string[]): Promise<ResolvedProduct[]>;
  onPurchase(productId: string): Promise<void>;
  onRestore(): Promise<void>;
  onDismiss(): void;
  onOpenURL?(url: string): void;
  onEvent?(event: PaywallEvent): void;
  onError?(error: unknown): void;
}

Prices come from resolveProducts, never from the document — that is how {{package.price}} shows the right currency for the user's storefront without your server knowing anything about storefronts.

While onPurchase or onRestore is in flight the SDK holds a busy lock: buttons disable, a second tap in the same tick is dropped, and the button announces itself as disabled rather than silently ignoring taps. onDismiss and onOpenURL are deliberately not gated — a host promise that never settles must not trap the user on the screen. If one never settles, a 30 s watchdog releases the lock and emits host_callback_timeout.

onError fires at most once per paywall, and only when there is nothing to show at all. A failed background refresh with a paywall already on screen is not an error.

Persisting the cache

Storage defaults to in-memory, which is what keeps the dependency count at zero. Pass a real adapter and the cache survives an app restart — which is what makes the second launch render instantly and work on a plane:

import AsyncStorage from '@react-native-async-storage/async-storage';

storage: {
  get: (key) => AsyncStorage.getItem(key),
  set: (key, value) => AsyncStorage.setItem(key, value),
},

Or with MMKV:

const mmkv = new MMKV();

storage: {
  get: async (key) => mmkv.getString(key) ?? null,
  set: async (key, value) => { mmkv.set(key, value); },
},

set must be atomic per key — a reader sees the old value or the new one, never half a document. Both of the above are. Keys are namespaced rnrp:v1:doc:<id>.

Publishing the document

Serve it with Cache-Control: max-age=60. Sixty seconds is short enough that a fix reaches users the same minute you publish it, and long enough that a CDN absorbs a launch spike instead of your origin.

Ship a bundled document for every id you use. It is not a nicety. It is what the user sees on a first launch with no network, and it is the reason this library never renders a blank screen. Worst case, someone sees a paywall a year out of date — which still sells, where a spinner does not.

Two things that will bite you

An empty placeholder renders as an empty string. "Try free for {{package.trialLength}}" becomes "Try free for " when the store returns no trial. It never leaks the raw {{...}} and never throws, but it will happily show you half a sentence. Write copy that survives the token being empty, or put trial copy only on packages that have one.

A fresh document applies on the next show, not instantly. When a cached document exists, the SDK renders it immediately and refreshes in the background; the new revision takes effect the next time the paywall opens. This is the deliberate trade for never showing a spinner where a paywall should be. Plan copy changes a session ahead — and do not use this as a kill switch.

The document format

SCHEMA.md is the contract: seven node types, a closed placeholder set, four actions, and the forward-compatibility rules that let a document published today render on a build shipped a year ago. Each rule there links to the test that proves it.

A complete document you can copy and edit lives in templates/example.json, and example/ is an Expo app that runs the whole fetch → validate → cache → render path against it.

Contributing

License

MIT