Skip to Content
GuidesBuild a Solana mobile app

Build a Solana mobile app with Synq

A complete tutorial: a fresh Expo app, Synq sign-in, Mobile Wallet Adapter wallet linking, sign-to-link against Synq, transaction signing, and a server-side webhook handler that reconciles the linked wallet into your product DB.

End-to-end, builds in about 90 minutes if you have an Expo environment set up. The result is structurally identical to what Burn & Claim and JobSkr ship.

Prerequisites: Node 22, pnpm or npm, Xcode + Android Studio if you want to run on both platforms, an Expo  account for EAS Build. A Synq Org + Brand + App registered at dashboard.synq.id .

The app you will build

A single-screen wallet-linking demo:

  • “Sign in with Synq” → branded Synq hosted login
  • “Connect wallet” → MWA prompts to pick a wallet app
  • “Link wallet” → signs a Synq-issued challenge, posts to /users/me/wallets/link
  • “Send 0.001 SOL to a self-transfer” → signs a Versioned Transaction and submits it

By the end you will understand every layer of a real Synq-on-Solana- Mobile app and have working code to extend.

Step 1 — Bootstrap the Expo app

pnpm create expo my-synq-solana --template blank-typescript cd my-synq-solana

Add the runtime peers and SDKs:

pnpm add @synqid/react-native @synqid/solana-mobile \ @solana/web3.js \ @solana-mobile/mobile-wallet-adapter-protocol \ @solana-mobile/mobile-wallet-adapter-protocol-web3js \ react-native-mobile-wallet-adapter-protocol \ react-native-get-random-values \ expo-secure-store expo-linking expo-web-browser \ buffer

Then add the MWA Expo plugin:

npx expo install expo-build-properties

In app.json:

{ "expo": { "name": "my-synq-solana", "slug": "my-synq-solana", "scheme": "mysynqsolana", "plugins": [ [ "expo-build-properties", { "android": { "compileSdkVersion": 35, "targetSdkVersion": 35 }, "ios": { "useFrameworks": "static" } } ] ], "ios": { "bundleIdentifier": "id.synq.demo.solana" }, "android": { "package": "id.synq.demo.solana" } } }

Add a polyfill for Buffer and crypto.getRandomValues in polyfills.ts at the project root:

import 'react-native-get-random-values' import { Buffer } from 'buffer' ;(globalThis as any).Buffer = Buffer

Import it at the very top of App.tsx:

import './polyfills'

You only need to do this once. The peer deps require it.

Step 2 — Register an App with Synq

In the Synq Dashboard :

  1. Create an Org. Create a Brand under it. Set logo + colors.
  2. Under the Brand, create an App named my-synq-solana-dev.
  3. Redirect URI: mysynqsolana://oauth/callback. This must match the scheme in your app.json and the path used by the SDK.
  4. Copy the App’s client_id and client_secret. Both go into .env:
# .env EXPO_PUBLIC_SYNQ_CLIENT_ID=app_xxx EXPO_PUBLIC_SYNQ_ISSUER=https://api.synq.id EXPO_PUBLIC_SYNQ_API_URL=https://api.synq.id EXPO_PUBLIC_SOLANA_RPC_ENDPOINT=https://api.mainnet-beta.solana.com

(client_secret does not ship in mobile apps — the device flow and PKCE handle the security boundary. Only Web/server clients need the secret. See Concepts → Sessions and tokens.)

Step 3 — Wire <SynqProvider>

Create src/synq.ts:

import { createSynqConfig } from '@synqid/react-native' export const synqConfig = createSynqConfig({ clientId: process.env.EXPO_PUBLIC_SYNQ_CLIENT_ID!, issuer: process.env.EXPO_PUBLIC_SYNQ_ISSUER!, redirectUri: 'mysynqsolana://oauth/callback', scopes: ['openid', 'profile', 'email'], })

In App.tsx:

import './polyfills' import { SynqProvider } from '@synqid/react-native' import { synqConfig } from './src/synq' import { Home } from './src/Home' export default function App() { return ( <SynqProvider config={synqConfig}> <Home /> </SynqProvider> ) }

Step 4 — Wire <SolanaMobileProvider>

Wrap your app’s authenticated tree:

// src/Home.tsx import { useSynqHttp, useUser } from '@synqid/react-native' import { SolanaMobileProvider } from '@synqid/solana-mobile/react-native' import { Button, SafeAreaView, Text } from 'react-native' import { WalletScreen } from './WalletScreen' export function Home() { const { user, signIn } = useUser() if (!user) { return ( <SafeAreaView> <Button title="Sign in with Synq" onPress={() => signIn()} /> </SafeAreaView> ) } return ( <AuthenticatedTree /> ) } function AuthenticatedTree() { const http = useSynqHttp() return ( <SolanaMobileProvider apiUrl={process.env.EXPO_PUBLIC_SYNQ_API_URL!} http={http} rpc={process.env.EXPO_PUBLIC_SOLANA_RPC_ENDPOINT!} cluster="mainnet-beta" identity={{ name: 'My Synq Solana', uri: 'https://example.com', icon: 'favicon.ico', }} > <WalletScreen /> </SolanaMobileProvider> ) }

The <SolanaMobileProvider> is mounted only after the user is signed in. Mounting it earlier wires http against a missing session — the SDK throws.

