Skip to content

${redev}

Published 12/4/2025 · 10 min read

Tags: solana , javascript , x402 , svelte , sveltekit

Time to build something real. We’re creating a SvelteKit app with:

  • Wallet connection (Phantom, Solflare)
  • Protected API routes that require payment
  • Automatic payment handling in the frontend
  • A polished user experience

Coming from Vue, SvelteKit feels familiar - reactive by default, clean syntax, no virtual DOM overhead. Perfect for a payment-focused app where responsiveness matters.

Project Setup

npx sv create x402-sveltekit
cd x402-sveltekit
npm install @solana/web3.js @solana/spl-token

When prompted, select TypeScript and your preferred options.

The Wallet Store

Svelte stores are perfect for wallet state. Create a reactive wallet store:

// src/lib/stores/wallet.ts
import { writable, derived } from "svelte/store";
import { Connection, PublicKey, clusterApiUrl } from "@solana/web3.js";
import { browser } from "$app/environment";

interface WalletState {
  connected: boolean;
  publicKey: PublicKey | null;
  wallet: any | null;
}

function createWalletStore() {
  const { subscribe, set, update } = writable<WalletState>({
    connected: false,
    publicKey: null,
    wallet: null,
  });

  return {
    subscribe,

    async connect() {
      if (!browser) return;

      // Check for Phantom
      const phantom = (window as any).phantom?.solana;

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

      try {
        const response = await phantom.connect();
        set({
          connected: true,
          publicKey: response.publicKey,
          wallet: phantom,
        });
      } catch (err) {
        console.error("Wallet connection failed:", err);
      }
    },

    async disconnect() {
      const phantom = (window as any).phantom?.solana;
      if (phantom) {
        await phantom.disconnect();
      }
      set({ connected: false, publicKey: null, wallet: null });
    },

    // Check if already connected on page load
    async checkConnection() {
      if (!browser) return;

      const phantom = (window as any).phantom?.solana;
      if (phantom?.isConnected) {
        set({
          connected: true,
          publicKey: phantom.publicKey,
          wallet: phantom,
        });
      }
    },
  };
}

export const wallet = createWalletStore();

// Derived store for the connection
export const connection = derived(
  wallet,
  () => new Connection(clusterApiUrl("devnet"), "confirmed")
);

// Derived store for address string
export const walletAddress = derived(
  wallet,
  ($wallet) => $wallet.publicKey?.toBase58() ?? null
);

x402 Payment Store

Create a store that handles x402 payment flows:

// src/lib/stores/x402.ts
import { writable, get } from "svelte/store";
import { wallet, connection } from "./wallet";
import { Transaction, PublicKey } from "@solana/web3.js";
import {
  getAssociatedTokenAddress,
  createTransferInstruction,
  createAssociatedTokenAccountInstruction,
  TOKEN_PROGRAM_ID,
  ASSOCIATED_TOKEN_PROGRAM_ID,
} from "@solana/spl-token";

interface PaymentRequirements {
  x402Version: number;
  accepts: Array<{
    scheme: string;
    network: string;
    maxAmountRequired: string;
    payTo: string;
    asset: { address: string };
    description: string;
  }>;
}

interface X402State {
  loading: boolean;
  status: string | null;
  error: string | null;
}

