Cover photo

Develop World Class Swap Hooks with Jupiter Exchange's V6 Swap API

Ever wanted to build a dApp with world-class user swap experience? Well, look no further! This builder guide streamlines the process for integrating the V6 Swap API of Jupiter Exchange into your dApp.

"Increase Bandwidth, Reduce Latency", is more than just a tagline for Solana. It is a core principle that drives the network's innovation, enabling dApps built on Solana to deliver unparalleled consumer experiences. Jupiter Exchange perfectly exemplifies this vision, offering seamless and lightning-fast token swaps, making it the embodiment of Solana’s commitment to speed and efficiency.

Now, what if I told you that in just 15 to 30 minutes, the Solana dApp that you’re building could seamlessly integrate a Jupiter Swap feature that enables users to transact effortlessly without ever leaving the platform?

That's right. With Jupiter's streamlined swap integration process, you can empower your users to access lightning-fast token swaps directly within your dApp. No extra steps, no unnecessary redirects. Just a smooth, intuitive experience that keeps users engaged while enhancing the functionality of your platform.

In this Builder Guide, I'll walk you through how you can create from scratch a Swap feature that enables seamless swaps on your platform while also covering how you can successfully land transactions onchain with Jupiter. Additionally, I'll show how you can activate a referral fee feature, allowing your platform to earn a fee on every swap transaction made through it. Whether you’re an established Solana dApp or just starting your journey, this guide will provide you with the tools and insights to build a world-class user experience.

How Jupiter Swap Works

Fundamentally, Jupiter Swap is powered by an intricate routing engine, named Metis, designed to handle Solana's numerous AMMs and provide users with the best swap prices while operating seamlessly on Solana's hyper-fast block times.

The diagram below illustrates the evolution of Jupiter Swaps, progressing from V1 to V2 to Metis, growing from single-route swaps to multi-route swaps to incremental-route swaps. Metis optimizes swap prices by streaming input tokens to incrementally construct routes that can split and merge at any stage. This iterative approach allows routes to be generated sequentially for each split, enabling the reuse of the same / different DEX across different splits. As a result, Jupiter can dynamically evaluate route prices, ensuring users receive the best possible swap price.

Overview of Jupiter's APIs

Jupiter Swap API - https://quote-api.jup.ag/v6/swap

The Jupiter Swap API facilitates seamless token swaps on the Solana blockchain by enabling developers to interact with liquidity pools efficiently. Upon receiving these inputs, the API returns a serialized transaction that can be signed and broadcasted to the network. You can enhance transaction success by leveraging advanced features like priority fees, compute unit limits, and MEV protection.

The following JSON represents the payload for a POST /swap endpoint request:

{
  // The public key of the user making the request (REQUIRED)
  "userPublicKey": "string",

  // Automatically wrap/unwrap SOL if true; use wSOL token account otherwise (default: true)
  "wrapAndUnwrapSol": true,

  // Enable shared program accounts; required if using destinationTokenAccount
  "useSharedAccounts": true,

  // Fee token account; must be created beforehand if feeBps is set
  "feeAccount": "string",

  // A public key used to track transactions (useful for integrators)
  "trackingAccount": "string",

  // Compute unit price for prioritizing the transaction (in micro lamports)
  "computeUnitPriceMicroLamports": 0,

  // Additional fee for prioritizing the transaction in lamports
  "prioritizationFeeLamports": 0,

  // Request a legacy transaction instead of a versioned transaction (default: false)
  "asLegacyTransaction": false,

  // Use the token ledger to calculate token input based on differences (default: false)
  "useTokenLedger": false,

  // Public key of the token account to receive the output tokens
  "destinationTokenAccount": "string",

  // Enable dynamic compute unit limits using swap simulation (default: false)
  "dynamicComputeUnitLimit": true,

  // Skip RPC calls to check user's accounts; assumes all accounts are set up
  "skipUserAccountsRpcCalls": true,

  // Dynamic slippage settings for trade optimization
  "dynamicSlippage": {
    // Minimum slippage tolerance in basis points (bps)
    "minBps": 0,

    // Maximum slippage tolerance in basis points (bps)
    "maxBps": 0
  },

  // Response from a quote including details of the transaction
  "quoteResponse": {
    // Mint address of the input token
    "inputMint": "string",

    // Amount of input tokens
    "inAmount": "string",

    // Mint address of the output token
    "outputMint": "string",

    // Amount of output tokens
    "outAmount": "string",

    // Threshold amount for the trade
    "otherAmountThreshold": "string",

    // Swap mode (e.g., ExactIn or ExactOut)
    "swapMode": "ExactIn",

    // Slippage in basis points
    "slippageBps": 0,

    // Platform fee details
    "platformFee": {
      // Amount of the fee
      "amount": "string",

      // Fee in basis points
      "feeBps": 0
    },

    // Percentage impact on price due to the trade
    "priceImpactPct": "string",

    // Route plan details for the trade
    "routePlan": [
      {
        "swapInfo": {
          // Key of the automated market maker (AMM)
          "ammKey": "string",

          // Label identifying the AMM
          "label": "string",

          // Mint address of the input token
          "inputMint": "string",

          // Mint address of the output token
          "outputMint": "string",

          // Input token amount
          "inAmount": "string",

          // Output token amount
          "outAmount": "string",

          // Fee amount for the trade
          "feeAmount": "string",

          // Mint address of the fee token
          "feeMint": "string"
        },

        // Percentage of the total trade allocated to this route
        "percent": 0
      }
    ],

    // Slot context of the quote
    "contextSlot": 0,

    // Time taken for the quote in milliseconds
    "timeTaken": 0
  }
}

Jupiter Quote API - https://quote-api.jup.ag/v6/quote

The Jupiter Quote API is used to obtain optimal token swap routes and pricing. The API provides a detailed route plan, price impact, and fee breakdown to help users evaluate their swaps before execution.

Required Parameters:

  • inputMint (string): The mint address of the input token.

  • outputMint (string): The mint address of the output token.

  • amount (integer): The amount of input tokens in the smallest unit (e.g. lamports for SOL).

