Build a Dual-Chain Mini App with Nimiq Pay
In this tutorial, you will build a mini app that uses both injected providers:
- the Nimiq provider for Nimiq account and signing flows
- the Ethereum provider for EIP-1193 account and signing flows
You will request accounts and signatures through the wallet. The provider returned from init() throws Nimiq wallet errors, which the example handles in catch; see wallet errors.
What you'll build
The mini app includes two action buttons:
| Flow | Methods | User confirmation expected |
|---|---|---|
| Nimiq | listAccounts() -> sign() | Account sharing when needed, then signing |
| Ethereum | eth_requestAccounts -> personal_sign | 2 prompts (account connection, signing) |
Prerequisites
- Node.js (version 22+ required)
- Nimiq Pay app on a mobile device (or emulator)
- Phone and dev machine on the same Wi-Fi network
- At least one Ethereum account available in Nimiq Pay for the Ethereum success path
1. Create the project
Use Vite with Vue + TypeScript for this tutorial:
npm create vite@latest my-mini-app -- --template vue-ts
cd my-mini-app
npm install
2. Configure the dev server
Edit vite.config.ts:
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
host: true,
},
})
3. Install the Nimiq Mini App SDK
Install the latest release of @nimiq/mini-app-sdk. The wallet error handling used in this tutorial requires SDK 0.2.0 or later:
pnpm add @nimiq/mini-app-sdk
npm install @nimiq/mini-app-sdk
yarn add @nimiq/mini-app-sdk
bun add @nimiq/mini-app-sdk
4. Add the dual-chain mini app
In src/App.vue, use separate script, template, and style blocks.
4.1 Add the script block
<script setup lang="ts">
import { init } from '@nimiq/mini-app-sdk'
import { onMounted, ref } from 'vue'
interface EthereumProvider {
request: (args: { method: string, params?: unknown[] | Record<string, unknown> }) => Promise<any>
}
declare global {
interface Window {
ethereum?: EthereumProvider
}
}
let nimiqPromise: ReturnType<typeof init> | null = null
const loading = ref(false)
const isNimiqConnecting = ref(true)
const status = ref<string | null>(null)
const errorMessage = ref<string | null>(null)
const nimiqAccounts = ref<string[] | null>(null)
const nimiqSignature = ref<string | null>(null)
const ethAccounts = ref<string[] | null>(null)
const ethSignature = ref<string | null>(null)
// Convert a UTF-8 string to hex for personal_sign.
function toHexUtf8(input: string) {
return `0x${Array.from(new TextEncoder().encode(input))
.map(byte => byte.toString(16).padStart(2, '0'))
.join('')}`
}
function resetFeedback() {
errorMessage.value = null
status.value = null
}
onMounted(async () => {
try {
nimiqPromise = init({ timeout: 10_000 })
await nimiqPromise
}
catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
}
finally {
isNimiqConnecting.value = false
}
})
async function runNimiqFlow() {
resetFeedback()
nimiqAccounts.value = null
nimiqSignature.value = null
if (!nimiqPromise) {
errorMessage.value = 'Nimiq provider not ready. Open this app inside Nimiq Pay.'
status.value = 'Nimiq flow failed.'
return
}
loading.value = true
try {
const nimiq = await nimiqPromise
// Request account sharing if the provider has no cached accounts.
status.value = 'Requesting Nimiq accounts...'
const accounts = await nimiq.listAccounts()
nimiqAccounts.value = accounts
if (!accounts.length)
throw new Error('No Nimiq accounts returned.')
// Request signing confirmation.
status.value = 'Requesting Nimiq signing confirmation...'
const signatureResult = await nimiq.sign('Nimiq Pay dual-chain tutorial')
nimiqSignature.value = JSON.stringify(signatureResult, null, 2)
status.value = 'Nimiq flow completed.'
}
catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
status.value = 'Nimiq flow failed.'
}
finally {
loading.value = false
}
}
async function runEthereumFlow() {
resetFeedback()
ethAccounts.value = null
ethSignature.value = null
if (!window.ethereum) {
errorMessage.value = 'Ethereum provider not found. Open this app inside Nimiq Pay.'
status.value = 'Ethereum flow failed.'
return
}
loading.value = true
try {
// Prompt 1: wallet connection confirmation.
status.value = 'Requesting Ethereum accounts...'
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts',
}) as string[]
ethAccounts.value = accounts
if (!accounts.length)
throw new Error('No Ethereum accounts returned.')
// Request signing confirmation.
const message = toHexUtf8('Nimiq Pay dual-chain tutorial')
status.value = 'Requesting Ethereum signing confirmation...'
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [message, accounts[0]],
}) as string
ethSignature.value = signature
status.value = 'Ethereum flow completed.'
}
catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
status.value = 'Ethereum flow failed.'
}
finally {
loading.value = false
}
}
</script>
4.2 Add the template block
<template>
<div class="app">
<h1>Dual-Chain Mini App</h1>
<div class="actions">
<button :disabled="loading || isNimiqConnecting" @click="runNimiqFlow">
Run Nimiq flow
</button>
<button :disabled="loading" @click="runEthereumFlow">
Run Ethereum flow
</button>
</div>
<p v-if="isNimiqConnecting">
Waiting for the Nimiq provider to initialize...
</p>
<p v-if="status">
<strong>Status:</strong> {{ status }}
</p>
<p v-if="errorMessage" class="error">
<strong>Error:</strong> {{ errorMessage }}
</p>
<h2>Nimiq Output</h2>
<pre v-if="nimiqAccounts" class="result">Accounts: {{ JSON.stringify(nimiqAccounts, null, 2) }}</pre>
<pre v-if="nimiqSignature" class="result">Signature: {{ nimiqSignature }}</pre>
<h2>Ethereum Output</h2>
<pre v-if="ethAccounts" class="result">Accounts: {{ JSON.stringify(ethAccounts, null, 2) }}</pre>
<pre v-if="ethSignature" class="result">Signature: {{ ethSignature }}</pre>
</div>
</template>
4.3 Add the style block (mobile-friendly)
<style scoped>
.app {
max-width: 42rem;
margin: 0 auto;
padding: 1rem;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color: #1f3553;
}
h1 {
margin: 0 0 1rem;
font-size: clamp(1.75rem, 7vw, 2.4rem);
line-height: 1.2;
}
h2 {
margin: 1rem 0 0.5rem;
}
.actions {
display: grid;
gap: 0.75rem;
margin-bottom: 1rem;
}
button {
width: 100%;
min-height: 44px;
border: none;
border-radius: 0.625rem;
padding: 0.625rem 0.875rem;
font-weight: 600;
background: #1f3553;
color: #fff;
}
button:disabled {
opacity: 0.55;
}
.error {
color: #b31b1b;
}
.result {
margin: 0 0 0.625rem;
padding: 0.625rem;
border-radius: 0.5rem;
background: #f5f8fc;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
@media (min-width: 640px) {
.actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
5. Add localization
Nimiq Pay injects the user's selected language at window.nimiqPay.language. If you want your app to match the user's Nimiq Pay language, read it at the top of your script:
const language = window.nimiqPay?.language
|| navigator.language.split('-')[0]
|| 'en'
This reads the Nimiq Pay language first, falls back to the device locale, then to English. For a full translations setup, see Localization in Mini Apps.
6. Run the mini app
npm run dev -- --host
Copy the Network URL from the terminal output, for example:
http://192.168.1.42:5173
7. Test inside Nimiq Pay
- Make sure your phone and dev machine are on the same Wi‑Fi network.
- Open Nimiq Pay.
- Go to Mini Apps.
- Enter your network URL:
http://<your-ip>:5173
Open your mini app, tap Run Nimiq flow, then tap Run Ethereum flow.
You should see:
- Nimiq accounts and a Nimiq signature response.
- Ethereum account(s) and an Ethereum signature response.
Troubleshooting
No Ethereum account returned
The Ethereum success path requires at least one account available through Nimiq Pay.
Cannot open local URL from phone
Restart the dev server with --host, then use the terminal's Network URL.
Secure-context-only API missing
If your app uses APIs such as crypto.randomUUID(), they may be unavailable when the mini app is loaded from a local network URL like http://<your-ip>:5173. Add feature detection and a fallback, or use local HTTPS if the API is required.
Port 5173 is busy
Vite chooses another port automatically (for example 5174 or 5175). Always use the exact Network URL shown in the terminal.