One Reown project across login, profile, NFT and Swap

One project id and one AppKit instance serve eight mount points across three addons — what that shares, which network list each surface offers, and why every change needs a rebuild.

11 min readUpdated 6 August 2026reown, appkit, wagmi, dex, build-time

NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID is not a Wallet Connect setting. It is the project id for every wallet interaction the platform performs — wallet sign-in, profile linking, four NFT flows, the Swap terminal and the admin pool console — because createAppKit() is called once, at module scope, in a single file that all of them import.

That has consequences an operator meets in three separate support tickets before realising they are the same fact. This page is that fact, written down.

One AppKit, eight leaf mounts, no root provider

frontend/context/wallet.tsx calls createAppKit() at module scope. Module scope means once per browser tab, idempotent afterwards, and unreachable from anything fetched at runtime — there is no point at which a value from your database could reach it.

There is no root WagmiProvider. Instead LazyWalletProvider is mounted at eight leaf sites:

# Surface File
1 The wallet login form in the auth modal components/auth/auth-modal.tsx
2 Profile → Wallet tab (/user/profile?tab=wallet) user/profile/components/tabs/wallet-tab.tsx
3 /nft/create (ext)/nft/create/page.tsx
4 /nft/creator (ext)/nft/creator/page.tsx
5 /nft/token/[id] (ext)/nft/token/[id]/page.tsx
6 /nft/token/[id]/list (ext)/nft/token/[id]/list/page.tsx
7 The Swap terminal (/dex/swap only, not the /dex landing page) (ext)/dex/layout.tsx
8 The admin pool detail console (/admin/dex/pool/[id]) (ext)/admin/dex/pool/[id]/layout.tsx

LazyWalletProvider is next/dynamic(…, { ssr: false, loading: () => null }). It renders nothing on the server and nothing until the web3 chunk arrives — children included, because the component that would render {children} is the one being deferred. That is why it wraps single screens rather than whole segments: wrapping (ext)/dex would blank the public /dex landing page in the server response, and wrapping admin/dex would blank the chains, tokens, pairs, fees and settings consoles and pull roughly 600 KB of web3 into five screens that never ask for a signature.

A second WagmiProvider builds a second wagmi context. A connection made in one is invisible to the other, and the symptom is a page whose header shows a connected address while its action button reads not connected. The mount sites above are the complete list; adding a ninth inside any of them breaks that screen.

The Swap terminal does nest DexTonConnectProvider outside LazyWalletProvider, and that is not the same thing — TON Connect is a different protocol holding a TON session and touching no wagmi state.

Wallet Connect being disabled does not switch this off

Only mount 1 is gated on the extension: the auth modal renders the wallet branch when /api/settings lists wallet_connect among the enabled extensions. Mounts 2 through 8 are unconditional.

frontend/config/wallet.tsx throws at module load when the project id is unset:

NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID is not defined. Set it in .env and REBUILD the frontend — this value is inlined at build time, a restart is not enough.

There is no fallback id, deliberately: a fallback would make a misconfigured deploy run silently on somebody else's Reown project. So on an install running NFT Marketplace or Swap and not Wallet Connect, the variable is still mandatory, and leaving it out does not degrade those pages — it stops them rendering.

The network list, and where it narrows

config/wallet.tsx declares thirteen AppKit networks, and this is the list createAppKit() is built with:

Ethereum · Polygon · Arbitrum · Optimism · Base · BNB Smart Chain · Avalanche · Linea · Celo · Cronos · Fantom · Rootstock · Solana

defaultNetwork is Ethereum mainnet. Twelve of the thirteen are EVM and route through the wagmi adapter; Solana routes through a second SolanaAdapter registered in the same call, because AppKit maps each network to whichever adapter claims its CAIP namespace — eip155:* to wagmi, solana:* to the Solana adapter. A network listed with no adapter is one the modal offers and cannot connect to.

That list is the same on all eight mounts. The Reown modal opened from the login form offers the same thirteen networks as the one opened from the Swap terminal. What differs is what each surface does afterwards.

Surface Which networks matter
Wallet sign-in and profile linking Whatever the wallet is on. The chain id is only used to route signature verification, and the backend's own SIWE allow-list is a separate, shorter list
NFT flows The chain the contract is on
Swap terminal Only chains in switchableChains — see below