Optional Parameters:

  • slippageBps (integer): Defaults to 50 basis points (0.5%). Specifies the maximum slippage allowed for the swap.

  • dexes (string[]): Defaults to including all available decentralized exchanges (DEXes). You can specify an array of DEX names to include only selected DEXes.

  • excludeDexes (string[]): Excludes specific decentralized exchanges from the routing. (e.g. ["DEX1", "DEX2"]).

  • swapMode (string): ExactIn (default) for swaps with a fixed input amount. ExactOut for swaps requiring an exact output amount (useful for payments).

  • restrictIntermediateTokens (boolean): Defaults to false. Restricts intermediate tokens to highly liquid pairs. This also helps to increase the chances of your transaction landing onchain.

  • onlyDirectRoutes (boolean): Defaults to false. If set to true, the routing is restricted to single-hop routes (direct swaps) only.

  • asLegacyTransaction (boolean): Defaults to false. When enabled, the API uses legacy transactions instead of versioned transactions.

  • platformFeeBps (integer): Specifies a platform fee in basis points (BPS) to be deducted from the output token amount.

  • maxAccounts (integer): Allows you to limit the maximum number of accounts involved in the quote for easier integration with your own accounts.

  • autoSlippage (boolean): Defaults to false. If enabled, the API provides a suggested smart slippage value to optimize the swap process.

  • autoSlippageCollisionUsdValue (integer): Defaults to 1000. When autoSlippage is enabled, this parameter defines the USD value used to calculate the smart slippage impact. You can set a custom USD value here.

  • maxAutoSlippageBps (integer): Works in conjunction with autoSlippage=true. Specifies the maximum slippageBps allowed when using smart slippage. Setting this ensures the slippage value remains within a reasonable range.

Jupiter Tokens API - https://tokens.jup.ag/tokens

The Jupiter Tokens API provides detailed information about tokens available in the Jupiter ecosystem. It offers flexibility for retrieving token data by tag, mint address, or liquidity/market thresholds. Here are the details of the parameters and endpoints:

GET Tokens by Tag - https://tokens.jup.ag/tokens?tags=

  • Retrieves tokens filtered by specific tags.

  • Convenience Tags:

    • verified: Tokens verified and trusted by Jupiter. Combines “community” and “lst” tags.

    • unknown: Tokens flagged as unverified or requiring caution.

  • Other Tags:

    • community: Tokens approved by the Jupiter community.

    • strict: Previously validated tokens from deprecated strict-list repositories.

    • lst: Tokens from Sanctum’s list.

    • birdeye-trending: Top 100 trending tokens from BirdEye.

    • clone: Tokens listed by Clone protocol.

    • pump: Tokens that graduated from the Pump protocol.

GET Token by Mint - https://tokens.jup.ag/token/{mint_address}

  • Retrieves metadata for a specific token by its mint address.

  • mint_address (string): The token’s unique mint address.

GET Tradable Tokens - https://tokens.jup.ag/tokens_with_markets

  • Fetches a list of tokens tradable on Jupiter, meeting liquidity and routing thresholds.

Step-By-Step Guide: Equipping your dApp with Jupiter Swap

To provide context, in this guide we will be focusing mainly on the implementation of Typescript Hooks with Jupiter's V6 Swap API while utilizing Zustand for persistent state management.

NOTE: If you would like an even quicker and easier implementation with the user interface done up, you can go through the following resources:

  1. Jupiter Terminal - an open-source plug-and-play terminal that can be embedded in your dApp as an integrated swap OR widget OR modal. (An article will be released in the future covering this)

  2. Jupiverse Kit - an open-source passion project kickstarted by myself to equip Solana developers with a ready-to-use React components library powered by Jupiter. (An article will be released in the future covering this)

Now back to building .

In essence, accessing liquidity on Solana has never been easier, thanks to Jupiter’s innovative V6 Swap API. By simply sending a HTTP request to https://quote-api.jup.ag/v6 and specifying the token pairs, amount, and slippage, developers can quickly obtain serialized transactions that are ready for execution. These transactions can then be signed and submitted to the Solana blockchain with minimal effort. However, there are several prerequisites that you will have to prepare first before using the V6 Swap API.

Prerequisites

Create an interfaces.ts to store all TypeScript interfaces

export interface Token {
  address: string;
  name: string;
  symbol: string;
  decimals: number;
  logoURI: string;
  tags: string[];
  daily_volume: number;
  freeze_authority: string | null;
  mint_authority: string | null;
}

export interface QuoteResponse {
  inputMint: string;
  outputMint: string;
  amount: string;
  swapMode: "ExactIn" | "ExactOut";
  slippageBps: number;
  otherAmountThreshold: string;
  routes: any[];
  contextSlot: number;
}

export interface SwapResponse {
  swapTransaction: string;
}

export interface SwapState {
  tokenFrom: Token | null;
  tokenTo: Token | null;
  amountFrom: string;
  amountTo: string;
  slippage: number;
  quoteResponse: QuoteResponse | null;
  isCalculating: boolean;
  isSwapping: boolean;
  isFromDialogOpen: boolean;
  isToDialogOpen: boolean;
  error: string | null;
}

export interface SwapActions {
  setTokenFrom: (token: Token | null) => void;
  setTokenTo: (token: Token | null) => void;
  setAmountFrom: (amount: string) => void;
  setAmountTo: (amount: string) => void;
  setSlippage: (slippage: number) => void;
  setQuoteResponse: (quote: QuoteResponse | null) => void;
  setIsCalculating: (isCalculating: boolean) => void;
  setIsSwapping: (isSwapping: boolean) => void;
  setIsFromDialogOpen: (isOpen: boolean) => void;
  setIsToDialogOpen: (isOpen: boolean) => void;
  setError: (error: string | null) => void;
  reset: () => void;
  swapTokens: () => void;
  resetAmounts: () => void;
}

Implement useTokens.ts - a Token Hook for Fetching Verified Tokens

