Web3 Integration Guide Part 1: Connecting Crypto Wallets
Introduction
Web3 integration enables your web applications to interact with blockchain networks, accept cryptocurrency payments, and leverage decentralized technologies. This comprehensive five-part guide will teach you how to build a fully-functional Web3 application using Vue 3 and Vite.
In Part 1, we'll focus on the foundation: connecting cryptocurrency wallets to your web application. By the end of this lesson, you'll have a working Vue 3 app that can connect to MetaMask and WalletConnect, display user addresses, and handle network switching.
What You'll Learn
- Understanding Web3 wallets and providers
- Setting up a Vue 3 + Vite project for Web3
- Installing and configuring Ethers.js
- Implementing MetaMask connection
- Adding WalletConnect support
- Building a wallet connection UI component
- Handling wallet events (account changes, disconnections)
- Detecting and switching networks
Prerequisites
- Basic knowledge of Vue 3 Composition API
- Node.js 18+ and npm/yarn installed
- MetaMask browser extension (for testing)
- Basic understanding of JavaScript async/await
Understanding Web3 Wallets
What is a Web3 Wallet?
A Web3 wallet is a digital tool that stores your cryptocurrency private keys and enables interaction with blockchain networks. Unlike traditional apps where users create accounts with username/password, Web3 apps authenticate users through their wallet addresses.
Key Concepts:
- Public Address: Your wallet's public identifier (e.g., 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb4)
- Private Key: Secret key that proves ownership (never share this!)
- Provider: Software that connects your app to the blockchain
- Signer: Interface to sign transactions with your private key
Popular Wallet Options
| Wallet | Type | Best For | Platforms |
|---|---|---|---|
| MetaMask | Browser Extension | Desktop users, beginners | Chrome, Firefox, Brave |
| WalletConnect | Protocol | Mobile users, multiple wallets | 200+ mobile wallets |
| Coinbase Wallet | Browser + Mobile | Coinbase users | iOS, Android, Chrome |
| Rainbow | Mobile | NFT collectors | iOS, Android |
For this guide, we'll implement MetaMask (most popular) and WalletConnect (best mobile support).
Project Setup
Step 1: Create Vue 3 + Vite Project
Let's start by creating a new Vue 3 project using Vite:
npm create vite@latest web3-wallet-app -- --template vue
cd web3-wallet-app
npm install
Step 2: Install Web3 Dependencies
We'll use Ethers.js v6 as our Web3 library. It's lightweight, well-documented, and actively maintained:
npm install ethers@^6.0.0
For WalletConnect support, we'll also need:
npm install @web3modal/ethers@^5.0.0
Step 3: Project Structure
Organize your project like this:
web3-wallet-app/
├── src/
│ ├── components/
│ │ ├── WalletConnect.vue # Main wallet connection component
│ │ └── NetworkSelector.vue # Network switching component
│ ├── composables/
│ │ └── useWallet.js # Wallet state management
│ ├── utils/
│ │ └── chains.js # Chain configurations
│ ├── App.vue
│ └── main.js
├── package.json
└── vite.config.js
Building the Wallet Composable
Vue 3's Composition API allows us to create reusable logic with composables. Let's create useWallet.js
to manage wallet state:
Create src/composables/useWallet.js
import { ref, computed, readonly } from 'vue'
import { BrowserProvider } from 'ethers'
// Reactive state
const account = ref(null)
const chainId = ref(null)
const provider = ref(null)
const isConnected = ref(false)
const isConnecting = ref(false)
const error = ref(null)
export function useWallet() {
/**
* Connect to MetaMask wallet
*/
const connectMetaMask = async () => {
try {
isConnecting.value = true
error.value = null
// Check if MetaMask is installed
if (!window.ethereum) {
throw new Error('MetaMask is not installed. Please install it from metamask.io')
}
// Request account access
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
})
if (!accounts || accounts.length === 0) {
throw new Error('No accounts found. Please unlock MetaMask.')
}
// Create Ethers provider
provider.value = new BrowserProvider(window.ethereum)
// Get signer and account details
const signer = await provider.value.getSigner()
account.value = await signer.getAddress()
// Get network/chain ID
const network = await provider.value.getNetwork()
chainId.value = Number(network.chainId)
isConnected.value = true
// Setup event listeners
setupEventListeners()
console.log('✅ Connected to MetaMask:', account.value)
} catch (err) {
console.error('MetaMask connection error:', err)
error.value = err.message
disconnect()
} finally {
isConnecting.value = false
}
}
/**
* Disconnect wallet
*/
const disconnect = () => {
account.value = null
chainId.value = null
provider.value = null
isConnected.value = false
error.value = null
// Remove event listeners
if (window.ethereum) {
window.ethereum.removeAllListeners()
}
console.log('👋 Wallet disconnected')
}
/**
* Setup wallet event listeners
*/
const setupEventListeners = () => {
if (!window.ethereum) return
// Handle account changes
window.ethereum.on('accountsChanged', (accounts) => {
if (accounts.length === 0) {
// User disconnected wallet
disconnect()
} else if (accounts[0] !== account.value) {
// User switched accounts
account.value = accounts[0]
console.log('🔄 Account changed:', account.value)
}
})
// Handle chain/network changes
window.ethereum.on('chainChanged', (newChainId) => {
chainId.value = parseInt(newChainId, 16)
console.log('🔄 Chain changed:', chainId.value)
// Reload the page on chain change (recommended by MetaMask)
window.location.reload()
})
// Handle disconnection
window.ethereum.on('disconnect', () => {
disconnect()
})
}
/**
* Switch to a different network
*/
const switchNetwork = async (targetChainId) => {
try {
error.value = null
const chainIdHex = `0x${targetChainId.toString(16)}`
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: chainIdHex }]
})
console.log('✅ Switched to chain:', targetChainId)
} catch (err) {
// This error code indicates that the chain has not been added to MetaMask
if (err.code === 4902) {
error.value = 'This network is not available in your MetaMask. Please add it manually.'
} else {
error.value = err.message
}
console.error('Network switch error:', err)
}
}
/**
* Get balance of connected account
*/
const getBalance = async () => {
if (!provider.value || !account.value) {
return '0'
}
try {
const balance = await provider.value.getBalance(account.value)
// Convert from wei to ether
return (Number(balance) / 1e18).toFixed(4)
} catch (err) {
console.error('Error fetching balance:', err)
return '0'
}
}
/**
* Format address for display (0x1234...5678)
*/
const formatAddress = (address) => {
if (!address) return ''
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`
}
// Computed properties
const shortAddress = computed(() => formatAddress(account.value))
const networkName = computed(() => {
const networks = {
1: 'Ethereum Mainnet',
5: 'Goerli Testnet',
11155111: 'Sepolia Testnet',
137: 'Polygon',
80001: 'Mumbai Testnet',
56: 'BSC Mainnet',
97: 'BSC Testnet'
}
return networks[chainId.value] || `Chain ${chainId.value}`
})
return {
// State (readonly to prevent external mutations)
account: readonly(account),
chainId: readonly(chainId),
provider: readonly(provider),
isConnected: readonly(isConnected),
isConnecting: readonly(isConnecting),
error: readonly(error),
// Computed
shortAddress,
networkName,
// Methods
connectMetaMask,
disconnect,
switchNetwork,
getBalance,
formatAddress
}
}
Key Features Explained
- BrowserProvider: Ethers.js wrapper around window.ethereum that provides blockchain access
- Reactive State: Vue refs track wallet connection status, account, and chain ID
- Event Listeners: Automatically update when user switches accounts or networks
- Error Handling: Gracefully handle connection failures and user rejections
- Readonly Exports: Prevent components from accidentally mutating wallet state
Building the Wallet Connection UI
Now let's create a component that uses our wallet composable:
Create src/components/WalletConnect.vue
<template>
<div class="wallet-connect">
<!-- Error Display -->
<div v-if="error" class="error-banner">
⚠️ {{ error }}
<button @click="error = null" class="close-btn">×</button>
</div>
<!-- Not Connected State -->
<div v-if="!isConnected" class="connect-container">
<h2>Connect Your Wallet</h2>
<p>Connect your crypto wallet to interact with Web3 features</p>
<button
@click="connectMetaMask"
:disabled="isConnecting"
class="connect-btn metamask"
>
<img src="/metamask-icon.svg" alt="MetaMask" />
{{ isConnecting ? 'Connecting...' : 'Connect MetaMask' }}
</button>
<p class="install-note" v-if="!hasMetaMask">
Don't have MetaMask?
<a href="https://metamask.io/download/" target="_blank">Install it here</a>
</p>
</div>
<!-- Connected State -->
<div v-else class="connected-container">
<div class="wallet-info">
<div class="info-row">
<span class="label">Connected Account:</span>
<span class="value">
{{ shortAddress }}
<button @click="copyAddress" class="copy-btn" title="Copy full address">
{{ copied ? '✓' : '📋' }}
</button>
</span>
</div>
<div class="info-row">
<span class="label">Network:</span>
<span class="value network">
<span class="network-dot"></span>
{{ networkName }}
</span>
</div>
<div class="info-row">
<span class="label">Balance:</span>
<span class="value">{{ balance }} ETH</span>
</div>
</div>
<button @click="disconnect" class="disconnect-btn">
Disconnect Wallet
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useWallet } from '../composables/useWallet'
const {
account,
chainId,
isConnected,
isConnecting,
error,
shortAddress,
networkName,
connectMetaMask,
disconnect,
getBalance
} = useWallet()
const balance = ref('0.0000')
const copied = ref(false)
// Check if MetaMask is installed
const hasMetaMask = computed(() => {
return typeof window !== 'undefined' && typeof window.ethereum !== 'undefined'
})
// Update balance when connected
const updateBalance = async () => {
if (isConnected.value) {
balance.value = await getBalance()
}
}
// Copy address to clipboard
const copyAddress = async () => {
if (!account.value) return
try {
await navigator.clipboard.writeText(account.value)
copied.value = true
setTimeout(() => copied.value = false, 2000)
} catch (err) {
console.error('Failed to copy address:', err)
}
}
// Update balance on mount and when connected
onMounted(() => {
if (isConnected.value) {
updateBalance()
}
})
// Watch for connection changes
import { watch } from 'vue'
watch(isConnected, (connected) => {
if (connected) {
updateBalance()
} else {
balance.value = '0.0000'
}
})
</script>
<style scoped>
.wallet-connect {
max-width: 500px;
margin: 0 auto;
padding: 2rem;
}
.error-banner {
background: #fee;
border: 1px solid #fcc;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
color: #c00;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #c00;
}
.connect-container {
text-align: center;
}
.connect-container h2 {
margin-bottom: 0.5rem;
color: #333;
}
.connect-container p {
color: #666;
margin-bottom: 2rem;
}
.connect-btn {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 2rem;
font-size: 1.1rem;
font-weight: 600;
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.connect-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.connect-btn.metamask {
background: linear-gradient(135deg, #f6851b 0%, #e2761b 100%);
color: white;
}
.connect-btn.metamask:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(246, 133, 27, 0.4);
}
.connect-btn img {
width: 24px;
height: 24px;
}
.install-note {
margin-top: 1rem;
font-size: 0.9rem;
color: #666;
}
.install-note a {
color: #f6851b;
font-weight: 600;
}
.connected-container {
background: #f8f9fa;
border-radius: 12px;
padding: 1.5rem;
}
.wallet-info {
margin-bottom: 1.5rem;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #e0e0e0;
}
.info-row:last-child {
border-bottom: none;
}
.label {
font-weight: 600;
color: #666;
}
.value {
font-family: monospace;
color: #333;
display: flex;
align-items: center;
gap: 0.5rem;
}
.network {
display: flex;
align-items: center;
gap: 0.5rem;
}
.network-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4caf50;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.copy-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 0.25rem;
transition: transform 0.2s;
}
.copy-btn:hover {
transform: scale(1.2);
}
.disconnect-btn {
width: 100%;
padding: 0.75rem;
background: #dc3545;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.disconnect-btn:hover {
background: #c82333;
}
</style>
Component Features
- Conditional Rendering: Shows connect button when disconnected, wallet info when connected
- MetaMask Detection: Displays install prompt if MetaMask isn't found
- Address Copying: One-click copy of full wallet address to clipboard
- Live Balance: Fetches and displays ETH balance
- Network Indicator: Shows current blockchain network with visual indicator
- Error Display: User-friendly error messages with dismissal
Creating the Network Selector
Let's add a component that allows users to switch between different networks:
Create src/utils/chains.js
export const supportedChains = [
{
id: 1,
name: 'Ethereum Mainnet',
rpcUrl: 'https://eth.llamarpc.com',
currency: 'ETH',
explorer: 'https://etherscan.io'
},
{
id: 11155111,
name: 'Sepolia Testnet',
rpcUrl: 'https://rpc.sepolia.org',
currency: 'SepoliaETH',
explorer: 'https://sepolia.etherscan.io'
},
{
id: 137,
name: 'Polygon Mainnet',
rpcUrl: 'https://polygon-rpc.com',
currency: 'MATIC',
explorer: 'https://polygonscan.com'
},
{
id: 80001,
name: 'Mumbai Testnet',
rpcUrl: 'https://rpc-mumbai.maticvigil.com',
currency: 'MATIC',
explorer: 'https://mumbai.polygonscan.com'
}
]
export const getChainById = (chainId) => {
return supportedChains.find(chain => chain.id === chainId)
}
Create src/components/NetworkSelector.vue
<template>
<div class="network-selector" v-if="isConnected">
<label for="network">Select Network:</label>
<select
id="network"
:value="chainId"
@change="handleNetworkChange"
class="network-select"
>
<option
v-for="chain in supportedChains"
:key="chain.id"
:value="chain.id"
>
{{ chain.name }}
</option>
</select>
</div>
</template>
<script setup>
import { useWallet } from '../composables/useWallet'
import { supportedChains } from '../utils/chains'
const { chainId, isConnected, switchNetwork } = useWallet()
const handleNetworkChange = (event) => {
const targetChainId = parseInt(event.target.value, 10)
if (targetChainId !== chainId.value) {
switchNetwork(targetChainId)
}
}
</script>
<style scoped>
.network-selector {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
margin: 1rem 0;
}
label {
font-weight: 600;
color: #333;
}
.network-select {
flex: 1;
padding: 0.5rem 1rem;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
background: white;
transition: border-color 0.2s;
}
.network-select:hover {
border-color: #f6851b;
}
.network-select:focus {
outline: none;
border-color: #f6851b;
}
</style>
Putting It All Together
Update src/App.vue
<template>
<div id="app">
<header>
<h1>🔗 Web3 Wallet Integration</h1>
<p>Learn how to connect crypto wallets to your Vue 3 app</p>
</header>
<main>
<WalletConnect />
<NetworkSelector />
</main>
<footer>
<p>Part 1: Wallet Connection | <a href="/guides/web3-integration-guide-2025-part-2">Next: Smart Contracts →</a></p>
</footer>
</div>
</template>
<script setup>
import WalletConnect from './components/WalletConnect.vue'
import NetworkSelector from './components/NetworkSelector.vue'
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 2rem;
}
#app {
max-width: 1200px;
margin: 0 auto;
}
header {
text-align: center;
color: white;
margin-bottom: 3rem;
}
header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
header p {
font-size: 1.2rem;
opacity: 0.9;
}
main {
background: white;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
}
footer {
text-align: center;
margin-top: 2rem;
color: white;
}
footer a {
color: #ffd700;
font-weight: 600;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
</style>
Testing Your Application
Step 1: Run the Development Server
npm run dev
Your app should now be running at http://localhost:5173
Step 2: Test Wallet Connection
- Open the app in your browser
- Click "Connect MetaMask"
- Approve the connection in MetaMask popup
- Verify your address and balance are displayed
- Try switching networks using the network selector
- Test disconnecting and reconnecting
Step 3: Test Edge Cases
- No MetaMask: Test in a browser without MetaMask installed
- User Rejection: Click "Reject" when MetaMask asks for connection
- Account Switching: Switch accounts in MetaMask and verify UI updates
- Network Switching: Change networks and check balance updates
Common Issues and Solutions
Issue: "MetaMask is not installed" error
Solution: Install MetaMask browser extension from metamask.io/download
Issue: Connection request doesn't appear
Solutions:
- Check if MetaMask popup is blocked by browser
- Unlock MetaMask wallet
- Refresh the page and try again
Issue: Balance shows as 0
Solutions:
- Ensure you're on a testnet and have test tokens
- Get test ETH from faucets:
- Sepolia: sepoliafaucet.com
- Mumbai: faucet.polygon.technology
Issue: "ChainId not supported" error
Solution: Add the network to MetaMask manually or implement the wallet_addEthereumChain
method (covered in Part 5)
Best Practices
Security
- ✅ Never store private keys in your code or frontend
- ✅ Always validate addresses before sending transactions
- ✅ Use HTTPS in production
- ✅ Implement rate limiting for RPC calls
- ✅ Display clear transaction details before user confirmation
User Experience
- ✅ Show loading states during connection
- ✅ Provide clear error messages
- ✅ Save connection preference in localStorage
- ✅ Auto-reconnect on page refresh if previously connected
- ✅ Support multiple wallets (MetaMask, WalletConnect, Coinbase)
Performance
- ✅ Cache provider instances
- ✅ Debounce balance updates
- ✅ Use event listeners instead of polling
- ✅ Lazy-load Web3 libraries
What's Next?
Congratulations! You now have a working wallet connection system. In the next parts of this guide, we'll build on this foundation:
- Part 2: Interacting with smart contracts - reading data, writing transactions, and handling events
- Part 3: Accepting cryptocurrency payments and ERC-20 tokens
- Part 4: Sending payments to users, batch transfers, and gas optimization
- Part 5: Security best practices, testing, and production deployment
Resources
- Ethers.js v6 Documentation
- MetaMask Developer Docs
- WalletConnect Documentation
- Vue 3 Official Guide
- Vite Documentation
Complete Code Repository
The complete working code for this guide is available on GitHub:
git clone https://github.com/godfake/web3-wallet-integration-vue3.git
cd web3-wallet-integration-vue3
git checkout part-1-wallet-connection
npm install
npm run dev