
Skill
setup-evm-browser-app
scaffold EVM browser applications with MetaMask
Description
Scaffold a vanilla JS/TS browser app with MetaMask EVM integration using createEVMClient, EIP-1193 provider event listeners, RPC methods, and chain switching with chainConfiguration fallback
SKILL.md
Setup EVM Browser App with MetaMask Connect
When to use
Use this skill when:
- Building a vanilla JavaScript or TypeScript browser app (no React) with MetaMask
- Integrating
createEVMClientinto a plain HTML page or a bundler-based project - Wiring up EIP-1193 provider event listeners for account and chain changes
- Performing RPC calls through
provider.requestin a non-framework context
Workflow
Step 1: Install dependencies
npm install @metamask/connect-evm
@metamask/connect-multichain is a regular dependency of @metamask/connect-evm and is installed transitively. (Only the 2.0.0 release briefly made it a peer dependency; 2.1.0 reverted that.) Installing it explicitly is harmless but not required. The SDK warns at runtime if duplicate or mismatched copies are resolved.
Or include via CDN/script tag if not using a bundler.
Step 2: Create the EVM client
import { createEVMClient, getInfuraRpcUrls } from '@metamask/connect-evm';
const client = await createEVMClient({
dapp: {
name: 'My Browser DApp',
url: window.location.href,
},
api: {
supportedNetworks: {
...getInfuraRpcUrls({ infuraApiKey: 'YOUR_INFURA_KEY', chainIds: ['0x1', '0x89', '0xa4b1', '0xaa36a7'] }),
},
},
ui: {
headless: false,
preferExtension: true,
showInstallModal: true,
},
eventHandlers: {
// Keys are camelCase — `display_uri`/`wallet_sessionChanged` are NOT valid here
displayUri: (uri: string) => {
console.log('QR URI:', uri);
// Render QR code for mobile connection
},
connect: ({ accounts, chainId }) => {
// Fires on connection and on automatic session restore
updateUI(accounts, chainId);
},
},
debug: false,
});
There is no wallet_sessionChanged handler on the EVM client — session restores surface through the connect handler / provider event and accountsChanged. (wallet_sessionChanged is a multichain-client event.)
Step 3: Register provider event listeners
Set up EIP-1193 event listeners immediately after client creation:
const provider = client.getProvider();
provider.on('accountsChanged', (accounts: string[]) => {
if (accounts.length === 0) {
// User disconnected their wallet
updateUI([], null);
return;
}
updateUI(accounts, null);
fetchBalance(accounts[0]);
});
provider.on('chainChanged', (chainId: string) => {
// chainId is a hex string, e.g. '0x1'
document.getElementById('chain')!.textContent = `Chain: ${chainId}`;
// Refresh balances since the chain changed
const currentAccount = document.getElementById('account')?.dataset.address;
if (currentAccount) fetchBalance(currentAccount);
});
provider.on('disconnect', () => {
// The connect-evm provider emits `disconnect` with no payload
console.log('Disconnected');
updateUI([], null);
});
Step 4: Connect and update UI
const connectBtn = document.getElementById('connect-btn')!;
const disconnectBtn = document.getElementById('disconnect-btn')!;
connectBtn.addEventListener('click', async () => {
try {
connectBtn.textContent = 'Connecting...';
connectBtn.setAttribute('disabled', 'true');
const { accounts, chainId } = await client.connect({
chainIds: ['0x1'],
});
updateUI(accounts, chainId);
} catch (err: any) {
if (err.code === 4001) {
showError('Connection rejected. Click Connect to try again.');
return;
}
if (err.code === -32002) {
showError('A connection request is already pending. Check MetaMask.');
return;
}
showError(err.message ?? 'Connection failed');
} finally {
connectBtn.textContent = 'Connect MetaMask';
connectBtn.removeAttribute('disabled');
}
});
disconnectBtn.addEventListener('click', async () => {
await client.disconnect();
updateUI([], null);
});
function updateUI(accounts: string[], chainId: string | null) {
const accountEl = document.getElementById('account')!;
const chainEl = document.getElementById('chain')!;
const connectedSection = document.getElementById('connected')!;
if (accounts.length === 0) {
connectedSection.style.display = 'none';
connectBtn.style.display = 'block';
return;
}
accountEl.textContent = `Account: ${accounts[0]}`;
accountEl.dataset.address = accounts[0];
if (chainId) chainEl.textContent = `Chain: ${chainId}`;
connectedSection.style.display = 'block';
connectBtn.style.display = 'none';
}
function showError(message: string) {
const errorEl = document.getElementById('error')!;
errorEl.textContent = message;
setTimeout(() => { errorEl.textContent = ''; }, 5000);
}
Step 5: Make RPC calls via provider.request
const provider = client.getProvider();
// Cached/intercepted reads — safe before connect (no chain selection needed)
const chainId = await provider.request({ method: 'eth_chainId' });
const accounts = await provider.request({ method: 'eth_accounts' });
// Node-routed reads need a SELECTED chain — they throw `No chain ID selected`
// until after connect() (or a restored session). Call these post-connect.
const blockNumber = await provider.request({ method: 'eth_blockNumber' });
async function fetchBalance(address: string) {
const wei = await provider.request({
method: 'eth_getBalance',
params: [address, 'latest'],
}) as string;
const ethBalance = parseInt(wei, 16) / 1e18;
document.getElementById('balance')!.textContent =
`Balance: ${ethBalance.toFixed(6)} ETH`;
}
// Get gas price
const gasPrice = await provider.request({ method: 'eth_gasPrice' });
// Get transaction count (nonce)
const nonce = await provider.request({
method: 'eth_getTransactionCount',
params: [accounts[0], 'latest'],
});
// Call a contract (read-only)
const result = await provider.request({
method: 'eth_call',
params: [
{
to: '0xContractAddress',
data: '0xEncodedFunctionSelector',
},
'latest',
],
});
Step 6: Switch chains with chainConfiguration fallback
async function switchChain(targetChainId: string) {
try {
await client.switchChain({ chainId: targetChainId });
} catch (err: any) {
if (err.code === 4001) {
showError('Chain switch rejected by user.');
}
}
}
// Switch to a chain with fallback configuration
// chainConfiguration triggers wallet_addEthereumChain if the chain
// is not already configured in the user's wallet
async function switchToArbitrum() {
try {
await client.switchChain({
chainId: '0xa4b1',
chainConfiguration: {
chainId: '0xa4b1', // optional in the type, but set it to the target chain — if omitted it falls back to the currently selected chain (likely the wrong chain to add)
chainName: 'Arbitrum One',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://arb1.arbitrum.io/rpc'],
blockExplorerUrls: ['https://arbiscan.io'],
},
});
} catch (err: any) {
if (err.code === 4001) {
showError('User rejected the chain addition or switch.');
}
}
}
// Switch to well-known chains
document.getElementById('switch-mainnet')!.addEventListener('click',
() => switchChain('0x1'));
document.getElementById('switch-polygon')!.addEventListener('click',
() => switchChain('0x89'));
document.getElementById('switch-sepolia')!.addEventListener('click',
() => switchChain('0xaa36a7'));
document.getElementById('switch-arbitrum')!.addEventListener('click',
() => switchToArbitrum());
Step 7: Complete HTML structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>MetaMask Connect</title>
</head>
<body>
<h1>MetaMask Connect Demo</h1>
<button id="connect-btn">Connect MetaMask</button>
<p id="error" style="color: red;"></p>
<div id="connected" style="display: none;">
<p id="account"></p>
<p id="chain"></p>
<p id="balance"></p>
<button id="switch-mainnet">Mainnet (0x1)</button>
<button id="switch-polygon">Polygon (0x89)</button>
<button id="switch-sepolia">Sepolia (0xaa36a7)</button>
<button id="switch-arbitrum">Arbitrum (0xa4b1)</button>
<button id="disconnect-btn">Disconnect</button>
</div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
Important Notes
- Call
createEVMClientonce at app startup — each call returns a new EVM client wrapper, but they all share one underlying multichain core (the core is the singleton whose options merge across calls). Don't recreate the client repeatedly. - Chain IDs are always hex strings — use
'0x1','0x89','0xaa36a7'. Never pass decimal numbers or decimal strings. 0x1(Ethereum mainnet) is auto-included in everyconnect()call regardless of thechainIdsyou specify.- The provider exists before connection —
client.getProvider()always returns a valid EIP-1193 provider. But node-routed reads (eth_blockNumber,eth_getBalance,eth_call, …) require a selected chain and throwNo chain ID selecteduntil one is set (afterconnect()or a restored session). Onlyeth_chainIdandeth_accounts(intercepted, served from cache) are safe before connecting. - Convenience getters — use
client.getChainId()(returnsHex | undefined) andclient.getAccount()(returnsAddress | undefined) instead ofprovider.request({ method: 'eth_chainId' })/eth_accountsfor cached state. - Connection status —
client.statusreturns'connecting'|'connected'|'disconnected'. (The 5-value'loaded'/'pending'union belongs to the multichain core, not the EVM client.) Use this for UI state instead of tracking manually. - Register event listeners before connecting — set up
accountsChanged,chainChanged, anddisconnecthandlers immediately after getting the provider. chainConfigurationis a fallback, not a forced add — it is only used if the wallet doesn't already have the chain configured. If the chain exists, onlywallet_switchEthereumChainfires.- Page reloads restore automatically — the EVM client syncs any persisted session before
createEVMClientresolves and re-emitsconnect/accountsChangedon the provider. The EVM client has no.on()method and nowallet_sessionChangedhandler — use the provider events (oreventHandlers.connect) to restore UI state. - Error code 4001 means the user deliberately rejected — show a retry option, not a crash screen.
- Error code -32002 means a request is already pending — do not send another
connect(). Wait for the user to respond in MetaMask.
More skills from the metamask-connect-cursor-plugin repository
View all 18 skillsmigrate-from-sdk
migrate MetaMask SDK to connect-evm and connect-solana
Jul 13EthereumMigrationSDKWeb3migrate-wagmi-metamask-connector
migrate wagmi apps to MetaMask connector
Jul 13EthereumMigrationwagmiWeb3send-evm-transaction
send EVM transactions with MetaMask
Jul 13API DevelopmentEthereumWeb3send-solana-transaction
send Solana transactions via MetaMask
Jul 13PaymentsSolanaWeb3setup-evm-react-app
scaffold React apps with MetaMask integration
Jul 13API DevelopmentEthereumFrontendReact +1setup-evm-react-native-app
scaffold React Native apps with MetaMask
Jul 13EthereumMobileReact NativeWeb3
More from MetaMask
View publishersmart-accounts-kit
build dApps with MetaMask Smart Accounts
smart-accounts-kit
Jul 13API DevelopmentAuthEthereumPermissions +1x402-payments
build x402 payment flows
smart-accounts-kit
Jul 13API DevelopmentPaymentsWeb3x402discovery
discover and use services
ocap-kernel
Jul 13API DevelopmentSearchmetamask
interact with MetaMask wallet capabilities
ocap-kernel
Jul 13API DevelopmentEthereumWeb3wallet
manage wallet balances and transactions
ocap-kernel
Jul 13EthereumPaymentsWeb3metamask-connect
integrate MetaMask into dApps
connect-monorepo
Jul 13API DevelopmentEthereumSolanawagmi +1