The useTokens.ts hook fetches verified tokens via Jupiter's Tokens API

import { useEffect, useState } from "react";
import { Token } from "../utils/interfaces";

export const useTokens = () => {
  const [tokens, setTokens] = useState<Token[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchTokens = async () => {
      try {
        setLoading(true);
        const response = await fetch(
          "https://tokens.jup.ag/tokens?tags=verified"
        );
        if (!response.ok) {
          throw new Error("Failed to fetch tokens");
        }
        const data = await response.json();
        setTokens(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : "Failed to fetch tokens");
      } finally {
        setLoading(false);
      }
    };

    fetchTokens();
  }, []);

  return { tokens, loading, error };
};

Create useSwap.ts - a Swap Hook with Jupiter's V6 Swap API

This useSwap.ts Hook will be the main file that interacts with Jupiter's Swap APIs, providing the following key functionalities:

  • Quote Retrieval - the getQuote function is responsible for fetching a quote for swapping a specified amount of one token for another, while taking into consideration the slippage and platform fees.

    • Ensure that restrictIntermediateTokens is set to true. Without it, your route may be routed through random intermediate tokens, leading to higher chances of failure. Enabling this option ensures your route only uses highly liquid intermediate tokens, providing you with the best price and a more stable transaction path.

  • Swap Execution - the executeSwap function is in charge of preparing and executing a swap transaction.

    • You can access the Jupiter Referral Dashboard to create your referral account and obtain your referral key. By using this key, you can earn fees on every swap transaction conducted through your platform.

  • Output Calculation - the calculateOutputAmount function estimates the output amount of tokens received from a swap based on the input amount and current market conditions.

  • Max Input Calculation - the calculateMaxInput function determines the maximum amount of a token that can be swapped, while taking into account the necessary gas fees if the token is SOL.

import { useCallback, useMemo, useState } from "react";
import { useWallet } from "@solana/wallet-adapter-react";
import {
  Connection,
  LAMPORTS_PER_SOL,
  PublicKey,
  VersionedTransaction,
} from "@solana/web3.js";
import { Token, QuoteResponse, SwapResponse } from "../utils/interfaces";

// Minimum SOL to keep for gas fees (0.01 SOL)
const MIN_SOL_FOR_GAS = 0.01 * LAMPORTS_PER_SOL;
const WSOL_MINT = "So11111111111111111111111111111111111111112";

interface UseSwapProps {
  rpcUrl: string;
  referralKey?: string;
  platformFeeBps?: number;
}

export const useSwap = ({
  rpcUrl,
  referralKey,
  platformFeeBps,
}: UseSwapProps) => {
  const { publicKey, signTransaction } = useWallet();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // Initialize connection with provided RPC URL
  const connection = useMemo(() => new Connection(rpcUrl), [rpcUrl]);

  // Get quote for swap
  const getQuote = useCallback(
    async (
      inputToken: Token,
      outputToken: Token,
      amount: number,
      slippageBps: number = 300
    ) => {
      try {
        setLoading(true);
        setError(null);

        const url = new URL("https://quote-api.jup.ag/v6/quote");
        url.searchParams.append("inputMint", inputToken.address);
        url.searchParams.append("outputMint", outputToken.address);
        url.searchParams.append(
          "amount",
          (amount * Math.pow(10, inputToken.decimals)).toString()
        );
        url.searchParams.append("slippageBps", slippageBps.toString());
        url.searchParams.append("restrictIntermediateTokens", "true");

        // Add platform fee if provided
        if (platformFeeBps) {
          url.searchParams.append("platformFeeBps", platformFeeBps.toString());
        }

        const response = await fetch(url.toString());
        const data = await response.json();

        if (!response.ok) {
          throw new Error(data.error || "Failed to get quote");
        }

        return data as QuoteResponse;
      } catch (err) {
        const errorMessage =
          err instanceof Error ? err.message : "Failed to get quote";
        setError(errorMessage);
        throw new Error(errorMessage);
      } finally {
        setLoading(false);
      }
    },
    [platformFeeBps]
  );

  // Execute swap
  const executeSwap = useCallback(
    async (quoteResponse: QuoteResponse) => {
      if (!publicKey || !signTransaction) {
        setError("Wallet not connected");
        return null;
      }

      try {
        setLoading(true);
        setError(null);

        let feeAccount;
        if (referralKey) {
          // Calculate fee account address
          const [feeAccountAddress] = await PublicKey.findProgramAddressSync(
            [
              Buffer.from("referral_ata"),
              new PublicKey(referralKey).toBuffer(),
              new PublicKey(quoteResponse.inputMint).toBuffer(),
            ],
            new PublicKey("REFER4ZgmyYx9c6He5XfaTMiGfdLwRnkV4RPp9t9iF3")
          );
          feeAccount = feeAccountAddress.toString();
        }

        const swapResponse = await fetch("https://quote-api.jup.ag/v6/swap", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            quoteResponse,
            userPublicKey: publicKey.toString(),
            wrapAndUnwrapSol: true,
            dynamicComputeUnitLimit: true,
            feeAccount,
            dynamicSlippage: {
              maxBps: quoteResponse.slippageBps,
            },
            prioritizationFeeLamports: {
              priorityLevelWithMaxLamports: {
                maxLamports: 10000000,
                priorityLevel: "veryHigh",
              },
            },
          }),
        });

        if (!swapResponse.ok) {
          throw new Error("Failed to prepare swap transaction");
        }

        const { swapTransaction } = (await swapResponse.json()) as SwapResponse;

        // Deserialize the transaction
        const swapTransactionBuf = Buffer.from(swapTransaction, "base64");
        const transaction = VersionedTransaction.deserialize(
          new Uint8Array(swapTransactionBuf.toJSON().data)
        );

        // Sign the transaction
        const signedTransaction = await signTransaction(transaction);

        // Execute the transaction
        const rawTransaction = signedTransaction.serialize();
        const txid = await connection.sendRawTransaction(rawTransaction, {
          skipPreflight: true,
          maxRetries: 2,
        });

        // Get the latest blockhash
        const latestBlockhash = await connection.getLatestBlockhash();

        // Confirm transaction
        await connection.confirmTransaction({
          blockhash: latestBlockhash.blockhash,
          lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
          signature: txid,
        });

        return txid;
      } catch (err) {
        const errorMessage =
          err instanceof Error ? err.message : "Failed to execute swap";
        setError(errorMessage);
        throw new Error(errorMessage);
      } finally {
        setLoading(false);
      }
    },
    [connection, publicKey, signTransaction, referralKey]
  );

  // Calculate output amount based on input
  const calculateOutputAmount = useCallback(
    async (inputToken: Token, outputToken: Token, inputAmount: number) => {
      if (!inputAmount || inputAmount <= 0) return "0";

      try {
        const quote = await getQuote(inputToken, outputToken, inputAmount);
        if (!quote) return "0";

        // Convert amount from base units to decimal
        const outputAmount = (
          parseInt(quote.otherAmountThreshold) /
          Math.pow(10, outputToken.decimals)
        ).toString();

        return outputAmount;
      } catch (err) {
        const errorMessage =
          err instanceof Error
            ? err.message
            : "Failed to calculate output amount";
        console.error("Failed to calculate output amount:", err);
        setError(errorMessage);
        throw new Error(errorMessage);
      }
    },
    [getQuote]
  );

  // Calculate max input amount accounting for gas fees if token is SOL
  const calculateMaxInput = useCallback(
    async (inputToken: Token, balance: number) => {
      if (!balance || balance <= 0) return 0;

      // If input token is SOL, leave some for gas
      if (inputToken.address === WSOL_MINT) {
        const maxAmount = Math.max(
          0,
          (balance * LAMPORTS_PER_SOL - MIN_SOL_FOR_GAS) / LAMPORTS_PER_SOL
        );
        return parseFloat(maxAmount.toFixed(inputToken.decimals));
      }

      // For other tokens, use full balance
      return balance;
    },
    []
  );

  return {
    getQuote,
    executeSwap,
    calculateOutputAmount,
    calculateMaxInput,
    loading,
    error,
  };
};