The Swap terminal's chain switcher is an intersection

The terminal's chain picker does not show the thirteen. It shows the intersection of two sets:

  1. What the server says is usable. GET /api/dex/chain returns the chains whose dexChain row is enabled and that pass the dexEnabledChains narrowing list, each carrying a routerEnabled flag which is simply "this chain has at least one allowlisted router".

  2. What this build's wagmi config can switch to — the ids of the thirteen networks above, exposed to the terminal as wallet.supportedChainIds.

A chain in one set and not the other is filtered out silently. That is deliberate: a chain the wallet cannot switch to dead-ends the user at the wallet prompt, and a chain with no router dead-ends them at the quote. When the intersection is empty the picker reads No network is configured for swapping yet.

So the operator-visible rule is:

  • Disabling a chain on /admin/dex/chain removes it from the Swap terminal. It does not remove it from the Reown modal — the modal is built from the static list and never consults your server.
  • A user can therefore connect on, or switch their wallet to, a network you have disabled for swapping. Nothing breaks; the terminal simply will not quote on it.

There is a helper, networksFor(enabledChainIds), that filters the static list by the server's answer and would make the modal itself match. It has no callerschain-registry.test.ts says so in as many words. Do not describe the modal to a customer as reflecting your enabled chains, because it does not.

Three copies of one list, and the test that keeps them equal

The same chain list is written out in three files that cannot import one another:

Copy File Why it is separate
DEX_CHAIN_STATIC backend/src/api/(ext)/dex/utils/chains.ts The backend cannot import an AppKit network object
networks / ALL_DEX_NETWORKS frontend/config/wallet.tsx The frontend cannot import the backend registry
DEX_WALLET_CHAIN_IDS frontend/config/dex-chain-ids.ts Importing config/wallet.tsx constructs the wagmi adapter and throws when the project id is unset — the admin readiness console needs the ids and none of that

e2e/unit/backend/dex/chain-registry.test.ts parses all three files and fails the build if they disagree, in every direction. It is the only thing that can: the backend's boot-time assertChainIdConsistency() compares the DEX registry against the Ecosystem's chain configs, which is a different question and skips silently when Ecosystem is absent.

Two chains are registered exceptions, because AppKit does not broker them at all: TRON (connected through the injected TronLink provider) and TON (connected through TON Connect). Your Reown project covers neither. That is why DEX_WALLET_CHAIN_IDS has fifteen entries and config/wallet.tsx has thirteen.

If you ever add a chain, it is a three-file commit plus a frontend rebuild and a backend restart. Adding it to one file alone produces exactly one of two silent failures: a chain the admin console reports ready and no user is ever offered, or a chain the switcher offers and no quote route knows.

Everything here is a build-time value

Every variable on this page begins with NEXT_PUBLIC_, which means Next.js inlines the value into the JavaScript bundle at build time. Editing .env and restarting changes nothing — the old value is already compiled in.

The Reown project id. Inlined into the frontend bundle at build time AND read by the backend at module load. No fallback: the frontend throws at module load without it.
Opt in to AppKit's embedded email and social wallet. The value is compared case-insensitively, so true, TRUE and True all switch it ON; unset or any other value is off.
Application name shown in the wallet's approval sheet.
Description shown beneath the name in the approval sheet.
Fallback origin for the approval sheet when window.location is unavailable.

The approval-sheet icon is /img/logo/logo.png on your own origin. Set the name and description before you invite anyone to sign: a request to sign a message from "Bicrypto" on a site branded something else is exactly what users are taught to reject.

pnpm build:frontend
pnpm restart

The project id is the one value that needs both halves — see the backend uses it too.

The embedded wallet toggle

With NEXT_PUBLIC_DEX_EMBEDDED_WALLET=true, AppKit is configured with features.email: true and four socials — Google, X, Apple and Discord — so the modal offers an embedded wallet whose key lives in an iframe at secure.walletconnect.org. External wallets are shown alongside it rather than behind it (emailShowWallets: true), because a self-custody product whose first screen is an email field reads as a custodial one.

Three things to know before you turn it on:

It is a request, not a guarantee. dashboard.reown.com can override email, socials, swaps, onramp and activity; when the cloud answers, the values compiled into your build are discarded and AppKit logs a warning saying so. The durable switch for all five is the Reown project dashboard, not your .env.

