dApp Frontend Development with Vue.js
Vue.js in Web3 is not second-class compared to React. The issue is that most Web3 libraries were written React-first: wagmi, RainbowKit, ConnectKit — all React hooks. For Vue, you need a different stack, and it exists: wagmi/core (framework-agnostic) + vue-query + web3modal. Or ethers.js directly with Vue 3 Composition API. Let's explore both approaches.
Stack and Architecture
Option 1: wagmi/core + vue-query
@wagmi/core is the headless version of wagmi without React attachment. All actions (connect, readContract, writeContract, watchAccount) work as regular functions. vue-query (TanStack Query for Vue) handles caching and reactivity.
// wagmi.config.ts
import { createConfig, http } from '@wagmi/core'
import { mainnet, polygon } from '@wagmi/core/chains'
import { walletConnect, injected } from '@wagmi/connectors'
export const config = createConfig({
chains: [mainnet, polygon],
connectors: [
injected(), // MetaMask and other EIP-1193
walletConnect({ projectId: import.meta.env.VITE_WC_PROJECT_ID }),
],
transports: {
[mainnet.id]: http(),
[polygon.id]: http(),
},
})
Composable for account management:
// composables/useWallet.ts
import { ref, computed, watchEffect } from 'vue'
import { connect, disconnect, getAccount, watchAccount } from '@wagmi/core'
import { config } from '@/wagmi.config'
export function useWallet() {
const account = ref(getAccount(config))
const unwatch = watchAccount(config, {
onChange(data) { account.value = data }
})
onUnmounted(() => unwatch())
return {
address: computed(() => account.value.address),
isConnected: computed(() => account.value.isConnected),
chainId: computed(() => account.value.chainId),
connect: (connector) => connect(config, { connector }),
disconnect: () => disconnect(config),
}
}
Option 2: ethers.js + Vue 3 Composition API
If the project already uses ethers.js or your team knows ethers better:
// composables/useEthers.ts
import { ref, shallowRef } from 'vue'
import { BrowserProvider, JsonRpcSigner } from 'ethers'
export function useEthers() {
const provider = shallowRef<BrowserProvider | null>(null)
const signer = shallowRef<JsonRpcSigner | null>(null)
const address = ref<string | null>(null)
async function connectWallet() {
if (!window.ethereum) throw new Error('No wallet detected')
const _provider = new BrowserProvider(window.ethereum)
const _signer = await _provider.getSigner()
provider.value = _provider
signer.value = _signer
address.value = await _signer.getAddress()
window.ethereum.on('accountsChanged', (accounts: string[]) => {
address.value = accounts[0] ?? null
})
window.ethereum.on('chainChanged', () => window.location.reload())
}
return { provider, signer, address, connectWallet }
}
shallowRef for provider and signer is important. Deep reactivity of Vue on ethers.js objects causes performance issues and strange bugs.
Reading Contract Data
With vue-query caching and refetch work transparently:
// composables/useTokenBalance.ts
import { useQuery } from '@tanstack/vue-query'
import { readContract } from '@wagmi/core'
import { erc20Abi } from 'viem'
import { config } from '@/wagmi.config'
export function useTokenBalance(tokenAddress: Ref<`0x${string}`>, owner: Ref<`0x${string}` | undefined>) {
return useQuery({
queryKey: computed(() => ['balance', tokenAddress.value, owner.value]),
queryFn: () => readContract(config, {
address: tokenAddress.value,
abi: erc20Abi,
functionName: 'balanceOf',
args: [owner.value!],
}),
enabled: computed(() => !!owner.value),
staleTime: 10_000, // 10 second cache
})
}
Writing to Contract and Transaction State
Mutations via vue-query useMutation, transaction state via waitForTransactionReceipt:
import { useMutation } from '@tanstack/vue-query'
import { writeContract, waitForTransactionReceipt } from '@wagmi/core'
export function useApprove(tokenAddress: Ref<`0x${string}`>) {
return useMutation({
mutationFn: async ({ spender, amount }: { spender: `0x${string}`, amount: bigint }) => {
const hash = await writeContract(config, {
address: tokenAddress.value,
abi: erc20Abi,
functionName: 'approve',
args: [spender, amount],
})
await waitForTransactionReceipt(config, { hash })
return hash
},
})
}
In component:
<script setup>
const { mutate: approve, isPending, isSuccess, error } = useApprove(tokenAddress)
</script>
<template>
<button @click="approve({ spender, amount })" :disabled="isPending">
{{ isPending ? 'Confirm in wallet...' : 'Approve' }}
</button>
<p v-if="error">{{ error.shortMessage }}</p>
</template>
Web3Modal for Multi-wallet Connection
WalletConnect's Web3Modal works with Vue through @web3modal/wagmi/vue:
import { createWeb3Modal } from '@web3modal/wagmi/vue'
createWeb3Modal({
wagmiConfig: config,
projectId: import.meta.env.VITE_WC_PROJECT_ID,
enableAnalytics: false,
})
After that in any component:
<w3m-button />
Ready UI with support for MetaMask, WalletConnect, Coinbase Wallet, Injected and 300+ wallets. Customization via CSS variables.
State Management: Pinia for Global Web3 State
For data needed in multiple unrelated components (balance, allowances), Pinia store:
// stores/web3.ts
import { defineStore } from 'pinia'
import { useWallet } from '@/composables/useWallet'
export const useWeb3Store = defineStore('web3', () => {
const wallet = useWallet()
// computed getters, actions for batch operations
return { ...wallet }
})
Build: Vite + @vitejs/plugin-vue
Node polyfills for libraries expecting Node.js environment (some ethers.js parts):
// vite.config.ts
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({
plugins: [vue(), nodePolyfills({ include: ['buffer', 'stream', 'util'] })],
resolve: {
alias: { '@': '/src' }
}
})
Without polyfills Buffer is not defined or process is not defined — classic errors on first Web3 project run on Vite.