Construct useSwapStore.ts - a Zustand Store for Managing Swap State

The useSwapStore.ts manages the state and actions related to the useSwap.ts hook, ensuring that the token states are saved across sessions, providing world-class user experience. Additionally, the useSwapSelector hook is provided for optimized state selection.

import { create } from "zustand";
import { persist } from "zustand/middleware";
import { SwapState, SwapActions } from "../utils/interfaces";

// Define the initial state of the swap store
const initialState: SwapState = {
  tokenFrom: null,
  tokenTo: null,
  amountFrom: "",
  amountTo: "",
  slippage: 3,
  quoteResponse: null,
  isCalculating: false,
  isSwapping: false,
  isFromDialogOpen: false,
  isToDialogOpen: false,
  error: null,
};

export const useSwapStore = create<SwapState & SwapActions>()(
  persist(
    (set, get) => ({
      ...initialState,

      setTokenFrom: (token) => set({ tokenFrom: token }),
      setTokenTo: (token) => set({ tokenTo: token }),
      setAmountFrom: (amount) => {
        // Always set the input amount
        set({ amountFrom: amount });

        // Reset 'to' amount if 'from' amount is invalid
        if (!amount || isNaN(parseFloat(amount))) {
          set({ amountTo: "0", quoteResponse: null });
        }
      },
      setAmountTo: (amount) => set({ amountTo: amount }),
      setSlippage: (slippage) => set({ slippage }),
      setQuoteResponse: (quote) => set({ quoteResponse: quote }),
      setIsCalculating: (isCalculating) => set({ isCalculating }),
      setIsSwapping: (isSwapping) => set({ isSwapping }),
      setIsFromDialogOpen: (isOpen) => set({ isFromDialogOpen: isOpen }),
      setIsToDialogOpen: (isOpen) => set({ isToDialogOpen: isOpen }),
      setError: (error) => {
        set({ error });
        // Reset 'to' amount when there's an error to trigger recalculation
        if (error) {
          set({ amountTo: "", quoteResponse: null });
        }
      },

      reset: () => {
        const { tokenFrom, tokenTo } = get();
        set({
          ...initialState,
          // Preserve selected tokens
          tokenFrom,
          tokenTo,
        });
      },

      resetAmounts: () =>
        set({
          amountFrom: "",
          amountTo: "",
          quoteResponse: null,
          error: null,
        }),

      swapTokens: () => {
        const { tokenFrom, tokenTo, amountFrom, amountTo } = get();
        set({
          tokenFrom: tokenTo,
          tokenTo: tokenFrom,
          amountFrom: amountTo,
          amountTo: amountFrom,
        });
      },
    }),
    {
      name: "swap-storage",
      // Only persist tokens, amounts, and slippage
      partialize: (state) => ({
        tokenFrom: state.tokenFrom,
        tokenTo: state.tokenTo,
        amountFrom: state.amountFrom,
        amountTo: state.amountTo,
        slippage: state.slippage,
      }),
    }
  )
);

// Selector hook for better performance
export const useSwapSelector = <T>(selector: (state: SwapState) => T) =>
  useSwapStore(selector);

Create useSwapOperations.ts - a Swap Operations Hook to facilitate token swapping

The useSwapOperations.ts is designed to manage the state and logic required for calculating token swap quote, executing swaps, and handling user interactions related to token swaps. Key functionalities includes:

  • Quote Calculation - uses the getQuote function from useSwap.ts

  • Swap Execution - uses the executeSwap function from useSwap.ts

  • Auto-Refresh and Debounce - auto refresh and calculate debounces via useEffect hooks

  • Error Handling - provides user feedback through toast notifications for various error scenarios

P.S. If your dApp already includes toast notifications, you can replace the ones in this file with the toast notifications used in your dApp.

import React, { useCallback, useEffect, useRef } from "react";
import { useSwapStore, useSwapSelector } from "../store/useSwapStore";
import { useSwap } from "./useSwap";
import { useTokenBalance } from "./useTokenBalance";
import { SquareArrowOutUpRightIcon } from "lucide-react";
import { toast } from "sonner";

interface SwapOperationsConfig {
  rpcUrl: string;
  referralKey?: string;
  platformFeeBps?: number;
}

export const useSwapOperations = (config: SwapOperationsConfig) => {
  // State selectors
  const tokenFrom = useSwapSelector((state) => state.tokenFrom);
  const tokenTo = useSwapSelector((state) => state.tokenTo);
  const amountFrom = useSwapSelector((state) => state.amountFrom);
  const amountTo = useSwapSelector((state) => state.amountTo);
  const slippage = useSwapSelector((state) => state.slippage);
  const isCalculating = useSwapSelector((state) => state.isCalculating);
  const isSwapping = useSwapSelector((state) => state.isSwapping);

  // Actions
  const {
    setAmountFrom,
    setAmountTo,
    setQuoteResponse,
    setIsCalculating,
    setIsSwapping,
    setError,
  } = useSwapStore();

  const { getQuote, executeSwap, calculateMaxInput } = useSwap(config);
  const { balance: fromBalance, refetch: refetchFromBalance } =
    useTokenBalance(tokenFrom);
  const { balance: toBalance, refetch: refetchToBalance } =
    useTokenBalance(tokenTo);

  const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  // Memoized quote calculation with debounce
  const calculateQuote = useCallback(
    async (amount: string) => {
      // Skip quote calculation if tokens aren't selected
      if (!tokenFrom || !tokenTo) {
        setAmountTo("0");
        setQuoteResponse(null);
        setIsCalculating(false);
        return;
      }

      // Handle empty input silently
      if (!amount) {
        setAmountTo("0");
        setQuoteResponse(null);
        setIsCalculating(false);
        return;
      }

      // Show error for invalid number after debounce
      if (isNaN(parseFloat(amount))) {
        setAmountTo("0");
        setQuoteResponse(null);
        setIsCalculating(false);
        toast.error(
          <div className="flex flex-col bg-red-100 w-full p-4 rounded-lg">
            <span className="font-semibold">
              Invalid Input: Please enter a valid number
            </span>
            <span>The amount must be a valid numerical value</span>
          </div>,
          {
            style: {
              padding: 0,
              margin: 0,
            },
          }
        );
        return;
      }

      const parsedAmount = parseFloat(amount);
      // Show error for zero or negative amount after debounce
      if (parsedAmount <= 0) {
        setAmountTo("0");
        setQuoteResponse(null);
        setIsCalculating(false);
        toast.error(
          <div className="flex flex-col bg-red-100 w-full p-4 rounded-lg">
            <span className="font-semibold">Invalid Amount</span>
            <span>Amount must be greater than 0</span>
          </div>,
          {
            style: {
              padding: 0,
              margin: 0,
            },
          }
        );
        return;
      }

      // Check balance only after we have a valid amount
      if (fromBalance !== null && parsedAmount > fromBalance) {
        setAmountTo("0");
        setQuoteResponse(null);
        toast.error(
          <div className="flex flex-col bg-red-100 w-full p-4 rounded-lg">
            <span className="font-semibold">Insufficient Balance</span>
            <span>You don't have enough {tokenFrom.symbol} in your wallet</span>
          </div>,
          {
            style: {
              padding: 0,
              margin: 0,
            },
          }
        );
        return;
      }

      setIsCalculating(true);
      try {
        const quote = await getQuote(
          tokenFrom,
          tokenTo,
          parsedAmount,
          slippage * 100
        );

        if (quote) {
          setQuoteResponse(quote);
          const outputAmount = (
            parseInt(quote.otherAmountThreshold) /
            Math.pow(10, tokenTo.decimals)
          ).toString();
          setAmountTo(outputAmount);
        }
      } catch (err) {
        const errorMessage =
          err instanceof Error
            ? err.message
            : "Failed to calculate output amount";
        console.error("Failed to calculate output amount:", err);
        setAmountTo("0");
        setQuoteResponse(null);
        toast.error(
          <div className="flex flex-col bg-red-100 w-full p-4 rounded-lg">
            <span className="font-semibold">Quote Calculation Failed</span>
            <span className="break-words text-wrap">{errorMessage}</span>
          </div>,
          {
            style: {
              padding: 0,
              margin: 0,
            },
          }
        );
      } finally {
        setIsCalculating(false);
      }
    },
    [
      tokenFrom,
      tokenTo,
      slippage,
      fromBalance,
      getQuote,
      setAmountTo,
      setQuoteResponse,
      setIsCalculating,
    ]
  );

  // Add this new function to handle manual refresh
  const handleRefreshQuote = useCallback(() => {
    if (!amountFrom || !tokenFrom || !tokenTo) return;

    // Clear existing timer if any
    if (refreshTimerRef.current) {
      clearTimeout(refreshTimerRef.current);
    }

    // Trigger immediate quote calculation
    calculateQuote(amountFrom);

    // Start new refresh timer
    refreshTimerRef.current = setTimeout(() => {
      handleRefreshQuote();
    }, 15000);
  }, [amountFrom, tokenFrom, tokenTo, calculateQuote]);

  // Add auto-refresh effect
  useEffect(() => {
    if (amountFrom && tokenFrom && tokenTo && !isCalculating && !isSwapping) {
      refreshTimerRef.current = setTimeout(() => {
        handleRefreshQuote();
      }, 15000);
    }

    return () => {
      if (refreshTimerRef.current) {
        clearTimeout(refreshTimerRef.current);
      }
    };
  }, [
    amountFrom,
    tokenFrom,
    tokenTo,
    isCalculating,
    isSwapping,
    handleRefreshQuote,
  ]);

  // Debounced quote calculation
  const debouncedCalculateQuote = useCallback(
    (amount: string) => {
      const debounced = setTimeout(() => calculateQuote(amount), 500);
      return () => clearTimeout(debounced);
    },
    [calculateQuote]
  );

  // Effect to handle input validation and trigger quote calculation
  useEffect(() => {
    const validateAndCalculate = () => {
      // Reset 'to' amount for empty/invalid input without showing error
      if (!amountFrom || isNaN(parseFloat(amountFrom))) {
        setAmountTo("0");
        setQuoteResponse(null);
        return;
      }

      const parsedAmount = parseFloat(amountFrom);
      // Skip calculation for zero or negative amounts without showing error
      if (parsedAmount <= 0) {
        setAmountTo("0");
        setQuoteResponse(null);
        return;
      }

      // Proceed with quote calculation if input is valid and positive
      debouncedCalculateQuote(amountFrom);
    };

    validateAndCalculate();
  }, [amountFrom, debouncedCalculateQuote, setAmountTo, setQuoteResponse]);

  // Calculate maximum input amount
  const calculateMaximumInput = useCallback(async () => {
    if (!tokenFrom || !fromBalance || fromBalance <= 0) return;

    const maxAmount = await calculateMaxInput(tokenFrom, fromBalance);
    return maxAmount.toFixed(tokenFrom.decimals);
  }, [tokenFrom, fromBalance, calculateMaxInput]);

  // Handle swap execution
  const handleSwap = useCallback(async () => {
    if (!tokenFrom || !tokenTo || !amountFrom || !amountTo) {
      toast.error(
        <div className="flex flex-col bg-red-100 w-full p-4 rounded-lg">
          <span className="font-semibold">Invalid Swap</span>
          <span>
            Please ensure you have selected tokens and entered valid amounts
          </span>
        </div>,
        {
          style: {
            padding: 0,
            margin: 0,
          },
        }
      );
      return;
    }

    setIsSwapping(true);
    const loadingToast = toast.loading(
      <div className="flex flex-col p-4">
        <span className="font-semibold">Processing Transaction</span>
        <span className="text-xs text-black/50">
          Please wait while your transaction is being processed
        </span>
      </div>,
      {
        style: {
          padding: 0,
          margin: 0,
        },
      }
    );

    try {
      const quote = await getQuote(
        tokenFrom,
        tokenTo,
        parseFloat(amountFrom),
        slippage * 100
      );

      if (!quote) {
        throw new Error("Failed to get quote");
      }

      const txid = await executeSwap(quote);
      if (!txid) {
        throw new Error("Failed to execute swap");
      }

      // Reset amounts and states only on successful swap
      setAmountFrom("");
      setAmountTo("0");
      setQuoteResponse(null);
      setError(null);

      toast.dismiss(loadingToast);
      toast.success(
        <div className="flex flex-col bg-green-100 w-full p-4  rounded-lg">
          <span className="font-semibold">Swap Successful!</span>
          <span className="text-xs text-black/50">
            <span className="break-words">Transaction ID:</span>
            <a
              href={`https://solana.fm/tx/${txid}`}
              target="_blank"
              rel="noopener noreferrer"
              className="text-blue-500 hover:underline break-words"
            >
              {txid}{" "}
              <SquareArrowOutUpRightIcon className="h-3 w-3 inline align-text-bottom" />
            </a>
          </span>
        </div>,
        {
          style: {
            padding: 0,
            margin: 0,
          },
        }
      );

      await Promise.all([refetchFromBalance(), refetchToBalance()]);
    } catch (err) {
      const errorMessage =
        err instanceof Error ? err.message : "Failed to execute swap";

      toast.dismiss(loadingToast);

      // Check if user rejected/cancelled the transaction
      if (
        errorMessage.toLowerCase().includes("user rejected") ||
        errorMessage.toLowerCase().includes("cancelled")
      ) {
        toast.error(
          <div className="flex flex-col bg-red-100 w-full p-4 rounded-lg">
            <span className="font-semibold">Transaction Cancelled</span>
            <span>You cancelled the transaction</span>
          </div>,
          {
            style: {
              padding: 0,
              margin: 0,
            },
          }
        );
      } else {
        toast.error(
          <div className="flex flex-col bg-red-100 w-full p-4 rounded-lg">
            <span className="font-semibold">Transaction Failed</span>
            <span className="break-words text-wrap">{errorMessage}</span>
          </div>,
          {
            style: {
              padding: 0,
              margin: 0,
            },
          }
        );
      }

      console.error("Swap failed:", err);
      setError(errorMessage);

      // Refetch quote to ensure prices are up to date
      if (amountFrom && !isNaN(parseFloat(amountFrom))) {
        calculateQuote(amountFrom);
      }
    } finally {
      setIsSwapping(false);
    }
  }, [
    tokenFrom,
    tokenTo,
    amountFrom,
    amountTo,
    slippage,
    getQuote,
    executeSwap,
    setAmountFrom,
    setAmountTo,
    setQuoteResponse,
    setIsCalculating,
    setIsSwapping,
    setError,
    refetchFromBalance,
    refetchToBalance,
  ]);

  return {
    calculateMaximumInput,
    handleSwap,
    handleRefreshQuote,
    fromBalance,
    toBalance,
  };
};

Implementing useTokens.ts + useSwap.ts + useSwapStore.ts + useSwapOperations.ts in your dApp UI

With the logic already set up, we can now proceed to implement the <Swap /> component in the dApp’s UI. Essentially, since useSwap.ts is called from useSwapOperations.ts, your <Swap /> component will only need to import the following modules:

import { useTokens } from "../hooks/useTokens";
import { useSwapOperations } from "../hooks/useSwapOperations";
import { useSwapStore } from "../store/useSwapStore";

Thereafter, we can initialize the hooks like this:

const { tokens } = useTokens();
const {
    tokenFrom,
    tokenTo,
    amountFrom,
    amountTo,
    slippage,
    isFromDialogOpen,
    isToDialogOpen,
    isCalculating,
    isSwapping,
    setTokenFrom,
    setTokenTo,
    setAmountFrom,
    setSlippage,
    setIsFromDialogOpen,
    setIsToDialogOpen,
    swapTokens,
  } = useSwapStore();
const {
    calculateMaximumInput,
    handleSwap,
    handleRefreshQuote,
    fromBalance,
    toBalance,
  } = useSwapOperations({ rpcUrl, referralKey, platformFeeBps });

After initializing the hooks, you can now use these variables and methods to implement the core functionality and UI interactions of the <Swap /> component that you desire.

