@synqid/react-native
The React Native + Expo SDK. Wraps @synqid/js; adds RN-native
sheets, secure storage, deep links, and a wallet-adapter bridge for
Solana. Same primitives as @synqid/react, but
the modal is a bottom sheet (Gorhom or your own), tokens live in
the keychain, and OAuth happens through a system browser via
expo-web-browser.
AI agent? Fetch
/llms.txtfor a one-file index of every SDK install command and copy-pasteable snippet. RN notable gotchas: deep-linkredirectUri(e.g.myapp://...) + the matching scheme inapp.json, both registered in the Dashboard App’s redirect URIs.
Install
pnpm add @synqid/react-native @synqid/js
pnpm add expo-secure-store expo-web-browser expo-linking @gorhom/bottom-sheetFor bare React Native (non-Expo):
pnpm add @synqid/react-native @synqid/js
pnpm add react-native-keychain react-native-inappbrowser-reborn @gorhom/bottom-sheetAdd the redirect scheme to your app config.
// app.json (Expo)
{
"expo": {
"scheme": "myapp"
}
}# ios/Podfile (bare RN) — add to the relevant target
ios.url_types = [{ url_schemes: ['myapp'] }]<!-- android/app/src/main/AndroidManifest.xml (bare RN) -->
<activity android:launchMode="singleTask">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
</intent-filter>
</activity>Provider
import { SynqProvider } from '@synqid/react-native'
export default function App() {
return (
<SynqProvider
issuer="https://synq.id"
clientId={process.env.EXPO_PUBLIC_SYNQ_CLIENT_ID!}
redirectUri="myapp://oauth/callback"
>
<Root />
</SynqProvider>
)
}The redirectUri is your deep link. Register the matching App
redirect URI in your Synq Brand’s App settings (e.g.
myapp://oauth/callback).
Options
interface SynqProviderProps {
issuer: string
clientId: string
redirectUri: string
scopes?: string[] // default request scopes
walletAdapter?: NativeWalletAdapter // see below
uiTheme?: SynqNativeTheme // colors, spacing, fonts
storage?: SecureStorage // SecureStore by default
fetch?: typeof fetch // override fetch
}Hooks
import { useUser, useSynq } from '@synqid/react-native'
function Welcome() {
const user = useUser()
const { requestSignIn, signOut } = useSynq()
if (!user) {
return (
<Button title="Sign in" onPress={() => requestSignIn()} />
)
}
return (
<>
<Text>Hi {user.firstName}</Text>
<Button title="Sign out" onPress={signOut} />
</>
)
}useUser() returns the current user or null. Re-renders on
sign-in/out. useSynq() returns the full client.
Sign-in sheets
Mount them once near your root:
import { SignInSheet, ConnectSheet } from '@synqid/react-native'
<SynqProvider {...config}>
<YourApp />
<SignInSheet />
<ConnectSheet />
</SynqProvider><SignInSheet> opens via requestSignIn(). <ConnectSheet> opens
via requestConnect() (for linking a wallet after the user is
already signed in).
Three customization tiers
Same model as @synqid/react. Pick the lightest that fits.
Tier 1 — theme defaults
Three ways to set a theme, mirroring @synqid/react. RN-native
values: numbers for radii/spacing, string font weights, no CSS.
1. Pick a preset. Built-in: dark | light | glass |
monochrome | brutalist.
<SynqProvider {...config} uiTheme="glass" />2. Override tokens. Deep-merged onto the active preset.
<SynqProvider
{...config}
uiTheme={{
preset: 'dark',
primary: '#5FD8CA',
primaryForeground: '#0A0612',
radius: 16, // sugar — sets all radius* scales
fontFamilyDisplay: 'Inter',
}}
/>3. Split light + dark. With colorScheme="system" the sheet
re-renders when iOS / Android system theme changes
(useColorScheme()).
<SynqProvider
{...config}
colorScheme="system"
uiTheme={{
light: { preset: 'light', primary: '#0A6E5E' },
dark: { preset: 'dark', primary: '#5FD8CA' },
}}
/>Tokens catalog
| Group | Tokens |
|---|---|
| Colors | primary, primaryForeground, primaryHover, surface, surfaceElevated, border, borderHover, textPrimary, textMuted, textOnPrimary, danger, success, warning, backdrop, sheetHandle |
| Typography | fontFamily, fontFamilyDisplay, fontFamilyMono, fontWeightHeadline, fontWeightButton |
| Spacing (px) | spacingXs, spacingSm, spacingMd, spacingLg, spacingXl |
| Radii (px) | radiusSm, radiusMd, radiusLg, radiusFull, radius (sugar — sets all radius*) |
Bring-your-own provider icons
Pass a React component or a URI string per provider. (RN does not
support inline SVG markup — use a react-native-svg component.)
<SynqProvider
{...config}
providerIcons={{
google: <GoogleSvg />,
apple: 'https://cdn.acme.com/apple.png',
}}
/>Missing entries fall back to a single-letter monogram.
Slot-level style overrides
<SynqProvider
{...config}
signInSheetStyles={{
background: { backgroundColor: '#0F0824' },
title: { fontFamily: 'Inter-Bold', fontSize: 22 },
providerButton: { borderRadius: 28 },
}}
connectSheetStyles={{
walletButton: { borderRadius: 28 },
}}
/>Slots — sign-in: background, handle, container, title,
providerButton, providerButtonText, walletButton,
walletButtonText. Connect: background, handle, container,
title, walletButton, walletButtonText.
Default markup renders a Gorhom bottom sheet with a draggable handle, provider list, optional wallet button, and swipe-to-dismiss.
Tier 2 — compound parts
import { SignIn } from '@synqid/react-native'
<SignIn>
<SignIn.Sheet snapPoints={['65%', '90%']}>
<SignIn.Handle />
<SignIn.Header>
<SignIn.Logo />
<SignIn.Title>Welcome to Acme</SignIn.Title>
<SignIn.Description>
Sign in or create an account.
</SignIn.Description>
</SignIn.Header>
<SignIn.ErrorBanner />
<SignIn.ProviderList>
<SignIn.Provider id="google" />
<SignIn.Provider id="apple" />
</SignIn.ProviderList>
<SignIn.Divider>or</SignIn.Divider>
<SignIn.WalletButton />
<SignIn.Footer>
<SignIn.Terms />
</SignIn.Footer>
</SignIn.Sheet>
</SignIn>Every part accepts style and asChild (for native, “asChild”
means pass a single child component that receives ref + behavior
props):
<SignIn.Provider id="google" asChild>
<MyButton size="lg">Continue with Google</MyButton>
</SignIn.Provider>Tier 3 — render prop
<SignIn>
{(ctx) => (
<MyCustomSheet visible={ctx.isOpen} onClose={ctx.close}>
<FlatList
data={ctx.providers}
renderItem={({ item: p }) => (
<Pressable
disabled={ctx.isLoading}
onPress={() => ctx.signIn(p.id)}
>
<Image source={{ uri: p.iconUrl }} />
<Text>Continue with {p.name}</Text>
</Pressable>
)}
/>
{ctx.error && <Text style={styles.error}>{ctx.error.message}</Text>}
</MyCustomSheet>
)}
</SignIn>ctx shape:
interface SignInRenderContext {
isOpen: boolean
close: () => void
providers: Array<{
id: ProviderId
name: string
iconUrl: string
recommended: boolean
}>
walletAvailable: boolean
isLoading: boolean
loadingProvider: ProviderId | null
error: SynqAuthError | null
signIn: (providerId: ProviderId) => Promise<void>
signInWithWallet: () => Promise<void>
}You own the markup; Synq owns the side effects.
Wallet adapter (Solana)
import { useSolanaMwaAdapter } from '@synqid/react-native'
function App() {
const walletAdapter = useSolanaMwaAdapter()
return (
<SynqProvider {...config} walletAdapter={walletAdapter}>
<Root />
</SynqProvider>
)
}useSolanaMwaAdapter selects the right adapter at runtime:
- Android — uses Mobile Wallet Adapter (
@solana-mobile/mobile- wallet-adapter-protocol). Connects to Phantom, Solflare, etc. via the OS-level wallet picker. - iOS — uses Phantom’s deep-link protocol (Phantom is the only
iOS wallet with a stable deep-link API as of writing). Bring a
different iOS adapter via
walletAdapterprop if you ship on other wallets.
Custom adapter shape (e.g. you’re building a wallet of your own):
interface NativeWalletAdapter {
publicKeyBase58: string | null
connected: boolean
connect(): Promise<void>
disconnect(): Promise<void>
signMessage(message: Uint8Array): Promise<Uint8Array>
}Token storage
Tokens are stored in expo-secure-store (Keychain on iOS,
EncryptedSharedPreferences on Android). Each clientId gets its own
storage namespace so multiple Synq apps on the same device don’t
collide.
Override for bare RN with react-native-keychain:
import { SecureStorageReactNativeKeychain } from '@synqid/react-native/storage'
<SynqProvider
{...config}
storage={new SecureStorageReactNativeKeychain({ service: 'com.acme' })}
>Custom shape:
interface SecureStorage {
getItem(key: string): Promise<string | null>
setItem(key: string, value: string): Promise<void>
deleteItem(key: string): Promise<void>
}Deep-link handling
The OAuth redirect flow:
requestSignIn()opensexpo-web-browserto Synq’s/oauth2/authorize.- User completes sign-in.
- Synq redirects to your
redirectUri(myapp://oauth/callback? code=…&state=…). - iOS/Android routes the URL to your app via
expo-linking. @synqid/react-nativeintercepts the URL, dismisses the browser, exchanges the code, stores the tokens, renders signed-in state.
You don’t need to handle the deep link yourself — the SDK registers a listener at mount.
For bare React Native without Expo, the equivalent flow uses
react-native-inappbrowser-reborn + Linking from React Native;
the SDK’s BareRnProvider swaps the browser/linking modules out.
Animations
The default sheet uses Gorhom’s spring animations. Override:
<SignIn.Sheet
snapPoints={['65%', '90%']}
animationConfigs={{
damping: 18,
stiffness: 140,
mass: 1.2,
}}
>For users with Reduce Motion enabled, animations are dampened to
< 100ms.
Accessibility
Default sheets cover:
- VoiceOver / TalkBack focus traps (the sheet content becomes the accessible root).
- Returns focus to the opener (your sign-in button) on close.
- Accessibility labels on every provider button.
- Announces loading and error state via
accessibilityLiveRegion. - Honors
Reduce Motion,Larger Text, and dark-mode color schemes via theuiThemeprop.
Programmatic open
Open from anywhere with useSynq():
const { requestSignIn, requestConnect } = useSynq()
await requestSignIn({
title: 'Sign in to continue',
recommendedProviders: ['google'],
})
// resolves with User | null
const walletAddress = await requestConnect({
title: 'Connect a wallet to claim',
})
// resolves with string | nullBackground refresh
When the user backgrounds your app and returns, the SDK calls
getSession() automatically (which refreshes the access token if
near expiry). No code from you needed.
If your app has a server backend, prefer storing the refresh token on the server (via a backend session). The SDK then only holds an access token client-side, refreshed via a backend endpoint. This limits the blast radius if a device is compromised.
Errors
Same error catalog as @synqid/js. Surface via hooks:
const { signInError } = useSynq()
if (signInError?.code === 'access_denied') {
// user cancelled
}Mental model summary
<SynqProvider>once at the root.<SignInSheet>and<ConnectSheet>once near it.- Same three customization tiers (theme defaults, compound, render prop) — RN-flavored.
- Tokens in the OS keychain. One namespace per
clientId. - Wallet adapter is pluggable. MWA on Android by default; Phantom deep link on iOS by default; bring your own for other wallets.
- Background → foreground transitions auto-refresh the session. You don’t think about it.