Connect a browser wallet
Connect to a real browser wallet, get user approval, and display a live address and balance
Connect a browser wallet
The Scenario
On Days 1 and 2, you generated keypairs and managed private keys yourself, creating them in memory, serializing bytes to a file, loading them back. That works for scripts and backend services, but it's not how real users interact with Solana. Nobody pastes a 64-byte secret key into a web app.
Browser wallets like Phantom and Solflare handle the hard parts: they store your keys securely, present a clean interface for approving transactions, and expose a standard API that any web app can connect to. Your app never sees the private key. It asks the wallet to sign, the user approves or rejects, and the wallet returns the signed result.
In web2 terms, this is the difference between storing passwords in your own database versus using "Sign in with Google." You delegate authentication to a trusted provider and get back a verified identity.
Today you're going to build a web page that discovers installed wallets, connects to one, and displays the connected account's address and balance. This is your first step toward building apps that real users can interact with.
The Challenge
Build a browser app that detects installed Solana wallets, lets the user connect, and displays their wallet address and devnet balance. You should not ask the user for a private key or secret phrase at any point.
What You'll Need
- Node.js
- A terminal
- A code editor
- A browser wallet extension installed (Phantom, Solflare, or Backpack; pick one and install it if you haven't already)
Before You Start
You need a browser wallet installed to test this. If you don't have one, go to phantom.app and install the Chrome extension. Create a new wallet (it takes about 30 seconds), and switch it to devnet: open Phantom, go to Settings, then Developer Settings, then change the network to Devnet. Fund your devnet address through faucet.solana.com so you have a balance to display.
Steps
This challenge uses a brand-new Vite project, separate from the script-based projects you built in Days 1–3. That's because you're now building a browser app rather than a Node.js script.
Create a new Vite project:
npm create vite@latest day-4-wallet -- --template vanilla
cd day-4-wallet
npm install
npm install @solana/kit @wallet-standard/app
The @wallet-standard/app package provides a function called getWallets() that discovers any wallet extensions the user has installed. This is the Wallet Standard, an open protocol that lets any wallet work with any app without either side needing custom integration code.
Replace the contents of index.html with:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Solana Wallet Connect</title>
<style>
body {
font-family: system-ui, sans-serif;
max-width: 720px;
margin: 40px auto;
padding: 0 20px;
background: #1a1a2e;
color: #e0e0e0;
}
h1 { color: #00ffa3; }
.wallet-list { margin: 20px 0; }
.wallet-btn {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
margin: 8px 0;
background: #16213e;
color: #e0e0e0;
border: 1px solid #333;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
}
.wallet-btn:hover { border-color: #00ffa3; }
.wallet-btn img { width: 32px; height: 32px; border-radius: 4px; }
.connected {
background: #16213e;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.address {
font-family: monospace;
font-size: 14px;
word-break: break-all;
color: #00ffa3;
margin: 8px 0;
}
.balance { font-size: 28px; margin: 16px 0; }
.disconnect-btn {
padding: 8px 16px;
background: transparent;
color: #ff4444;
border: 1px solid #ff4444;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.disconnect-btn:hover { background: #ff444420; }
#status { color: #888; margin: 10px 0; }
#error { color: #ff4444; margin: 10px 0; }
.no-wallets {
padding: 20px;
background: #16213e;
border-radius: 8px;
text-align: center;
color: #888;
}
.no-wallets a { color: #00ffa3; }
</style>
</head>
<body>
<h1>Connect a Solana wallet</h1>
<div id="status">Looking for wallets...</div>
<div id="error"></div>
<div id="wallet-list" class="wallet-list"></div>
<div id="connected" class="connected" style="display: none"></div>
<script type="module" src="src/main.js"></script>
</body>
</html>
You can also delete src/style.css — the new index.html has all the styles inline, so the default stylesheet isn't needed.
Replace the contents of src/main.js with:
import { createSolanaRpc, devnet } from "@solana/kit";
import { getWallets } from "@wallet-standard/app";
const rpc = createSolanaRpc(devnet("https://api.devnet.solana.com"));
const walletListDiv = document.getElementById("wallet-list");
const connectedDiv = document.getElementById("connected");
const statusDiv = document.getElementById("status");
const errorDiv = document.getElementById("error");
let connectedWallet = null;
function isSolanaWallet(wallet) {
return wallet.chains?.some((chain) => chain.startsWith("solana:"));
}
function renderWalletList(wallets) {
const solanaWallets = wallets.filter(isSolanaWallet);
if (solanaWallets.length === 0) {
walletListDiv.innerHTML = `
<div class="no-wallets">
No Solana wallets found.<br>
Install <a href="https://phantom.app" target="_blank">Phantom</a>
or another Solana wallet to continue.
</div>`;
statusDiv.textContent = "";
return;
}
statusDiv.textContent = `Found ${solanaWallets.length} wallet(s):`;
walletListDiv.innerHTML = "";
for (const wallet of solanaWallets) {
const btn = document.createElement("button");
btn.className = "wallet-btn";
const icon = wallet.icon;
btn.innerHTML = icon
? `<img src="${icon}" alt="" /> ${wallet.name}`
: wallet.name;
btn.addEventListener("click", () => connectWallet(wallet));
walletListDiv.appendChild(btn);
}
}
async function connectWallet(wallet) {
errorDiv.textContent = "";
const connectFeature = wallet.features["standard:connect"];
if (!connectFeature) {
errorDiv.textContent = "This wallet doesn't support connecting.";
return;
}
try {
statusDiv.textContent = "Requesting connection...";
const { accounts } = await connectFeature.connect();
if (accounts.length === 0) {
errorDiv.textContent = "No accounts returned. Did you reject the request?";
statusDiv.textContent = "";
return;
}
connectedWallet = wallet;
const account = accounts[0];
const address = account.address;
const { value: balanceInLamports } = await rpc.getBalance(address).send();
const balanceInSol = (Number(balanceInLamports) / 1_000_000_000).toFixed(9);
walletListDiv.style.display = "none";
statusDiv.textContent = "";
connectedDiv.style.display = "block";
connectedDiv.innerHTML = `
<h3>Connected to ${wallet.name}</h3>
<div class="address">${address}</div>
<div class="balance">${balanceInSol} SOL</div>
<button class="disconnect-btn" id="disconnectBtn">Disconnect</button>`;
document
.getElementById("disconnectBtn")
.addEventListener("click", () => disconnectWallet(wallet));
} catch (err) {
errorDiv.textContent = `Connection failed: ${err.message}`;
statusDiv.textContent = "";
}
}
async function disconnectWallet(wallet) {
const disconnectFeature = wallet.features["standard:disconnect"];
if (disconnectFeature) {
await disconnectFeature.disconnect();
}
connectedWallet = null;
connectedDiv.style.display = "none";
walletListDiv.style.display = "block";
statusDiv.textContent = "Disconnected. Choose a wallet to reconnect:";
}
const { get, on } = getWallets();
renderWalletList(get());
on("register", () => {
if (!connectedWallet) {
renderWalletList(get());
}
});
Run It
Start the dev server:
npm run dev
Open the URL Vite prints (usually http://localhost:5173). If that port is already in use, Vite will automatically pick the next available one — check the terminal output for the exact URL. You should see your installed wallet listed with its icon. Click it, and your wallet extension will pop up asking you to approve the connection. Once you approve, the page will show your wallet address and devnet balance.
What Just Happened
You built a web app that connects to a real wallet without ever touching a private key. Compare this with Days 1 and 2, where you generated keypairs and saved raw bytes to a file. Here, the wallet extension manages all of that. Your app only sees the public address and the ability to request signatures.
The getWallets() function from @wallet-standard/app doesn't know about Phantom or Solflare specifically. It discovers any wallet that implements the Wallet Standard, which is an open specification that wallet developers adopt. This means your app automatically works with wallets that didn't exist when you wrote it.
You filtered for Solana-compatible wallets by checking wallet.chains for entries starting with "solana:".
The connection flow is permission-based. When you called connectFeature.connect(), the wallet popped up and asked the user to approve. If they reject, you get an error. If they approve, you get back an array of accounts. The user is always in control of which accounts they share and when.
The on("register") listener handles a timing issue. Wallet extensions inject themselves into the page, but they might register after your script runs. The listener catches late arrivals so the UI stays up to date.
The balance query uses the same createSolanaRpc and getBalance calls from earlier days. Make sure your wallet is set to devnet, or the balance you see won't match what the devnet RPC returns.
Resources
Submission
Share a screenshot of your app showing the connected wallet's address and balance. Bonus: if you have more than one wallet extension installed, show the wallet selection screen with multiple options.