Step 5 — Build the wallet screen

src/WalletScreen.tsx:

import { useWallet, useLinkedWallets, useTransactionSigner, useConnection, } from '@synqid/solana-mobile/react-native' import { SystemProgram, Transaction } from '@solana/web3.js' import { useState } from 'react' import { Button, SafeAreaView, Text, View } from 'react-native' export function WalletScreen() { const { wallet, authorize, deauthorize } = useWallet() const { linkedWallets, signAndLink, refresh } = useLinkedWallets() const { signTransaction } = useTransactionSigner() const connection = useConnection() const [status, setStatus] = useState<string>('') const isCurrentLinked = !!wallet && linkedWallets.some(w => w.publicKey === wallet.address) const link = async () => { setStatus('Requesting wallet signature…') try { const linked = await signAndLink({ label: 'My demo wallet' }) setStatus(`Linked ${linked.publicKey.slice(0, 8)}…`) } catch (err) { setStatus(err instanceof Error ? err.message : 'Failed') } } const sendSelfTransfer = async () => { if (!wallet) return setStatus('Building transaction…') const { blockhash } = await connection.getLatestBlockhash() const tx = new Transaction({ recentBlockhash: blockhash, feePayer: wallet.publicKey, }).add( SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: wallet.publicKey, lamports: 1_000_000, }) ) const signed = await signTransaction(tx) const sig = await connection.sendRawTransaction(signed.serialize()) setStatus(`Sent ${sig.slice(0, 12)}…`) } return ( <SafeAreaView style={{ padding: 24, gap: 16 }}> <Text>Wallet: {wallet ? wallet.address.slice(0, 12) + '…' : '(none)'}</Text> {!wallet && <Button title="Connect wallet" onPress={authorize} />} {wallet && <Button title="Disconnect" onPress={deauthorize} />} {wallet && !isCurrentLinked && ( <Button title="Link to Synq" onPress={link} /> )} {wallet && isCurrentLinked && ( <Button title="Send 0.001 SOL (self)" onPress={sendSelfTransfer} /> )} <Text>Status: {status}</Text> <View style={{ marginTop: 16 }}> <Text style={{ fontWeight: 'bold' }}>Linked wallets ({linkedWallets.length}):</Text> {linkedWallets.map(w => ( <Text key={w.id}> {w.publicKey.slice(0, 12)}… {w.label && `— ${w.label}`} </Text> ))} </View> <Button title="Refresh linked wallets" onPress={refresh} /> </SafeAreaView> ) }

Step 6 — Build and run

For Android:

eas build --profile development --platform android

When the build finishes, install the APK on a device with Phantom or Solflare installed. Open the app, sign in, connect a wallet, link it, send the self-transfer.

For iOS:

eas build --profile development --platform ios

Install via TestFlight or eas device:create for a registered device. iOS requires a real device — the simulator doesn’t have wallet apps installed.

Step 7 — Wire the webhook (server side)

A Next.js Route Handler that listens for user.wallet.linked and reconciles to your product DB. Bring this in under any backend you already have — the code below uses the @synqid/nextjs shape.

// app/api/synq/webhook/route.ts import { verifyWebhookSignature } from '@synqid/js' export async function POST(req: Request) { const body = await req.text() const sig = req.headers.get('synq-signature') if (!sig) return new Response(null, { status: 400 }) const ok = verifyWebhookSignature({ signature: sig, payload: body, secret: process.env.SYNQ_WEBHOOK_SECRET!, }) if (!ok) return new Response(null, { status: 401 }) const event = JSON.parse(body) as { type: string; data: any; id: string } // Idempotency: dedupe on event.id. if (await alreadyProcessed(event.id)) { return new Response(null, { status: 200 }) } if (event.type === 'user.wallet.linked') { await db.userWallets.upsert({ userId: event.data.userId, walletId: event.data.walletId, publicKey: event.data.publicKey, label: event.data.label, isPrimary: event.data.isPrimary, }) } else if (event.type === 'user.wallet.unlinked') { await db.userWallets.delete({ walletId: event.data.walletId }) } else if (event.type === 'user.wallet.primary_changed') { await db.userWallets.updateMany({ where: { userId: event.data.userId }, data: { isPrimary: false }, }) await db.userWallets.update({ where: { walletId: event.data.newPrimaryWalletId }, data: { isPrimary: true }, }) } await markProcessed(event.id) return new Response(null, { status: 200 }) }

In the Synq dashboard, register this endpoint as a webhook for the events listed above. Save the webhook secret to your env vars; the signature verification uses it.

What you have at this point

A complete Synq + Solana Mobile app:

  • Branded Synq sign-in via PKCE-authorization-code flow
  • MWA wallet authorization on Android and iOS
  • Sign-to-link against Synq’s /users/me/wallets
  • Transaction signing for product flows
  • Server-side webhook ingest that keeps your product DB synced with Synq’s truth

The same code patterns extend to N wallets per user, transaction batching via signAllTransactions, deep transaction history reads via connection.getSignaturesForAddress, and any other flow Solana apps need.

Going further


If you walked this end to end and something didn’t work as documented, email support@synq.id with the step number — we use that feedback to keep this page tight.

Last updated on