March 8, 2026Β·9 minΒ·By

Web3 dApp Architecture: How to Structure a Production Decentralized App

Web3dApparchitectureThe GraphblockchainReact

Web3 apps have unique architectural challenges: blockchain reads are slow, state is split between on-chain and off-chain, and user actions require wallet confirmation. Here's how to structure it correctly.

The Architecture Stack

Frontend (Next.js + wagmi)
    |
    β”œβ”€β”€ Smart Contracts (Ethereum/Polygon/Base)
    |       └── Direct reads via RPC
    |
    β”œβ”€β”€ The Graph (indexed blockchain data)
    |       └── Fast GraphQL queries over on-chain events
    |
    └── Backend API (optional, for off-chain data)
            └── User profiles, metadata, notifications

Why You Need an Indexer (The Graph)

Reading data directly from contracts is slow and limited:

// Bad: reading 1000 events directly from chain is slow and expensive
const filter = contract.filters.Transfer(null, userAddress)
const events = await contract.queryFilter(filter, 0, 'latest')  // Scans entire chain!

// Good: query indexed data via GraphQL
const { data } = useQuery(gql`
    query UserTransfers($address: String!) {
        transfers(where: { to: $address }, orderBy: blockTimestamp, orderDirection: desc) {
            id
            from
            to
            value
            blockTimestamp
            transactionHash
        }
    }
`, { variables: { address: userAddress.toLowerCase() } })

Setting Up a Subgraph

# subgraph.yaml
specVersion: 0.0.5
schema:
    file: ./schema.graphql
dataSources:
    - kind: ethereum
      name: Token
      network: base
      source:
          address: "0x..."
          abi: Token
          startBlock: 12345678
      mapping:
          kind: ethereum/events
          apiVersion: 0.0.7
          language: wasm/assemblyscript
          entities:
              - Transfer
              - User
          abis:
              - name: Token
                file: ./abis/Token.json
          eventHandlers:
              - event: Transfer(indexed address,indexed address,uint256)
                handler: handleTransfer
          file: ./src/mapping.ts
// src/mapping.ts
import { Transfer as TransferEvent } from '../generated/Token/Token'
import { Transfer, User } from '../generated/schema'

export function handleTransfer(event: TransferEvent): void {
    let transfer = new Transfer(event.transaction.hash.toHex() + '-' + event.logIndex.toString())
    transfer.from = event.params.from.toHex()
    transfer.to = event.params.to.toHex()
    transfer.value = event.params.value
    transfer.blockTimestamp = event.block.timestamp
    transfer.transactionHash = event.transaction.hash.toHex()
    transfer.save()

    // Update user balance
    let user = User.load(event.params.to.toHex())
    if (!user) {
        user = new User(event.params.to.toHex())
        user.totalReceived = event.params.value
    } else {
        user.totalReceived = user.totalReceived.plus(event.params.value)
    }
    user.save()
}

Frontend State Architecture

// Split on-chain and off-chain state clearly
// hooks/useUserDashboard.ts

// On-chain: read directly or via subgraph
function useOnChainData(address: string) {
    const { data: balance } = useReadContract({
        address: TOKEN_ADDRESS,
        abi: tokenAbi,
        functionName: 'balanceOf',
        args: [address]
    })

    const { data: transfers } = useQuery(TRANSFERS_QUERY, {
        variables: { address: address.toLowerCase() }
    })

    return { balance, transfers }
}

// Off-chain: fetch from your backend API
function useOffChainData(address: string) {
    return useQuery({
        queryKey: ['user-profile', address],
        queryFn: () => fetch(`/api/users/${address}`).then(r => r.json())
    })
}

// Combined hook
export function useUserDashboard(address: string) {
    const onChain = useOnChainData(address)
    const offChain = useOffChainData(address)
    return { ...onChain, ...offChain }
}

Wallet-Based Authentication

