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.

Sep 8, 2026/18 min readWeb3
ShareY
Build a Token Swap dApp with thirdweb, A to Z

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.

Note

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

scaffold
npx create-next-app@latest swap-app --typescript --app --tailwindcd swap-appnpm i thirdweb

Create 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.

.env.local
NEXT_PUBLIC_THIRDWEB_CLIENT_ID=your_client_idTHIRDWEB_SECRET_KEY=your_secret_key   # server only, never prefixed NEXT_PUBLIC
lib/thirdweb.ts
1import { createThirdwebClient } from "thirdweb";2 3export const client = createThirdwebClient({4  clientId: process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID!,5});
app/providers.tsx
1"use client";2import { ThirdwebProvider } from "thirdweb/react";3 4export function Providers({ children }: { children: React.ReactNode }) {5  return <ThirdwebProvider>{children}</ThirdwebProvider>;6}
app/layout.tsx
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}
Tip

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.

components/connect.tsx
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.

gasless ConnectButton
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/>
Watch out

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 shipMinutesA day or two
Control over UITheme + prefill onlyTotal
Token selection UXthirdweb'sYours
Good forMVPs, embedded 'buy this token' flowsA product where the swap *is* the product

5. The fast path: SwapWidget

app/page.tsx
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.

lib/tokens.ts
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.
lib/quote.ts
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}
what a quote gives you
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.

hooks/use-quote.ts
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}
Tip

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.

lib/prepare.ts
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.

lib/execute.ts
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.

lib/track.ts
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}
Watch out

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

hooks/use-swap.ts
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.

reading an ERC-20 balance
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}
ConditionButton says
No walletConnect wallet
Amount is 0 or emptyEnter an amount
Amount > balanceInsufficient {symbol}
Quote still loadingFetching price…
No route for this pairThis pair isn't supported
ReadySwap
lib/errors.ts
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.

app/api/quote/route.ts
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}
Note

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.

components/fund.tsx
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 localStorage so 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 refetchInterval across many users adds up.
  • Handle the 'wrong network' caseTransactionButton and the widgets prompt a switch automatically; if you send raw, check account's chain first.

What thirdweb saved you

PieceRoll your ownWith thirdweb
Wallet connect (EIP-6963, WC, in-app)Weeks<ConnectButton />
Smart accounts + gas sponsorshipA month + a contract auditaccountAbstraction={{ sponsorGas: true }}
Token metadata + icons across chainsAn indexerIn the route response
Router / aggregatorIntegrate 5+ DEXs and bridgesBridge.Buy / Bridge.Sell
Cross-chain settlement trackingA message-passing indexerBridge.status
Fiat onrampKYC 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.

All posts

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.