Migrating Off the Legacy MetaMask SDK: A MetaMask Connect Upgrade Guide
MetaMask retired the legacy SDK for MetaMask Connect. Here's how to migrate to createEVMClient, what changes in your provider code, and how to test it.
If your EVM dApp has a Connect Wallet button wired through @metamask/sdk, that package is now on borrowed time. MetaMask has superseded the legacy SDK with MetaMask Connect, a ground-up rewrite with a new package, a streamlined API, and direct wallet communication. The migration is mostly mechanical, but two changes will break your code if you copy your old setup verbatim: the package name and entry point changed, and initialization is now asynchronous. This is a practical MetaMask SDK migration guide for EVM dApps — what moves, what stays, and how to confirm your connect flow still works before you ship.
What MetaMask Connect Actually Changes
Most of your dApp doesn't care which SDK created the provider. MetaMask Connect returns the same EIP-1193 provider the legacy SDK did, so your viem, ethers.js, web3.js, or wagmi calls keep working unchanged. The differences live in the setup layer:
- No relay server. The legacy SDK routed messages through a hosted relay. MetaMask Connect talks to the wallet directly, which removes a network hop and a third-party dependency from your connection path.
- Async initialization. The old
new MetaMaskSDK(...)constructor was synchronous and handed you a provider immediately.createEVMClient(...)returns a promise — you have toawaitit before the provider exists. - A shared multichain core. The EVM client wraps
createMultichainClientand manages a CAIP-25 session under the hood. That's what lets MetaMask Connect later attach a Solana scope to the same session — a capability worth its own discussion, but not something a single-chain EVM dApp has to think about during this migration.
The takeaway: this is a setup-layer change, not an application-layer rewrite. Your transaction-building and contract-read code is safe.
The Migration, Step by Step
There are three mechanical steps: swap the dependency, update the import and constructor, and move your initialization behind an await.
First, replace the package:
npm remove @metamask/sdk
npm install @metamask/connect-evmThen update the import and initialization. Here is the legacy pattern most dApps shipped:
// Before — legacy @metamask/sdk
import { MetaMaskSDK } from '@metamask/sdk'
const sdk = new MetaMaskSDK({
dappMetadata: { name: 'My dApp', url: window.location.href },
})
const provider = sdk.getProvider()
const accounts = await sdk.connect()And the MetaMask Connect equivalent:
// After — @metamask/connect-evm
import { createEVMClient } from '@metamask/connect-evm'
const evmClient = await createEVMClient({
dapp: { name: 'My dApp', url: window.location.href },
})
const provider = evmClient.getProvider()
const accounts = await evmClient.connect()Line by line:
createEVMClientreplaces thenew MetaMaskSDK()constructor and must be awaited. You can no longer create the client inline at the top level of a module — wrap it in anasyncfunction or initialize it inside an effect/lifecycle hook.dappMetadatais now justdapp. The fields (name,url) are the same.evmClient.getProvider()returns the EIP-1193 provider, available immediately once the client resolves. Everything downstream of this line —provider.request({ method: 'eth_sendTransaction', params: [...] }), your viem client, your wagmi config — stays identical.evmClient.connect()prompts the wallet and resolves with the connected accounts.
One gotcha worth flagging: evmClient.disconnect() only revokes the EVM (eip155) scopes. If you ever add a Solana scope to the same session, calling disconnect() from the EVM client will not tear down the whole multichain session. For a pure EVM dApp this is invisible, but it's a sharp edge if you go multichain later.
Re-Test the Connect Flow Before You Ship
The riskiest part of this migration isn't the transaction logic — it's the connect button itself. Moving initialization behind an await is exactly the kind of change that introduces race conditions: a button that fires before the client resolves, a provider that's undefined for the first render, or a rejected connection that leaves the UI stuck. None of that shows up in a unit test that mocks the provider, because the mock resolves instantly and never opens a real popup.
This is where an E2E test against a real MetaMask extension earns its keep. With @avalix/chroma you drive the actual wallet UI, so the async boundary and the authorization popup are exercised the way a user hits them:
import { createWalletTest } from '@avalix/chroma'
const test = createWalletTest({
wallets: [{ type: 'metamask' }],
})
test('connects through MetaMask Connect', async ({ page, wallets }) => {
const metamask = wallets.metamask
await metamask.importSeedPhrase({ seedPhrase: process.env.TEST_SEED! })
await page.goto('http://localhost:3000')
await page.getByRole('button', { name: 'Connect Wallet' }).click()
await metamask.authorize() // approve the connection request
await expect(page.getByTestId('account-address')).toBeVisible()
})What each step verifies:
importSeedPhraseloads a known test wallet into the extension.- Clicking the button triggers
createEVMClient/connect()— if your async init has a race, the popup never appears andauthorize()times out, failing the test loudly instead of silently in production. metamask.authorize()approves the connection popup, mirroring a real user.
Pair it with the rejection path, the case async migrations break most often:
test('handles a rejected connection', async ({ page, wallets }) => {
const metamask = wallets.metamask
await metamask.importSeedPhrase({ seedPhrase: process.env.TEST_SEED! })
await page.goto('http://localhost:3000')
await page.getByRole('button', { name: 'Connect Wallet' }).click()
await metamask.reject() // user declines
await expect(page.getByRole('button', { name: 'Connect Wallet' })).toBeEnabled()
})metamask.reject() declines the prompt, and the assertion confirms your UI returns to a clickable state rather than hanging on a promise that never resolved.
Takeaway
The MetaMask Connect migration is small on paper — swap one package, rename one field, add one await. But that await is precisely where dApps regress, because the connect button now depends on a promise resolving before it can fire. Pin the new version, move your initialization into an async boundary, and run your connect-and-reject E2E suite once against the new client. If both pass against a real extension, the rest of your dApp won't notice the SDK changed underneath it.