// Authenticate users with their wallet signature (SIWE - Sign In With Ethereum)
import { SiweMessage } from 'siwe'
import { useSignMessage, useAccount } from 'wagmi'

export function useWalletAuth() {
    const { address } = useAccount()
    const { signMessageAsync } = useSignMessage()

    const signIn = async () => {
        const nonce = await fetch('/api/auth/nonce').then(r => r.text())

        const message = new SiweMessage({
            domain: window.location.host,
            address,
            statement: 'Sign in to MyDApp',
            uri: window.location.origin,
            version: '1',
            chainId: 1,
            nonce
        })

        const signature = await signMessageAsync({
            message: message.prepareMessage()
        })

        const response = await fetch('/api/auth/verify', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ message, signature })
        })

        const { token } = await response.json()
        // Store JWT for subsequent API calls
        localStorage.setItem('auth_token', token)
    }

    return { signIn }
}

Handling Transaction UX

The hardest part of Web3 UX: transactions take 12-30 seconds and can fail:

type TxState = 'idle' | 'pending-wallet' | 'pending-chain' | 'success' | 'error'

function useTransactionFlow() {
    const [state, setState] = useState<TxState>('idle')
    const { writeContractAsync } = useWriteContract()
    const { waitForTransactionReceipt } = useWaitForTransactionReceipt

    const execute = async (contractCall: ContractCallConfig) => {
        setState('pending-wallet')
        let hash: `0x${string}`

        try {
            hash = await writeContractAsync(contractCall)
        } catch (e) {
            // User rejected in wallet
            setState('idle')
            return
        }

        setState('pending-chain')

        try {
            await waitForTransactionReceipt({ hash })
            setState('success')
        } catch (e) {
            setState('error')
        }
    }

    return { state, execute }
}

// In component
const { state, execute } = useTransactionFlow()

const messages = {
    idle: 'Stake Tokens',
    'pending-wallet': 'Confirm in MetaMask...',
    'pending-chain': 'Transaction submitted, waiting...',
    success: 'Staked successfully!',
    error: 'Transaction failed'
}

Gas Estimation

import { estimateGas, formatEther } from 'viem'
import { usePublicClient } from 'wagmi'

function useGasEstimate(contractCall) {
    const publicClient = usePublicClient()

    return useQuery({
        queryKey: ['gas-estimate', contractCall],
        queryFn: async () => {
            const gas = await publicClient.estimateContractGas(contractCall)
            const gasPrice = await publicClient.getGasPrice()
            const costWei = gas * gasPrice
            return {
                gas,
                costEth: formatEther(costWei),
                costUsd: Number(formatEther(costWei)) * ETH_PRICE_USD
            }
        }
    })
}

The key insight for Web3 architecture: minimize what's on-chain (expensive, slow, immutable) and maximize what's off-chain (cheap, fast, flexible). Smart contracts enforce rules and hold assets. Everything else goes in your regular database.

K
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

When should I use Server Components vs Client Components?+

Server Components are the default β€” use them for data fetching, static content, and anything that doesn't need interactivity. Only switch to Client Components when you need browser APIs, state, effects, or event handlers. The less JS you ship to the browser, the faster your site.

Why is my Next.js build so slow?+

Usually it's TypeScript type-checking or a large dependency. Try `NEXT_TELEMETRY_DISABLED=1 next build` with `--profile` and look at what's taking time. Splitting large data files and enabling turborepo are the two biggest wins.

Should I use the App Router or Pages Router for a new project?+

App Router for new projects, full stop. Pages Router still works fine but receives fewer improvements. The learning curve for App Router is steep for one week, then you never want to go back.

Does Innovibe build this kind of thing for clients?+

Yes β€” this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

How do I decide whether to build this in-house or hire an agency?+

Build in-house if your team has the skills and bandwidth and this is core to your product. Hire out if it's infrastructure, if speed matters, or if the expertise gap would take months to close. We're biased, obviously β€” but we'll tell you honestly when in-house makes more sense.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation