Building a multi-chain swap interface used to mean integrating separate SDKs for each DEX on each chain, managing router contract addresses, and handling incompatible ABIs across networks. With over $5 billion in daily DEX volume spread across Ethereum, Arbitrum, Base, Polygon, BSC, and dozens of other EVM chains, users expect a single UI that lets them swap tokens on any network without switching apps.
This guide walks you through building a production-ready multi-chain swap interface using React, wagmi/viem, and a single swap API that covers 46 EVM chains. By the end, you will have a working frontend that handles chain selection, token input, real-time quoting, and on-chain execution — all powered by one GET endpoint.
What You'll Need
- Node.js 18+ and a package manager (npm, pnpm, or bun)
- React 18+ with TypeScript (Next.js or Vite)
- wagmi v2 and viem for wallet connection and transaction submission
- A swap API — this guide uses swapapi.dev, which is free, requires no API key, supports 46 chains, and returns executable calldata from a single GET request
No backend server is required. The swap API is called directly from the frontend, and transactions are signed client-side through the user's wallet.
Step 1: Set Up the Project
Scaffold a new React app with wagmi pre-configured. This gives you wallet connection, chain switching, and transaction hooks out of the box.
pnpm create wagmi
Configure the chains you want to support in your wagmi config. Each chain needs its chain ID, which maps directly to the swap API's chainId parameter.
import { http, createConfig } from "wagmi";
import { mainnet, arbitrum, base, polygon, bsc } from "wagmi/chains";
export const config = createConfig({
chains: [mainnet, arbitrum, base, polygon, bsc],
transports: {
[mainnet.id]: http(),
[arbitrum.id]: http(),
[base.id]: http(),
[polygon.id]: http(),
[bsc.id]: http(),
},
});
Step 2: Build the Chain Selector
The chain selector drives the entire swap flow. When a user picks a chain, it determines which tokens are available and which chain ID gets passed to the API. Store common token addresses per chain so your UI can populate token dropdowns immediately.
const TOKENS: Record<number, { address: string; symbol: string; decimals: number }[]> = {
1: [
{ address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", symbol: "ETH", decimals: 18 },
{ address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", symbol: "USDC", decimals: 6 },
{ address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", symbol: "USDT", decimals: 6 },
],
42161: [
{ address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", symbol: "ETH", decimals: 18 },
{ address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", symbol: "USDC", decimals: 6 },
{ address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", symbol: "USDT", decimals: 6 },
],
8453: [
{ address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", symbol: "ETH", decimals: 18 },
{ address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", symbol: "USDC", decimals: 6 },
],
137: [
{ address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", symbol: "POL", decimals: 18 },
{ address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", symbol: "USDC", decimals: 6 },
],
};
Build the selector as a simple dropdown that triggers a chain switch via wagmi's useSwitchChain hook.
import { useSwitchChain, useChainId } from "wagmi";
function ChainSelector() {
const chainId = useChainId();
const { switchChain } = useSwitchChain();
const chains = [
{ id: 1, name: "Ethereum" },
{ id: 42161, name: "Arbitrum" },
{ id: 8453, name: "Base" },
{ id: 137, name: "Polygon" },
];
return (
<select value={chainId} onChange={(e) => switchChain({ chainId: Number(e.target.value) })}>
{chains.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
);
}
When the user switches chains, wagmi prompts their wallet to change networks automatically. Your token list updates to show only tokens available on the selected chain.
Step 3: Build the Token Input Component
The token input needs two parts: a token selector dropdown and an amount field. The amount must be converted to raw units (smallest denomination) before calling the API. This is where decimal handling matters — USDC uses 6 decimals on Ethereum, Arbitrum, and Base, but 18 decimals on BSC.
function parseAmount(amount: string, decimals: number): string {
const [whole, fraction = ""] = amount.split(".");
const paddedFraction = fraction.padEnd(decimals, "0").slice(0, decimals);
return BigInt(whole + paddedFraction).toString();
}
Use this function to convert the user's human-readable input (e.g., "1.5") into the raw amount the API expects (e.g., "1500000000000000000" for 18 decimals, or "1500000" for 6 decimals).
Step 4: Fetch a Swap Quote
The core of your interface is a single GET request to the swap API. It returns everything you need: the expected output amount, price impact, minimum output with slippage protection, and a complete transaction object ready for on-chain submission.
interface SwapQuote {
success: boolean;
data: {
status: "Successful" | "Partial" | "NoRoute";
expectedAmountOut: string;
minAmountOut: string;
priceImpact: number;
tokenTo: { decimals: number; symbol: string };
tx: { to: string; data: string; value: string };
};
}
async function fetchQuote(
chainId: number,
tokenIn: string,
tokenOut: string,
amount: string,
sender: string
): Promise<SwapQuote> {
const url = `https://api.swapapi.dev/v1/swap/${chainId}?tokenIn=${tokenIn}&tokenOut=${tokenOut}&amount=${amount}&sender=${sender}`;
const res = await fetch(url);
return res.json();
}
Debounce the quote fetch to avoid spamming the API as the user types. A 500ms delay after the last keystroke works well. The API responds in 1-5 seconds, so show a loading state while the quote is in flight.
import { useState, useEffect } from "react";
function useSwapQuote(chainId: number, tokenIn: string, tokenOut: string, amount: string, sender: string) {
const [quote, setQuote] = useState<SwapQuote | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!amount || !sender) return;
setLoading(true);
const timeout = setTimeout(async () => {
const result = await fetchQuote(chainId, tokenIn, tokenOut, amount, sender);
setQuote(result);
setLoading(false);
}, 500);
return () => clearTimeout(timeout);
}, [chainId, tokenIn, tokenOut, amount, sender]);
return { quote, loading };
}
Step 5: Display Pricing and Status
Once you have a quote, format the output amount for display. The API returns raw amounts, so divide by 10^decimals to get human-readable values.
function formatAmount(raw: string, decimals: number): string {
const n = BigInt(raw);
const divisor = BigInt(10 ** decimals);
const whole = n / divisor;
const fraction = (n % divisor).toString().padStart(decimals, "0").slice(0, 4);
return `${whole}.${fraction}`;
}
Handle all three response statuses. A Successful status means the full amount can be swapped. A Partial status means only part of the requested amount has available liquidity — display how much will actually be swapped. A NoRoute status means no swap path exists for this pair.
function QuoteDisplay({ quote }: { quote: SwapQuote }) {
if (!quote.success) return <div>Error fetching quote</div>;
if (quote.data.status === "NoRoute") return <div>No route found for this pair</div>;
const outputAmount = formatAmount(quote.data.expectedAmountOut, quote.data.tokenTo.decimals);
const isPartial = quote.data.status === "Partial";
return (
<div>
{isPartial && <div>Partial fill — limited liquidity available</div>}
<div>{outputAmount} {quote.data.tokenTo.symbol}</div>
<div>Price impact: {(quote.data.priceImpact * 100).toFixed(2)}%</div>
</div>
);
}
Check priceImpact before allowing execution. Values worse than -5% indicate the trade will move the market significantly. Show a warning or block the swap entirely for high-impact trades.
Step 6: Handle ERC-20 Approvals
When the input token is not the native gas token (ETH, POL, BNB), the user must approve the router contract to spend their tokens before the swap can execute. The router address comes from quote.data.tx.to.
import { useWriteContract } from "wagmi";
import { erc20Abi, parseAbi } from "viem";
function useApproval(tokenAddress: string, spender: string, amount: bigint) {
const { writeContractAsync } = useWriteContract();
const approve = async () => {
return writeContractAsync({
address: tokenAddress as `0x${string}`,
abi: erc20Abi,
functionName: "approve",
args: [spender as `0x${string}`, amount],
});
};
return { approve };
}
Native token swaps (using 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE as tokenIn) skip this step entirely — the value is sent with the transaction itself.
Step 7: Execute the Swap
After the user approves (if needed), fetch a fresh quote — calldata expires after 30 seconds — and submit the transaction using wagmi's useSendTransaction hook. The API response includes to, data, and value fields that map directly to a standard Ethereum transaction.
import { useSendTransaction } from "wagmi";
function useExecuteSwap() {
const { sendTransactionAsync } = useSendTransaction();
const execute = async (tx: { to: string; data: string; value: string }) => {
return sendTransactionAsync({
to: tx.to as `0x${string}`,
data: tx.data as `0x${string}`,
value: BigInt(tx.value),
});
};
return { execute };
}
Before submitting, estimate gas with a 20% buffer to avoid out-of-gas failures on complex multi-hop routes. The API does not include a gasLimit — you must supply your own.
import { usePublicClient } from "wagmi";
async function estimateGas(client: ReturnType<typeof usePublicClient>, tx: { to: string; data: string; value: string; from: string }) {
const estimate = await client!.estimateGas({
to: tx.to as `0x${string}`,
data: tx.data as `0x${string}`,
value: BigInt(tx.value),
account: tx.from as `0x${string}`,
});
return (estimate * 120n) / 100n;
}
Step 8: Wire It All Together
Combine the components into a single swap form. The flow is: select chain, pick tokens, enter amount, view quote, approve if needed, swap.
function SwapForm() {
const { address } = useAccount();
const chainId = useChainId();
const [tokenIn, setTokenIn] = useState(TOKENS[chainId]?.[0]?.address ?? "");
const [tokenOut, setTokenOut] = useState(TOKENS[chainId]?.[1]?.address ?? "");
const [amount, setAmount] = useState("");
const rawAmount = parseAmount(amount || "0", TOKENS[chainId]?.find(t => t.address === tokenIn)?.decimals ?? 18);
const { quote, loading } = useSwapQuote(chainId, tokenIn, tokenOut, rawAmount, address ?? "");
const { execute } = useExecuteSwap();
return (
<div>
<ChainSelector />
<input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="0.0" />
{loading && <div>Fetching quote...</div>}
{quote && <QuoteDisplay quote={quote} />}
<button onClick={() => execute(quote!.data.tx)} disabled={!quote || quote.data.status !== "Successful"}>
Swap
</button>
</div>
);
}
This gives you a functional multi-chain swap interface in under 200 lines of code. The swap API handles all the routing complexity — finding the best path across liquidity pools, encoding the calldata, and returning slippage-protected parameters — while your frontend focuses entirely on UX.
Production Considerations
Before shipping to users, add these safeguards:
- Price impact warnings: Block swaps with price impact worse than -5% and warn above -1%.
-
Transaction simulation: Use
eth_callwith the tx object before submitting. If it reverts, the swap would fail on-chain — do not submit. - Stale quote handling: Re-fetch the quote if more than 30 seconds have passed since the last fetch. Calldata includes a deadline that expires.
-
Error handling: The API returns specific error codes —
INVALID_PARAMS(400),RATE_LIMITED(429),UPSTREAM_ERROR(502). Handle each appropriately with retry logic for 429 and 502. - BSC decimal gotcha: USDC and USDT use 18 decimals on BSC, not 6 like on Ethereum. Always read decimals from your token config rather than hardcoding.
FAQ
How many chains does this approach support?
The swap API at swapapi.dev supports 46 EVM chains, including Ethereum, Arbitrum, Base, Polygon, BSC, Optimism, Avalanche, and newer networks like Monad, MegaETH, Berachain, and Sonic. Adding a new chain to your interface means adding one entry to your chain config and token list — no new SDK or contract integration required.
Do I need an API key?
No. The swap API is free and requires no API key, no authentication, and no account creation. Rate limits are approximately 30 requests per minute per IP, which is sufficient for a frontend application.
Can I do cross-chain swaps?
This API is single-chain only — each swap happens within one network. For cross-chain swaps, you would need to integrate a bridge protocol separately. However, supporting multiple chains in the same UI (letting users switch networks and swap on each one) is exactly what this guide covers.
What about token approvals?
ERC-20 tokens require an approval transaction before swapping. The router contract address comes from the API response (data.tx.to). Native token swaps (ETH, POL, BNB, AVAX) skip the approval step since the value is sent directly with the transaction.
How do I handle partial fills?
The API returns a Partial status when only part of the requested amount can be filled due to liquidity constraints. The response includes adjusted amountIn and expectedAmountOut values. Display these to the user and let them decide whether to proceed with the partial amount.
Get Started
You can have a working multi-chain swap interface running in under an hour:
- Scaffold a React app with wagmi
- Add the chain and token configuration from this guide
- Call
https://api.swapapi.dev/v1/swap/{chainId}with the user's input - Submit the returned
txobject through the user's wallet
The full API documentation, including all 46 supported chains and token addresses, is available at swapapi.dev. No signup required — start building now.
Top comments (0)