function createX402Store() {
  const { subscribe, set, update } = writable<X402State>({
    loading: false,
    status: null,
    error: null,
  });

  async function makePayment(
    requirements: PaymentRequirements
  ): Promise<string> {
    const $wallet = get(wallet);
    const $connection = get(connection);

    if (!$wallet.connected || !$wallet.publicKey || !$wallet.wallet) {
      throw new Error("Wallet not connected");
    }

    const req = requirements.accepts[0];
    const amount = BigInt(req.maxAmountRequired);

    const mintPubkey = new PublicKey(req.asset.address);
    const recipientPubkey = new PublicKey(req.payTo);
    const walletPubkey = $wallet.publicKey;

    // Get ATAs
    const sourceAta = await getAssociatedTokenAddress(mintPubkey, walletPubkey);
    const destAta = await getAssociatedTokenAddress(
      mintPubkey,
      recipientPubkey
    );

    const tx = new Transaction();

    // Check if destination ATA exists
    const destAccount = await $connection.getAccountInfo(destAta);
    if (!destAccount) {
      tx.add(
        createAssociatedTokenAccountInstruction(
          walletPubkey,
          destAta,
          recipientPubkey,
          mintPubkey,
          TOKEN_PROGRAM_ID,
          ASSOCIATED_TOKEN_PROGRAM_ID
        )
      );
    }

    // Add transfer
    tx.add(
      createTransferInstruction(
        sourceAta,
        destAta,
        walletPubkey,
        amount,
        [],
        TOKEN_PROGRAM_ID
      )
    );

    // Set transaction details
    const { blockhash } = await $connection.getLatestBlockhash();
    tx.recentBlockhash = blockhash;
    tx.feePayer = walletPubkey;

    // Sign with wallet
    const signedTx = await $wallet.wallet.signTransaction(tx);

    // Send and confirm
    const signature = await $connection.sendRawTransaction(
      signedTx.serialize()
    );
    await $connection.confirmTransaction(signature, "confirmed");

    return signature;
  }

  function createPaymentHeader(
    signature: string,
    requirements: PaymentRequirements
  ): string {
    const $wallet = get(wallet);
    const req = requirements.accepts[0];

    const payload = {
      scheme: req.scheme,
      network: req.network,
      payload: {
        signature,
        payer: $wallet.publicKey?.toBase58(),
      },
    };

    return btoa(JSON.stringify(payload));
  }

  return {
    subscribe,

    async fetch(url: string, options: RequestInit = {}): Promise<Response> {
      const $wallet = get(wallet);

      if (!$wallet.connected) {
        throw new Error("Please connect your wallet");
      }

      set({ loading: true, status: null, error: null });

      try {
        // Initial request
        const response = await fetch(url, options);

        if (response.status !== 402) {
          set({ loading: false, status: "Success", error: null });
          return response;
        }

        update((s) => ({ ...s, status: "Payment required" }));
        const requirements: PaymentRequirements = await response.json();

        // Format price
        const price =
          Number(requirements.accepts[0].maxAmountRequired) / 1_000_000;
        update((s) => ({
          ...s,
          status: `Paying $${price.toFixed(2)} USDC...`,
        }));

        // Make payment
        const signature = await makePayment(requirements);
        update((s) => ({
          ...s,
          status: "Payment confirmed, fetching content...",
        }));

        // Retry with payment header
        const paymentHeader = createPaymentHeader(signature, requirements);

        const retryResponse = await fetch(url, {
          ...options,
          headers: {
            ...options.headers,
            "X-PAYMENT": paymentHeader,
          },
        });

        if (retryResponse.status === 402) {
          throw new Error("Payment was not accepted");
        }

        set({ loading: false, status: "Success!", error: null });
        return retryResponse;
      } catch (err) {
        const error = (err as Error).message;
        set({ loading: false, status: null, error });
        throw err;
      }
    },

    reset() {
      set({ loading: false, status: null, error: null });
    },
  };
}

export const x402 = createX402Store();

Protected API Route

Create a SvelteKit API endpoint that requires payment:

// src/routes/api/premium/+server.ts
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { TREASURY_ADDRESS } from "$env/static/private";

const CONFIG = {
  treasuryAddress: TREASURY_ADDRESS,
  network: "solana-devnet",
  facilitatorUrl: "https://x402.org/facilitator",
  usdcMint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
  price: "10000", // $0.01 USDC
};

export const GET: RequestHandler = async ({ request }) => {
  const paymentHeader = request.headers.get("x-payment");

  const paymentRequirements = {
    x402Version: 1,
    accepts: [
      {
        scheme: "exact",
        network: CONFIG.network,
        maxAmountRequired: CONFIG.price,
        resource: request.url,
        description: "Premium content access",
        mimeType: "application/json",
        payTo: CONFIG.treasuryAddress,
        maxTimeoutSeconds: 60,
        asset: { address: CONFIG.usdcMint },
        extra: {},
      },
    ],
  };

  if (!paymentHeader) {
    return json(paymentRequirements, { status: 402 });
  }

  // Verify payment with facilitator
  try {
    const verifyResponse = await fetch(`${CONFIG.facilitatorUrl}/verify`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        paymentHeader,
        paymentRequirements: paymentRequirements.accepts[0],
      }),
    });

    const result = await verifyResponse.json();

    if (!result.valid) {
      return json(
        { error: "Invalid payment", ...paymentRequirements },
        { status: 402 }
      );
    }
  } catch (error) {
    return json({ error: "Payment verification failed" }, { status: 500 });
  }

  // Payment verified - return premium content
  return json(
    {
      message: "Welcome to the premium zone!",
      data: {
        secret: "The answer to everything is 42",
        tips: [
          "x402 makes micropayments viable",
          "Solana transactions cost ~$0.00025",
          "SvelteKit + Solana = fast everywhere",
        ],
        generatedAt: new Date().toISOString(),
      },
    },
    {
      headers: {
        "X-PAYMENT-RESPONSE": JSON.stringify({
          success: true,
          network: CONFIG.network,
        }),
      },
    }
  );
};