Absent is not off. These keys used to be missing from the config, and AppKit resolves a missing value from its own defaults where email and all seven socials are on. The variable exists so that the build states an intent either way.

Nothing in the platform branches on the result. If the connector does not appear — because this is off, because the dashboard says so, or because Reown is down — every code path behaves identically and the terminal simply asks for an external wallet. A unit test (embedded-wallet-optional.test.ts) asserts that no file under (ext)/dex gates on a connector's existence.

The user is told which kind of wallet they have, and that is the only thing that reads it. An embedded wallet is still self-custody and this platform still cannot recover it — which a user who signed in with Google has every reason to assume otherwise.

AppKit analytics are on

features.analytics is true. Connection events are reported to Reown and show up on your project dashboard. onramp and swaps are both explicitly false, so AppKit's own buy and swap panels are not offered — the platform's Swap terminal is not the AppKit one.

Auto-connect is on, deliberately

wagmi is configured with persistent storage — window.localStorage under the key wagmi. A returning visitor is reconnected without pressing anything.

This was the opposite for a long time. The old storage adapter dropped every key containing recentConnector or store, so wagmi had nothing to rehydrate and every mount started disconnected. That is unusable for the Swap terminal: signing on mobile deep-links out to the wallet app and returns to a cold page load, and with the filter in place the user came back to a Connect button while their transaction was already in the mempool, with no way for the page to know it existed.

With reconnect off, a page could not see a connection appear without a user gesture — so the profile Wallet tab could safely build and sign a SIWE message the moment one did, from an effect.

With reconnect on, that effect fires on page open and pops a signature prompt the user never asked for. It is now an explicit Link this wallet to my account button. Signature prompts must come from a click; if anyone re-introduces an auto-signing effect anywhere, persistence has to be turned back off in the same commit.

What it means in practice:

  • A user who deep-links out to their wallet and returns to a cold page comes back connected, and the terminal can find the transaction it started.
  • A connection made on one mount survives navigation to another, even though each mount is a separate provider instance, because both rehydrate from the same key.
  • On a shared computer the connection persists across visits. It is a connection, not a session — no Bicrypto cookie is involved and no wallet can be spent from — but the address is visible until the user disconnects or clears site data. That is worth a line in your own house rules.
  • Every mount passes cookies="", so wagmi's SSR cookie-hydration path contributes nothing. Persistence is entirely client-side.

The backend interpolates the same id

Server-side signature verification builds a viem public client pointed at:

https://rpc.walletconnect.org/v1/?chainId=<eip155:id>&projectId=<your project id>

read from the same NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID, at module load, in the backend process.

If the project is deleted, its domain allow-list stops matching, or it exceeds its quota, that call fails. verifySignature() returns false, and every wallet sign-in and every profile wallet link returns 401 "Signature verification failed" — including for users whose signature is perfectly valid.

The wallet prompt still succeeds, so it presents as a signature problem rather than as an account problem. The only tell is a Signature verification error line in the backend log under the AUTH module.

The same failure mode arrives from a firewall: the backend needs outbound HTTPS to rpc.walletconnect.org, not just the browser.

Because the two halves read the same name in different ways, the middle states are the confusing ones:

You did Result
Edited .env, restarted only Frontend still on the old id, backend on the new one
Edited .env, rebuilt only Frontend on the new id, backend still verifying against the old one
Rebuilt and restarted Correct

When you change the project id

  1. Create or update the project at cloud.reown.com and add your production origin to its allowed-domains list. The id ships in public JavaScript, so domain restriction is the only thing stopping it being spent elsewhere against your quota.

  2. Set NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID in .env. It is not in .env.example; add it by hand.

  3. Rebuild the frontend and restart the backendpnpm build:frontend && pnpm restart, or pnpm updator for the full path.

  4. Check the modal. Open the Reown modal from any of the eight surfaces. The header should show your NEXT_PUBLIC_SITE_NAME, and wallets should list.

  5. Check the server half, which the modal cannot tell you about. Link or sign in with a wallet end to end. A 401 Signature verification failed on a prompt the wallet accepted means the backend is on a different or a dead project id.

  6. Check the Swap terminal separately if you run it, since a terminal that renders proves the id is present in the bundle but proves nothing about the backend.