Here’s a TL;DR example of how I integrated the variables and methods into my <Swap /> component. Note that I modularized my codebase by creating custom components and helper functions to streamline the swap interface. The implementation utilizes NextJS (TypeScript) and TailwindCSS for a clean and efficient build.

import React, { useEffect } from "react";
import { ReactComponent as JupiterBrightLogo } from "../../assets/powered-by-jupiter/poweredbyjupiter-bright.svg";
import { ReactComponent as JupiterDarkLogo } from "../../assets/powered-by-jupiter/poweredbyjupiter-dark.svg";
import { ArrowDown, Loader2, WalletIcon } from "lucide-react";
import { useWallet } from "@solana/wallet-adapter-react";
import { UnifiedWalletButton } from "@jup-ag/wallet-adapter";
import SwapReset from "./SwapReset";
import SwapSettings from "./SwapSettings";
import SwapTokenButton from "./SwapTokenButton";
import SwapTokenDialog from "./SwapTokenDialog";
import { useTokens } from "../hooks/useTokens";
import { useSwapOperations } from "../hooks/useSwapOperations";
import { useSwapStore } from "../store/useSwapStore";
import { formatBalance } from "../helpers/formatBalance";

interface SwapProps {
  rpcUrl: string;
  referralKey?: string;
  platformFeeBps?: number;
}

const Swap = ({ rpcUrl, referralKey, platformFeeBps = 0 }: SwapProps) => {
  const { tokens } = useTokens();
  const { connected } = useWallet();
  const {
    tokenFrom,
    tokenTo,
    amountFrom,
    amountTo,
    slippage,
    isFromDialogOpen,
    isToDialogOpen,
    isCalculating,
    isSwapping,
    setTokenFrom,
    setTokenTo,
    setAmountFrom,
    setSlippage,
    setIsFromDialogOpen,
    setIsToDialogOpen,
    swapTokens,
  } = useSwapStore();

  const {
    calculateMaximumInput,
    handleSwap,
    handleRefreshQuote,
    fromBalance,
    toBalance,
  } = useSwapOperations({ rpcUrl, referralKey, platformFeeBps });

  // Initialize default tokens only if no tokens are selected or persisted
  useEffect(() => {
    // Skip if tokens haven't loaded yet
    if (tokens.length === 0) return;

    // Skip if we already have tokens selected
    if (tokenFrom || tokenTo) return;

    // Check if we have persisted tokens in localStorage
    const persistedState = localStorage.getItem("swap-storage");
    if (persistedState) {
      const { state } = JSON.parse(persistedState);
      // Skip if we have persisted tokens
      if (state.tokenFrom || state.tokenTo) return;
    }

    // Set default tokens only if no tokens are selected or persisted
    const solToken = tokens.find((token) => token.symbol === "SOL");
    const usdcToken = tokens.find((token) => token.symbol === "USDC");

    if (solToken && usdcToken) {
      setTokenFrom(solToken);
      setTokenTo(usdcToken);
    }
  }, [tokens, tokenFrom, tokenTo]); // Run when tokens load or selection changes

  const isSwapDisabled =
    !connected ||
    !tokenFrom ||
    !tokenTo ||
    !amountFrom ||
    !amountTo ||
    isCalculating ||
    isSwapping;

  return (
    <>
      <div className="w-full max-w-[480px] p-4 font-sans">
        <div className="rounded-3xl shadow-xl bg-background dark:bg-background-dark">
          <div className="p-6">
            {/* Header */}
            <div className="flex justify-between items-center mb-6">
              <h2 className="text-xl font-bold text-foreground dark:text-foreground-dark">
                Swap
              </h2>
              <div className="flex items-center">
                <SwapSettings
                  slippage={slippage}
                  onSlippageChange={setSlippage}
                  onSave={() => {}}
                />
                <SwapReset
                  onRefresh={handleRefreshQuote}
                  isCalculating={isCalculating}
                  isSwapping={isSwapping}
                />
              </div>
            </div>

            {/* From Token Input */}
            <div className="themed-card rounded-2xl p-4 mb-2">
              <div className="flex justify-between mb-3">
                <input
                  type="number"
                  placeholder="0"
                  value={amountFrom}
                  onChange={(e) => setAmountFrom(e.target.value)}
                  className="w-full bg-transparent text-xl font-medium p-0 h-auto focus-visible:outline-none focus-visible:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
                  disabled={isSwapping}
                />
                <SwapTokenButton
                  token={tokenFrom}
                  onClick={() => setIsFromDialogOpen(true)}
                />
              </div>
              <div className="flex justify-between items-center">
                <div className="text-sm text-muted-foreground dark:text-muted-dark-foreground">
                  {tokenFrom &&
                    `Balance: ${formatBalance(fromBalance)} ${
                      tokenFrom.symbol
                    }`}
                </div>
                <div className="flex gap-1.5">
                  <button
                    className="h-7 px-2.5 text-xs font-medium rounded-lg transition-colors hover:bg-accent hover:text-accent-foreground"
                    onClick={async () => {
                      if (fromBalance && tokenFrom) {
                        const amount = (fromBalance * 0.25).toFixed(
                          tokenFrom.decimals
                        );
                        setAmountFrom(amount);
                      }
                    }}
                    disabled={!fromBalance || fromBalance <= 0}
                  >
                    25%
                  </button>
                  <button
                    className="h-7 px-2.5 text-xs font-medium rounded-lg transition-colors hover:bg-accent hover:text-accent-foreground"
                    onClick={async () => {
                      if (fromBalance && tokenFrom) {
                        const amount = (fromBalance * 0.5).toFixed(
                          tokenFrom.decimals
                        );
                        setAmountFrom(amount);
                      }
                    }}
                    disabled={!fromBalance || fromBalance <= 0}
                  >
                    50%
                  </button>
                  <button
                    className="h-7 px-2.5 text-xs font-medium rounded-lg transition-colors hover:bg-accent hover:text-accent-foreground"
                    onClick={async () => {
                      const maxAmount = await calculateMaximumInput();
                      if (maxAmount) setAmountFrom(maxAmount);
                    }}
                    disabled={!fromBalance || fromBalance <= 0}
                  >
                    MAX
                  </button>
                </div>
              </div>
            </div>

            {/* Swap Button */}
            <div className="flex justify-center -my-3 relative z-10 mt-3 mb-3">
              <button
                className="h-9 w-9 cursor-pointer rounded-xl bg-card dark:bg-card-dark hover:bg-accent dark:hover:bg-accent transition-colors flex items-center justify-center shadow-md disabled:opacity-50"
                onClick={swapTokens}
                disabled={isSwapping}
                aria-label="Swap token positions"
              >
                <ArrowDown className="h-4 w-4 text-foreground dark:text-foreground-dark" />
              </button>
            </div>

            {/* To Token Input */}
            <div className="themed-card rounded-2xl p-4 mt-2 bg-muted/20 dark:bg-muted-dark/20">
              <div className="flex justify-between mb-3">
                <input
                  type="number"
                  placeholder="0"
                  value={amountTo}
                  readOnly
                  className="w-full bg-transparent text-xl font-medium p-0 h-auto focus-visible:outline-none focus-visible:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
                />
                <SwapTokenButton
                  token={tokenTo}
                  onClick={() => setIsToDialogOpen(true)}
                />
              </div>
              <div className="text-sm text-muted-foreground dark:text-muted-dark-foreground">
                {tokenTo &&
                  `Balance: ${formatBalance(toBalance)} ${tokenTo.symbol}`}
              </div>
            </div>

            {/* Swap Button or Connect Wallet Button */}
            {connected ? (
              <button
                className="w-full h-10 mt-6 py-4 text-base font-semibold rounded-2xl themed-button-primary disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
                onClick={handleSwap}
                disabled={isSwapDisabled}
              >
                {isCalculating ? (
                  <>
                    <Loader2 className="mr-2 h-5 w-5 animate-spin" />
                    Loading...
                  </>
                ) : isSwapping ? (
                  <>
                    <Loader2 className="mr-2 h-5 w-5 animate-spin" />
                    Swapping...
                  </>
                ) : (
                  "Swap"
                )}
              </button>
            ) : (
              <UnifiedWalletButton
                buttonClassName="font-sans font-semibold rounded-3xl mt-6 themed-button-primary w-full px-4 py-3 text-sm flex justify-center items-center text-center gap-2 hover:opacity-90 dark:hover:opacity-90 transition-colors text-white dark:text-white cursor-pointer"
                overrideContent={
                  <>
                    <WalletIcon className="h-4 w-4 opacity-70" />
                    Connect Wallet
                  </>
                }
              />
            )}

            {/* Powered by Jupiter logo */}
            <div className="flex justify-center mt-6">
              <a
                href="https://jup.ag"
                target="_blank"
                rel="noopener noreferrer"
              >
                <JupiterBrightLogo
                  width={150}
                  height={25}
                  className="block dark:hidden opacity-75 hover:opacity-100 transition-opacity"
                  preserveAspectRatio="xMidYMid meet"
                />
                <JupiterDarkLogo
                  width={150}
                  height={25}
                  className="hidden dark:block opacity-75 hover:opacity-100 transition-opacity"
                  preserveAspectRatio="xMidYMid meet"
                />
              </a>
            </div>
          </div>
        </div>

        {/* Token Selection Dialogs */}
        <SwapTokenDialog
          open={isFromDialogOpen}
          onClose={() => setIsFromDialogOpen(false)}
          onSelect={setTokenFrom}
        />
        <SwapTokenDialog
          open={isToDialogOpen}
          onClose={() => setIsToDialogOpen(false)}
          onSelect={setTokenTo}
        />
      </div>
    </>
  );
};