Wallet Connect Component

Create a reusable wallet button:

<!-- src/lib/components/WalletButton.svelte -->
<script lang="ts">
  import { wallet, walletAddress } from '$lib/stores/wallet';
  import { onMount } from 'svelte';

  onMount(() => {
    wallet.checkConnection();
  });

  function truncateAddress(address: string): string {
    return `${address.slice(0, 4)}...${address.slice(-4)}`;
  }
</script>

{#if $wallet.connected && $walletAddress}
  <div class="wallet-connected">
    <span class="address">{truncateAddress($walletAddress)}</span>
    <button on:click={() => wallet.disconnect()} class="disconnect">
      Disconnect
    </button>
  </div>
{:else}
  <button on:click={() => wallet.connect()} class="connect">
    Connect Wallet
  </button>
{/if}

<style>
  .connect {
    background: #9945FF;
    color: white;
    padding: 0.75rem 1.5rem;
    border-radius: 0.5rem;
    border: none;
    font-weight: 600;
    cursor: pointer;
    transition: background 0.2s;
  }

  .connect:hover {
    background: #7C3AED;
  }

  .wallet-connected {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    background: #1a1a2e;
    padding: 0.5rem 1rem;
    border-radius: 0.5rem;
  }

  .address {
    color: #9945FF;
    font-family: monospace;
  }

  .disconnect {
    background: transparent;
    color: #888;
    border: 1px solid #333;
    padding: 0.5rem 1rem;
    border-radius: 0.25rem;
    cursor: pointer;
  }

  .disconnect:hover {
    color: #fff;
    border-color: #666;
  }
</style>

Main Page

The home page with payment flow:

<!-- src/routes/+page.svelte -->
<script lang="ts">
  import WalletButton from '$lib/components/WalletButton.svelte';
  import { wallet } from '$lib/stores/wallet';
  import { x402 } from '$lib/stores/x402';

  let content: any = null;

  async function loadPremiumContent() {
    content = null;

    try {
      const response = await x402.fetch('/api/premium');
      content = await response.json();
    } catch (err) {
      // Error is already in the store
    }
  }
</script>

<main>
  <h1>x402 Demo</h1>
  <p class="subtitle">Pay-per-request with Solana + SvelteKit</p>

  <!-- Wallet Connection -->
  <section class="wallet-section">
    <WalletButton />
  </section>

  <!-- Status Messages -->
  {#if $x402.status}
    <div class="status info">
      {$x402.status}
    </div>
  {/if}

  {#if $x402.error}
    <div class="status error">
      {$x402.error}
    </div>
  {/if}

  <!-- Action -->
  {#if $wallet.connected}
    <button
      on:click={loadPremiumContent}
      disabled={$x402.loading}
      class="primary-button"
    >
      {$x402.loading ? 'Processing...' : 'Get Premium Content ($0.01)'}
    </button>
  {:else}
    <p class="hint">Connect your wallet to access premium content</p>
  {/if}

  <!-- Content Display -->
  {#if content}
    <div class="content-box">
      <h2>{content.message}</h2>
      <p><strong>Secret:</strong> {content.data.secret}</p>
      <div class="tips">
        <strong>Tips:</strong>
        <ul>
          {#each content.data.tips as tip}
            <li>{tip}</li>
          {/each}
        </ul>
      </div>
    </div>
  {/if}

  <!-- How it works -->
  <section class="how-it-works">
    <h2>How it works</h2>
    <ol>
      <li>Connect your Solana wallet (with devnet USDC)</li>
      <li>Click the button to request premium content</li>
      <li>The server responds with 402 Payment Required</li>
      <li>Your wallet prompts for a $0.01 USDC payment</li>
      <li>After payment confirms, content is delivered</li>
    </ol>
  </section>
</main>

<style>
  main {
    max-width: 640px;
    margin: 0 auto;
    padding: 2rem;
  }

  h1 {
    font-size: 2.5rem;
    margin-bottom: 0.5rem;
  }

  .subtitle {
    color: #888;
    margin-bottom: 2rem;
  }

  .wallet-section {
    margin-bottom: 2rem;
  }

  .status {
    padding: 1rem;
    border-radius: 0.5rem;
    margin-bottom: 1rem;
  }

  .status.info {
    background: #1e3a5f;
    color: #7dd3fc;
  }

  .status.error {
    background: #5f1e1e;
    color: #fca5a5;
  }

  .primary-button {
    background: #14F195;
    color: #000;
    padding: 1rem 2rem;
    border-radius: 0.5rem;
    border: none;
    font-weight: 600;
    font-size: 1rem;
    cursor: pointer;
    transition: transform 0.1s, background 0.2s;
  }

  .primary-button:hover:not(:disabled) {
    background: #0fd584;
    transform: translateY(-1px);
  }

  .primary-button:disabled {
    opacity: 0.6;
    cursor: not-allowed;
  }

  .hint {
    color: #888;
  }

  .content-box {
    margin-top: 2rem;
    padding: 1.5rem;
    background: #0a2f1f;
    border: 1px solid #14F195;
    border-radius: 0.5rem;
  }

  .content-box h2 {
    color: #14F195;
    margin-bottom: 1rem;
  }

  .tips ul {
    margin-top: 0.5rem;
    padding-left: 1.5rem;
  }

  .tips li {
    color: #a7f3d0;
  }

  .how-it-works {
    margin-top: 3rem;
    padding: 1.5rem;
    background: #1a1a2e;
    border-radius: 0.5rem;
  }

  .how-it-works h2 {
    margin-bottom: 1rem;
  }

  .how-it-works ol {
    padding-left: 1.5rem;
  }

  .how-it-works li {
    color: #888;
    margin-bottom: 0.5rem;
  }
</style>

Environment Variables

Create .env:

TREASURY_ADDRESS=your_solana_wallet_address

Type Declarations for Phantom

Create type declarations for the Phantom wallet:

// src/app.d.ts
declare global {
  namespace App {
    // interface Error {}
    // interface Locals {}
    // interface PageData {}
    // interface Platform {}
  }

  interface Window {
    phantom?: {
      solana?: {
        isPhantom: boolean;
        isConnected: boolean;
        publicKey: import("@solana/web3.js").PublicKey;
        connect(): Promise<{ publicKey: import("@solana/web3.js").PublicKey }>;
        disconnect(): Promise<void>;
        signTransaction(
          tx: import("@solana/web3.js").Transaction
        ): Promise<import("@solana/web3.js").Transaction>;
        signAllTransactions(
          txs: import("@solana/web3.js").Transaction[]
        ): Promise<import("@solana/web3.js").Transaction[]>;
      };
    };
  }
}

export {};

Running the App

npm run dev

Visit http://localhost:5173, connect your Phantom wallet (with devnet USDC), and try the payment flow.

Why Svelte for x402?

Coming from Vue, here’s what I noticed:

Reactivity is cleaner - Stores update, components react. No ref() vs reactive() decisions.

Less boilerplate - The wallet store is 50 lines. The React equivalent with Context would be twice that.

Bundle size - Matters for payment apps where every millisecond of load time costs conversions.

No virtual DOM overhead - Direct DOM updates mean snappier wallet popups and status changes.

Vue Comparison

If you’re coming from Vue, here’s the mental mapping:

VueSvelte
ref()let variable (in component)
reactive()writable() store
computed()derived() store
watch()$: statement
<template>Just write HTML
v-if{#if}
v-for{#each}
@clickon:click

The x402 store pattern works almost identically to a Pinia store.

The Complete Flow

When a user clicks “Get Premium Content”:

1. SvelteKit calls /api/premium
2. API returns 402 + payment requirements
3. x402 store detects 402
4. Creates USDC transfer transaction
5. Phantom wallet popup appears
6. User approves, transaction sent
7. Waits for Solana confirmation (~1s)
8. Retries /api/premium with X-PAYMENT header
9. API verifies via facilitator
10. Returns premium content
11. Svelte reactively updates UI

What You Built

A production-ready SvelteKit template with:

✅ Phantom wallet connection ✅ Reactive wallet state with Svelte stores ✅ Protected API routes requiring USDC payment ✅ Automatic payment handling ✅ Clean, reactive UI updates ✅ Familiar patterns for Vue developers

Next Steps

  • Add Solflare and other wallet support
  • Implement session tokens for repeated access
  • Add payment history with localStorage
  • Deploy to Vercel/Cloudflare Pages

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.

  • Form Handling: Moving from Vue to Svelte

    A practical guide to translating Vue form patterns to Svelte, covering two-way binding, validation, async submission, and what actually works better in each framework.

  • Building a Modal: Vue vs Svelte

    A side-by-side comparison of building a modal component in Vue 3 and Svelte 5, exploring the differences in reactivity, props, and component patterns.