Post 25
Build a Token Swap dApp with thirdweb, A to Z
A complete build: from an empty Next.js repo to a production token swap with wallet connect, gasless transactions, cross-chain routing, and a proper transaction lifecycle — using thirdweb's v5 SDK and Universal Bridge.

Swapping one token for another is the single most common thing a wallet-holder does, and it is deceptively hard to build well: you need wallet connection, token metadata, a pricing engine, allowance handling, a router, gas estimation, and a transaction lifecycle that survives cross-chain settlement. Roll all of that yourself and it is a quarter of work before you have a demo.
thirdweb collapses most of it. Their v5 SDK gives you the wallet layer and contract calls, and Universal Bridge (the product formerly called Pay) is an aggregated router across 95+ EVM chains, Solana, and 14,000+ tokens with a single quote / prepare / status flow. This post builds a real swap dApp on top of it — the fast way first, then the from-scratch way when you need control.
Stack: Next.js (App Router) + TypeScript + the thirdweb v5 SDK. Everything here is client-side unless noted; the one server piece keeps your secret key off the browser. Docs referenced throughout: portal.thirdweb.com.
1. What we are building
- Connect — an in-app or external wallet, optionally with a gasless smart account.
- Pick tokens — sell token, buy token, either or both cross-chain.
- Quote — live pricing, fees, price impact, and an estimated settlement time.
- Swap — approve (if needed) and execute the route, one confirmation at a time.
- Track — follow the transaction through to destination-chain settlement, with real failure states.
2. Project setup
npx create-next-app@latest swap-app --typescript --app --tailwindcd swap-appnpm i thirdwebCreate a project at thirdweb.com to get a client ID (safe for the browser) and a secret key (server only). Lock the client ID to your domains in the dashboard.
NEXT_PUBLIC_THIRDWEB_CLIENT_ID=your_client_idTHIRDWEB_SECRET_KEY=your_secret_key # server only, never prefixed NEXT_PUBLIC1import { createThirdwebClient } from "thirdweb";2 3export const client = createThirdwebClient({4 clientId: process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID!,5});1"use client";2import { ThirdwebProvider } from "thirdweb/react";3 4export function Providers({ children }: { children: React.ReactNode }) {5 return <ThirdwebProvider>{children}</ThirdwebProvider>;6}1import { Providers } from "./providers";2 3export default function RootLayout({4 children,5}: {6 children: React.ReactNode;7}) {8 return (9 <html lang="en">10 <body>11 <Providers>{children}</Providers>12 </body>13 </html>14 );15}ThirdwebProvider in v5 takes no props — the client is passed to each component and hook. That is deliberate: it keeps the provider from being a god-object and makes it obvious which client a call uses.
3. Connect a wallet
ConnectButton is the whole wallet layer: external wallets via EIP-6963, WalletConnect for mobile, and thirdweb's in-app wallets (email, social, passkey) that create a wallet for users who do not have one.
1"use client";2import { ConnectButton } from "thirdweb/react";3import { inAppWallet, createWallet } from "thirdweb/wallets";4import { base } from "thirdweb/chains";5import { client } from "@/lib/thirdweb";6 7const wallets = [8 inAppWallet({ auth: { options: ["email", "google", "passkey"] } }),9 createWallet("io.metamask"),10 createWallet("com.coinbase.wallet"),11 createWallet("me.rainbow"),12];13 14export function Connect() {15 return (16 <ConnectButton17 client={client}18 wallets={wallets}19 chain={base}20 connectModal={{ size: "compact" }}21 />22 );23}Make it gasless with a smart account
One prop turns every connected wallet into an ERC-4337 smart account with sponsored gas. New users no longer need ETH to do anything — which for a swap app removes the worst onboarding wall.
1<ConnectButton2 client={client}3 wallets={wallets}4 chain={base}5 accountAbstraction={{6 chain: base,7 sponsorGas: true, // thirdweb's bundler + paymaster sponsor the UserOps8 }}9/>Sponsored gas costs *you* — configure spend limits and allowed contract/method rules in the dashboard before you ship, or a script kiddie will drain your gas budget overnight. The paymaster policy is your firewall.
4. Two ways to build the swap
| SwapWidget (pre-built) | Bridge API (custom) | |
|---|---|---|
| Time to ship | Minutes | A day or two |
| Control over UI | Theme + prefill only | Total |
| Token selection UX | thirdweb's | Yours |
| Good for | MVPs, embedded 'buy this token' flows | A product where the swap *is* the product |
5. The fast path: SwapWidget
1"use client";2import { SwapWidget } from "thirdweb/react";3import { client } from "@/lib/thirdweb";4 5export default function Page() {6 return (7 <main className="grid min-h-screen place-items-center">8 <SwapWidget9 client={client}10 prefill={{11 // default selection — the user can still change it12 sellToken: { chainId: 8453 }, // native ETH on Base13 buyToken: {14 chainId: 8453,15 tokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC16 },17 }}18 />19 </main>20 );21}That is a working, cross-chain, aggregated swap with token search, quotes, approval handling, and a status screen — themed to match your app. If your swap is a feature rather than the whole product, stop here. The rest of this post is for when it is the whole product.
6. The custom path: Universal Bridge, step by step
Universal Bridge exposes three primitives in the Bridge namespace: routes (what can be swapped for what), Buy / Sell (get a quote and prepared transactions), and status (track a route to completion).
6a. A token model and the route list
Every token in a swap is { chainId, address, symbol, decimals }, where a missing/native address is the chain's gas token. Use Bridge.routes to populate selectors with only the pairs that are actually routable.
1import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";2import { client } from "./thirdweb";3 4export type TokenRef = {5 chainId: number;6 address: string; // NATIVE_TOKEN_ADDRESS for the gas token7 symbol: string;8 decimals: number;9 iconUri?: string;10};11 12// what can I receive if I'm selling USDC on Base?13export async function buyOptionsFor(sell: TokenRef) {14 const routes = await Bridge.routes({15 client,16 originChainId: sell.chainId,17 originTokenAddress: sell.address,18 limit: 100,19 });20 21 return routes.map((r) => ({22 chainId: r.destinationToken.chainId,23 address: r.destinationToken.address,24 symbol: r.destinationToken.symbol,25 decimals: r.destinationToken.decimals,26 iconUri: r.destinationToken.iconUri,27 }));28}6b. Get a quote
There are two directions, and picking the right one is a UX decision:
- `Bridge.Sell` — "I want to spend exactly 100 USDC, how much ETH do I get?" Use this when the user types into the *sell* field.
- `Bridge.Buy` — "I want to receive exactly 0.05 ETH, how much USDC does that cost?" Use this when the user types into the *buy* field.
1import { Bridge, toUnits } from "thirdweb";2import { client } from "./thirdweb";3import type { TokenRef } from "./tokens";4 5export async function getSellQuote(params: {6 sell: TokenRef;7 buy: TokenRef;8 sellAmount: string; // human units, e.g. "100"9}) {10 const { sell, buy, sellAmount } = params;11 12 return Bridge.Sell.quote({13 client,14 originChainId: sell.chainId,15 originTokenAddress: sell.address,16 destinationChainId: buy.chainId,17 destinationTokenAddress: buy.address,18 amount: toUnits(sellAmount, sell.decimals), // -> bigint in token's smallest unit19 maxSteps: 3, // keep routes simple; drop for best price on exotic pairs20 });21}1// {2// originAmount: 100_000000n, // 100 USDC (6 decimals)3// destinationAmount: 41_230000000000000n, // ~0.0412 ETH4// blockNumber, timestamp,5// estimatedExecutionTimeMs: 1200,6// steps: [ /* one per hop */ ],7// intent: { /* echo of your inputs */ }8// }6c. Render the quote honestly
A quote is an estimate. Show the user the rate, the fee, the price impact, and the estimated time — and re-fetch on a short interval so a stale quote does not become a bad surprise at signing.
1"use client";2import { useQuery } from "@tanstack/react-query";3import { getSellQuote } from "@/lib/quote";4import { toTokens } from "thirdweb";5import type { TokenRef } from "@/lib/tokens";6 7export function useSellQuote(sell: TokenRef, buy: TokenRef, sellAmount: string) {8 return useQuery({9 queryKey: ["quote", sell, buy, sellAmount],10 queryFn: () => getSellQuote({ sell, buy, sellAmount }),11 enabled: Number(sellAmount) > 0,12 refetchInterval: 15_000, // quotes drift — keep it fresh13 staleTime: 12_000,14 select: (q) => ({15 raw: q,16 receive: toTokens(q.destinationAmount, buy.decimals),17 rate:18 Number(toTokens(q.destinationAmount, buy.decimals)) /19 Number(toTokens(q.originAmount, sell.decimals)),20 etaSeconds: Math.ceil(q.estimatedExecutionTimeMs / 1000),21 }),22 });23}Put a countdown or a subtle 'refreshing…' pulse on the quote. Users trust a number that visibly updates more than one that sits still and then changes the instant they click swap.
6d. Prepare the route
prepare turns a quote into executable transactions. It returns steps, and each step has a transactions array — typically an approval followed by a buy. You must send them in order.
1import { Bridge, toUnits } from "thirdweb";2import { client } from "./thirdweb";3import type { TokenRef } from "./tokens";4 5export function prepareSwap(params: {6 sell: TokenRef;7 buy: TokenRef;8 sellAmount: string;9 sender: string;10 receiver: string;11}) {12 const { sell, buy, sellAmount, sender, receiver } = params;13 14 return Bridge.Sell.prepare({15 client,16 originChainId: sell.chainId,17 originTokenAddress: sell.address,18 destinationChainId: buy.chainId,19 destinationTokenAddress: buy.address,20 amount: toUnits(sellAmount, sell.decimals),21 sender,22 receiver, // usually === sender; different for "swap and send"23 });24}25 26// result.steps[i].transactions[j] = {27// action: "approval" | "buy",28// to: "0x…", data: "0x…", value: 123n, chainId: 8453,29// }6e. Execute — one confirmation at a time
Each prepared transaction is a plain call object. Wrap it with prepareTransaction, send it with the connected account, wait for the receipt, and only then move to the next one. A smart account can batch approval + buy into one signature; an EOA will prompt twice.
1import {2 prepareTransaction,3 sendTransaction,4 waitForReceipt,5 type Account,6} from "thirdweb";7import { client } from "./thirdweb";8import { defineChain } from "thirdweb/chains";9 10type PreparedTx = {11 to: string;12 data: `0x${string}`;13 value?: bigint;14 chainId: number;15 action: "approval" | "buy";16};17 18export async function executeRoute(19 steps: { transactions: PreparedTx[] }[],20 account: Account,21 onProgress: (msg: string) => void,22) {23 const originTxHashes: `0x${string}`[] = [];24 25 for (const step of steps) {26 for (const tx of step.transactions) {27 onProgress(28 tx.action === "approval"29 ? "Approve the swap in your wallet"30 : "Confirm the swap in your wallet",31 );32 33 const transaction = prepareTransaction({34 client,35 chain: defineChain(tx.chainId),36 to: tx.to,37 data: tx.data,38 value: tx.value ?? 0n,39 });40 41 const { transactionHash } = await sendTransaction({ transaction, account });42 onProgress("Waiting for confirmation…");43 await waitForReceipt({ client, chain: defineChain(tx.chainId), transactionHash });44 45 if (tx.action === "buy") originTxHashes.push(transactionHash);46 }47 }48 49 return originTxHashes;50}6f. Track to settlement
For a same-chain swap the receipt is basically the end. For a cross-chain swap, the origin transaction confirming just means your tokens left — the destination side settles seconds to minutes later. Bridge.status is how you know it actually landed.
1import { Bridge } from "thirdweb";2import { client } from "./thirdweb";3 4export async function trackToCompletion(5 transactionHash: `0x${string}`,6 chainId: number,7 onProgress: (msg: string) => void,8) {9 let result = await Bridge.status({ transactionHash, chainId, client });10 11 while (result.status === "PENDING") {12 onProgress("Bridging — this can take a minute…");13 await new Promise((r) => setTimeout(r, 5000));14 result = await Bridge.status({ transactionHash, chainId, client });15 }16 17 if (result.status === "FAILED") {18 throw new Error("The swap failed to settle on the destination chain.");19 }20 if (result.status === "NOT_FOUND") {21 throw new Error("Couldn't find the transaction — it may not be mined yet.");22 }23 24 // result.status === "COMPLETED"25 return result; // has destinationAmount + destination tx hash26}Bridge.status returning NOT_FOUND right after you send is normal — the indexer has not seen the origin tx yet. Treat NOT_FOUND as retryable for the first ~30 seconds, then as an error.
6g. The whole flow, as one hook
1"use client";2import { useState } from "react";3import { useActiveAccount } from "thirdweb/react";4import { prepareSwap } from "@/lib/prepare";5import { executeRoute } from "@/lib/execute";6import { trackToCompletion } from "@/lib/track";7import type { TokenRef } from "@/lib/tokens";8 9type Phase =10 | { kind: "idle" }11 | { kind: "working"; message: string }12 | { kind: "done"; received: string }13 | { kind: "error"; message: string };14 15export function useSwap() {16 const account = useActiveAccount();17 const [phase, setPhase] = useState<Phase>({ kind: "idle" });18 19 async function swap(sell: TokenRef, buy: TokenRef, sellAmount: string) {20 if (!account) return setPhase({ kind: "error", message: "Connect a wallet first." });21 22 try {23 setPhase({ kind: "working", message: "Building the best route…" });24 const route = await prepareSwap({25 sell, buy, sellAmount,26 sender: account.address,27 receiver: account.address,28 });29 30 const [buyTxHash] = await executeRoute(31 route.steps,32 account,33 (message) => setPhase({ kind: "working", message }),34 );35 36 const settled = await trackToCompletion(37 buyTxHash,38 sell.chainId,39 (message) => setPhase({ kind: "working", message }),40 );41 42 setPhase({ kind: "done", received: settled.destinationAmount.toString() });43 } catch (err) {44 setPhase({45 kind: "error",46 message: humanizeSwapError(err),47 });48 }49 }50 51 return { phase, swap, reset: () => setPhase({ kind: "idle" }) };52}7. Balances, gating, and error copy
The swap button should be disabled with a *reason*, not just greyed out. Read the sell-token balance and compare it to the input.
1"use client";2import { getContract, toTokens } from "thirdweb";3import { defineChain } from "thirdweb/chains";4import { balanceOf } from "thirdweb/extensions/erc20";5import { useReadContract, useActiveAccount } from "thirdweb/react";6import { client } from "@/lib/thirdweb";7import type { TokenRef } from "@/lib/tokens";8 9export function useTokenBalance(token: TokenRef) {10 const account = useActiveAccount();11 const contract = getContract({12 client,13 address: token.address,14 chain: defineChain(token.chainId),15 });16 17 const { data } = useReadContract(balanceOf, {18 contract,19 address: account?.address ?? "0x0000000000000000000000000000000000000000",20 queryOptions: { enabled: !!account },21 });22 23 return data ? toTokens(data, token.decimals) : "0";24}| Condition | Button says |
|---|---|
| No wallet | Connect wallet |
| Amount is 0 or empty | Enter an amount |
| Amount > balance | Insufficient {symbol} |
| Quote still loading | Fetching price… |
| No route for this pair | This pair isn't supported |
| Ready | Swap |
1import { BaseError } from "thirdweb";2 3export function humanizeSwapError(err: unknown): string {4 if (err instanceof BaseError) {5 if (err.name === "UserRejectedRequestError")6 return "You declined the request in your wallet.";7 if (/insufficient funds/i.test(err.message))8 return "Not enough balance to cover the swap and network fee.";9 }10 const msg = err instanceof Error ? err.message : "";11 if (/slippage|price moved/i.test(msg))12 return "The price moved while confirming — try again.";13 if (/settle on the destination/i.test(msg))14 return "Your tokens left this chain but the bridge hasn't settled. It usually resolves within a few minutes — check the explorer link.";15 return "The swap didn't go through. Nothing was lost beyond network fees.";16}8. Keep the secret key on the server
The client ID is browser-safe, but if you want higher rate limits, server-signed quotes, or to hide which router you use, proxy quotes through a Route Handler with the secret key.
1import { Bridge, toUnits, createThirdwebClient } from "thirdweb";2import { z } from "zod";3 4const server = createThirdwebClient({5 secretKey: process.env.THIRDWEB_SECRET_KEY!,6});7 8const schema = z.object({9 originChainId: z.number(),10 originTokenAddress: z.string(),11 destinationChainId: z.number(),12 destinationTokenAddress: z.string(),13 amount: z.string(),14 decimals: z.number(),15});16 17export async function POST(req: Request) {18 const input = schema.parse(await req.json());19 20 const quote = await Bridge.Sell.quote({21 client: server,22 originChainId: input.originChainId,23 originTokenAddress: input.originTokenAddress,24 destinationChainId: input.destinationChainId,25 destinationTokenAddress: input.destinationTokenAddress,26 amount: toUnits(input.amount, input.decimals),27 });28 29 // BigInts don't JSON-serialize — stringify them30 return Response.json({31 originAmount: quote.originAmount.toString(),32 destinationAmount: quote.destinationAmount.toString(),33 estimatedExecutionTimeMs: quote.estimatedExecutionTimeMs,34 });35}prepare and the actual sendTransaction still happen client-side — the user's wallet signs. The server only fetches quotes and can verify a swap afterwards via Bridge.status for accounting or webhooks (pass purchaseData into prepare to tag a route with your own order id).
9. Onramp: let users pay with a card
If your user has no crypto at all, BuyWidget covers the fiat-to-crypto step, and Universal Bridge will route the purchase straight into the token you want.
1"use client";2import { BuyWidget } from "thirdweb/react";3import { base } from "thirdweb/chains";4import { client } from "@/lib/thirdweb";5 6export function Fund() {7 return (8 <BuyWidget9 client={client}10 chain={base}11 amount="25"12 tokenAddress="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" // USDC13 />14 );15}10. Production checklist
- Domain-lock the client ID in the thirdweb dashboard so nobody else uses your quota.
- Configure the paymaster policy — allowed chains, contracts, methods, and per-user spend limits — before enabling
sponsorGas. - Test on testnets first (Base Sepolia, Optimism Sepolia). Universal Bridge supports them; route coverage is thinner, so verify your pairs.
- Re-quote before signing. If the last quote is older than ~30s when the user clicks swap, fetch a fresh one and show the diff.
- Persist the in-flight route (origin tx hash + chain) to
localStorageso a reload during a cross-chain settle resumes the status poll instead of showing 'idle'. - Show explorer links for every transaction — origin and destination — using
chain.blockExplorers. - Rate-limit your quote proxy if you built one; a naive
refetchIntervalacross many users adds up. - Handle the 'wrong network' case —
TransactionButtonand the widgets prompt a switch automatically; if you send raw, checkaccount's chain first.
What thirdweb saved you
| Piece | Roll your own | With thirdweb |
|---|---|---|
| Wallet connect (EIP-6963, WC, in-app) | Weeks | <ConnectButton /> |
| Smart accounts + gas sponsorship | A month + a contract audit | accountAbstraction={{ sponsorGas: true }} |
| Token metadata + icons across chains | An indexer | In the route response |
| Router / aggregator | Integrate 5+ DEXs and bridges | Bridge.Buy / Bridge.Sell |
| Cross-chain settlement tracking | A message-passing indexer | Bridge.status |
| Fiat onramp | KYC vendor integration | <BuyWidget /> |
The interesting work in a swap dApp is the UX around uncertainty — stale quotes, pending approvals, cross-chain lag, partial failures. thirdweb hands you the plumbing so you can spend your time there instead of on DEX integrations.
Found this useful? Pass it on.
Want a custom write-up for your team? Get in touch.
Building something like this?
If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.