Skip to content

${redev}

Published 12/4/2025 · 5 min read

Tags: solana , javascript , web3 , wallets , phantom

So far we’ve worked in Bun with keypair files. But real web apps need to connect to browser wallets like Phantom or Solflare.

This is where Solana development gets interesting for frontend developers.

How Browser Wallets Work

When you install Phantom, it injects an object into the browser’s window:

window.phantom.solana;

This object provides methods to:

  • Connect (request permission)
  • Get the user’s public key
  • Sign transactions
  • Sign messages

The user’s private key never leaves the extension. Your app only gets the public key and the ability to request signatures.

┌─────────────────┐     ┌─────────────────┐
│   Your Web App  │────▶│  Phantom Ext    │
│                 │     │                 │
│  "Sign this tx" │     │  User approves  │
│                 │◀────│  Returns sig    │
└─────────────────┘     └─────────────────┘


                        Private key stays
                        in the extension

Detecting Phantom

First, check if Phantom is installed:

function getPhantom() {
  if (typeof window === "undefined") return null;

  const phantom = (window as any).phantom?.solana;

  if (phantom?.isPhantom) {
    return phantom;
  }

  return null;
}

// Usage
const phantom = getPhantom();

if (!phantom) {
  console.log("Phantom not installed");
  // Could redirect to phantom.app
}

Connecting

Request permission to connect:

async function connectWallet() {
  const phantom = getPhantom();

  if (!phantom) {
    window.open("https://phantom.app/", "_blank");
    return null;
  }

  try {
    const response = await phantom.connect();
    console.log("Connected:", response.publicKey.toBase58());
    return response.publicKey;
  } catch (err) {
    console.error("Connection rejected:", err);
    return null;
  }
}

When you call connect(), Phantom shows a popup asking the user to approve. If they approve, you get their public key.

Checking Connection State

Phantom remembers connections. Check if already connected:

async function checkConnection() {
  const phantom = getPhantom();

  if (!phantom) return null;

  // Check if already connected
  if (phantom.isConnected && phantom.publicKey) {
    return phantom.publicKey;
  }

  // Try to reconnect silently (no popup)
  try {
    const response = await phantom.connect({ onlyIfTrusted: true });
    return response.publicKey;
  } catch {
    // User hasn't approved this site yet
    return null;
  }
}

The onlyIfTrusted: true option attempts to connect without showing a popup - it only works if the user has previously approved your site.

Disconnecting

async function disconnectWallet() {
  const phantom = getPhantom();

  if (phantom) {
    await phantom.disconnect();
  }
}

Listening for Changes

Phantom emits events when the connection state changes:

function setupWalletListeners() {
  const phantom = getPhantom();

  if (!phantom) return;

  phantom.on("connect", (publicKey: any) => {
    console.log("Connected:", publicKey.toBase58());
  });

  phantom.on("disconnect", () => {
    console.log("Disconnected");
  });

  phantom.on("accountChanged", (publicKey: any) => {
    if (publicKey) {
      console.log("Switched to:", publicKey.toBase58());
    } else {
      console.log("Disconnected");
    }
  });
}

A Complete Vanilla JS Example

Here’s a minimal HTML page that connects to Phantom:

<!DOCTYPE html>
<html>
  <head>
    <title>Phantom Connect</title>
    <script src="https://unpkg.com/@solana/web3.js@latest/lib/index.iife.min.js"></script>
  </head>
  <body>
    <button id="connect">Connect Wallet</button>
    <button id="disconnect" style="display:none">Disconnect</button>
    <p id="address"></p>

    <script>
      const connectBtn = document.getElementById("connect");
      const disconnectBtn = document.getElementById("disconnect");
      const addressEl = document.getElementById("address");

      function getPhantom() {
        return window.phantom?.solana?.isPhantom ? window.phantom.solana : null;
      }

      function updateUI(publicKey) {
        if (publicKey) {
          addressEl.textContent = `Connected: ${publicKey.toBase58()}`;
          connectBtn.style.display = "none";
          disconnectBtn.style.display = "inline";
        } else {
          addressEl.textContent = "";
          connectBtn.style.display = "inline";
          disconnectBtn.style.display = "none";
        }
      }

      connectBtn.onclick = async () => {
        const phantom = getPhantom();
        if (!phantom) {
          window.open("https://phantom.app/", "_blank");
          return;
        }

        try {
          const { publicKey } = await phantom.connect();
          updateUI(publicKey);
        } catch (err) {
          console.error(err);
        }
      };

      disconnectBtn.onclick = async () => {
        const phantom = getPhantom();
        if (phantom) {
          await phantom.disconnect();
          updateUI(null);
        }
      };

      // Check on page load
      window.onload = async () => {
        const phantom = getPhantom();
        if (phantom?.isConnected) {
          updateUI(phantom.publicKey);
        }
      };
    </script>
  </body>
</html>

Other Wallets

Phantom isn’t the only wallet. Others include:

  • Solflare - window.solflare
  • Backpack - window.backpack
  • Glow - window.glow

Each has slightly different APIs. This is why wallet adapter libraries exist - they normalize the interface.

The Wallet Standard

Solana wallets are moving toward the “Wallet Standard” - a unified interface:

// The new standard way to detect wallets
import { getWallets } from "@wallet-standard/app";

const { get } = getWallets();
const wallets = get();

// Lists all installed wallets that support the standard
wallets.forEach((wallet) => {
  console.log(wallet.name, wallet.icon);
});

We’ll use this in the next post when building our Svelte wallet component.

TypeScript Types

Add type safety for Phantom:

// src/types/phantom.d.ts
import { PublicKey, Transaction } from "@solana/web3.js";

interface PhantomProvider {
  isPhantom: boolean;
  isConnected: boolean;
  publicKey: PublicKey | null;

  connect(opts?: {
    onlyIfTrusted?: boolean;
  }): Promise<{ publicKey: PublicKey }>;
  disconnect(): Promise<void>;

  signTransaction(tx: Transaction): Promise<Transaction>;
  signAllTransactions(txs: Transaction[]): Promise<Transaction[]>;
  signMessage(message: Uint8Array): Promise<{ signature: Uint8Array }>;

  on(event: "connect", callback: (publicKey: PublicKey) => void): void;
  on(event: "disconnect", callback: () => void): void;
  on(
    event: "accountChanged",
    callback: (publicKey: PublicKey | null) => void
  ): void;
}

declare global {
  interface Window {
    phantom?: {
      solana?: PhantomProvider;
    };
  }
}

What You Learned

  • Browser wallets inject objects into window
  • connect() requests permission and returns the public key
  • Private keys never leave the wallet extension
  • Users can disconnect or switch accounts
  • Different wallets have different APIs (hence adapter libraries)

Next Up

Now that we understand how wallet connections work, let’s build a proper Svelte component that handles all of this with reactive state.

Related Articles

  • Understanding requestAnimationFrame

    A practical guide to browser animation timing. Learn what requestAnimationFrame actually does, why it beats setInterval, and how to use it properly.

  • Lambda Expressions vs Anonymous Functions

    When learning a functional programming style you will often come across the term Lambda Expressions or Lambda Functions. In simple terms they are just functions that can be used as data and therefore declared as a value. Let's explore a few examples.

  • JavaScript typeof Number

    Often you will need to check that you have a number before using it in your JavaScript, here's how.