export default Swap;

That's all it takes to implement the logic for the <Swap /> component in your dApp.

Straightforward, isn't it?

Here is a Github repository that you can refer to in case you are stuck.

Conclusion

Jupiter stands out as a game-changer in simplifying token swaps on the Solana blockchain. Its ability to abstract the complexities of liquidity aggregation and transaction serialization empowers developers to focus on creating seamless, user-friendly dApps. By providing robust APIs, streamlined integration, and support for advanced features like dynamic routing and referral fee mechanisms, Jupiter offers the tools necessary to elevate any Solana dApp to the next level without having to worry about the nitty-gritty of swaps.

As part of my passion for empowering builders, I’ve been working on a project aimed at accelerating development on Solana. Built on the foundation of Jupiter, this initiative aims to help builders bring their dApps to market faster while delivering exceptional user experiences.

Enter Jupiverse Kit—an open-source ready-to-use React components library that consolidates all of Jupiter’s APIs, SDKs, and packages (such as @jup-ag/terminal, @jup-ag/api, @jup-ag/wallet-adapter, and @jup-ag/react-hooks) along with other Solana packages. This unified package simplifies integration and enables developers to hit the ground running with prebuilt components.

P.S. Contributions are always welcome! This project is open to anyone in the Jupiverse who wants to collaborate and make an impact!

Jupiverse Kit

I hope this guide helps accelerate your development process and enables you to build more efficiently with Jupiter!

A huge shoutout to Soju for getting me involved with building with Jupiter and inspiring me to share my experience through writing! The big question now is, ‘Wen next article?

Loading...
highlight
Collect this post to permanently own it.
Buidling Onchain logo
Subscribe to Buidling Onchain and never miss a post.
#solana#jupiter#swap#typescript#hooks#zustand#jupiverse-kit#@jup-ag