Home / Frontend Development & Blockchain / TypeScript for Web3: Type-Safe State in Next.js

TypeScript for Web3: Type-Safe State in Next.js

6 mins read
Mar 18, 2026

TypeScript for Web3: Type-Safe State in Next.js

Introduction to Type-Safe Web3 Development

In 2026, Web3 applications demand rock-solid type safety to handle complex blockchain interactions without runtime errors. TypeScript excels here, bridging Next.js frontends with blockchain protocols like Ethereum. This guide delivers an end-to-end approach to state management that's fully typed, scalable, and production-ready.

Traditional state tools like Redux or Zustand work great for web apps, but Web3 adds layers: wallet connections, transaction states, chain data, and signer objects. Loose typing leads to bugs—like assuming a string address is valid or mishandling undefined providers.

We'll build a type-safe Web3 state manager using React Context, custom hooks, and ethers.js v6 (the 2026 standard). Expect actionable code you can copy-paste into your Next.js project.

Why TypeScript Shines in Web3 State Management

Web3 state is unpredictable:

  • Wallets may or may not be connected.
  • Networks switch dynamically.
  • Transactions pend, succeed, or fail asynchronously.

TypeScript enforces:

  • Union types for states (e.g., Connected | Disconnected | Loading).
  • Generics for chain-specific data.
  • Narrowing for safe provider/signer access.

In Next.js 15+, TypeScript integrates natively with App Router, server components, and React 19's compiler optimizations.

Setting Up Your Next.js Web3 Project

Start with a fresh Next.js app:

npx create-next-app@latest my-web3-app --typescript --tailwind --eslint --app cd my-web3-app npm install ethers@^6.13.0 wagmi@^2.12.0 viem@^2.21.0 @tanstack/react-query@^5.59.0 npm install -D @types/node

wagmi and viem provide typed blockchain abstractions. They're the go-to in 2026 for EVM chains.

Project Structure

my-web3-app/ ├── app/ │ ├── providers/ │ │ └── Web3Provider.tsx │ └── layout.tsx ├── contexts/ │ └── Web3Context.tsx ├── hooks/ │ └── useWeb3Actions.ts ├── types/ │ └── web3.ts └── components/ └── WalletConnect.tsx

Defining Type-Safe Web3 Interfaces

Create types/web3.ts for ironclad types:

// types/web3.ts export type ChainId = 1 | 5 | 137 | 42161; // Mainnets + testnets

export interface Web3State { isConnected: boolean; address: 0x${string} | null; chainId: ChainId | null; provider: any | null; signer: any | null; status: 'idle' | 'connecting' | 'connected' | 'error'; error: string | null; }

export interface Web3Actions { connect: () => Promise; disconnect: () => Promise; switchChain: (chainId: ChainId) => Promise; }

export type Web3ContextValue = Web3State & Web3Actions;

These types use template literals for hex addresses and discriminated unions for status—preventing invalid states at compile time.

Building the Core Web3 Context

In contexts/Web3Context.tsx, combine Context API with reducers for global state:

import { createContext, useContext, useReducer, ReactNode } from 'react'; import { Web3State, Web3Actions, ChainId } from '@/types/web3'; import { ethers } from 'ethers';

const Web3Context = createContext<Web3ContextValue | undefined>(undefined);

// Initial state let initialState: Web3State = { isConnected: false, address: null, chainId: null, provider: null, signer: null, status: 'idle', error: null, };

// Reducer for type-safe updates type Web3Action = | { type: 'CONNECTING' } | { type: 'CONNECTED'; payload: Web3State } | { type: 'ERROR'; payload: string } | { type: 'DISCONNECT' };

function web3Reducer(state: Web3State, action: Web3Action): Web3State { switch (action.type) { case 'CONNECTING': return { ...state, status: 'connecting' }; case 'CONNECTED': return { ...state, ...action.payload, status: 'connected' }; case 'ERROR': return { ...state, status: 'error', error: action.payload }; case 'DISCONNECT': return initialState; default: return state; } }

export const Web3Provider = ({ children }: { children: ReactNode }) => { const [state, dispatch] = useReducer(web3Reducer, initialState);

const connect = async () => { if (typeof window === 'undefined') return; try { dispatch({ type: 'CONNECTING' }); const { ethereum } = window as any; if (!ethereum) throw new Error('No wallet found');

  const provider = new ethers.BrowserProvider(ethereum);
  await provider.send('eth_requestAccounts', []);
  const signer = await provider.getSigner();
  const address = await signer.getAddress();
  const network = await provider.getNetwork();

  dispatch({
    type: 'CONNECTED',
    payload: {
      isConnected: true,
      address: address as `0x${string}`,
      chainId: Number(network.chainId) as ChainId,
      provider,
      signer,
    },
  });
} catch (error) {
  dispatch({ type: 'ERROR', payload: (error as Error).message });
}

};

const disconnect = () => { dispatch({ type: 'DISCONNECT' }); };

const switchChain = async (chainId: ChainId) => { const { ethereum } = window as any; try { await ethereum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: 0x${chainId.toString(16)} }], }); } catch (error) { // Handle chain not added } };

return ( <Web3Context.Provider value={{ ...state, connect, disconnect, switchChain }}> {children} </Web3Context.Provider> ); };

export const useWeb3 = () => { const context = useContext(Web3Context); if (!context) throw new Error('useWeb3 must be used within Web3Provider'); return context; };

This reducer ensures immutable, predictable updates. TypeScript narrows types based on status—e.g., address is only non-null when isConnected.

Integrating with Next.js Providers

Wrap your app in app/providers/Web3Provider.tsx:

// app/providers/Web3Provider.tsx import { Web3Provider } from '@/contexts/Web3Context';

export default function Providers({ children }: { children: React.ReactNode }) { return <Web3Provider>{children}</Web3Provider>; }

Update app/layout.tsx:

import Providers from './providers/Web3Provider';

export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <Providers>{children}</Providers> ); }

Practical Components: Wallet Connection UI

Build components/WalletConnect.tsx:

import { useWeb3 } from '@/contexts/Web3Context';

export default function WalletConnect() { const { isConnected, address, status, connect, disconnect } = useWeb3();

return ( <div className="p-4 bg-gray-800 rounded-lg"> {status === 'connecting' &&

Connecting...

} {!isConnected ? ( <button onClick={connect} className="px-4 py-2 bg-blue-600 text-white rounded" > Connect Wallet ) : (

Connected: {address?.slice(0, 6)}...{address?.slice(-4)}

<button onClick={disconnect} className="ml-2 px-4 py-2 bg-red-600 text-white rounded" > Disconnect
)}
); }

TypeScript ensures address access is safe—it's guarded by isConnected.

Advanced: Transaction State Management

Extend for transactions. Add to Web3State:

interface PendingTx { hash: 0x${string}; status: 'pending' | 'success' | 'failed'; }

pendingTxs: PendingTx[]; addTransaction: (hash: 0x${string}) => void;

Implement in reducer:

case 'ADD_TX': return { ...state, pendingTxs: [...state.pendingTxs, { hash: action.payload, status: 'pending' }], };

Hook it into a sendTransaction action using your signer.

Bridging to Blockchain Protocols

Query on-chain data with type-safe ABIs. Define interfaces for your smart contracts:

// types/Contract.ts interface TokenContract { balanceOf: (address: 0x${string}) => Promise; }

export const useTokenBalance = (contractAddress: 0x${string}, tokenId: TokenContract) => { const { address } = useWeb3(); // Fetch with viem or ethers, fully typed };

For ICRC-1 or Azle canisters (Internet Computer), adapt types similarly.

Zustand Alternative for Simplicity

Prefer hooks over Context? Use Zustand with TypeScript:

// stores/useWeb3Store.ts import { create } from 'zustand';

export const useWeb3Store = create<Web3ContextValue>((set, get) => ({ // ...state connect: async () => { set({ status: 'connecting' }); // impl }, }));

Ultra-lightweight, devtools-ready, and persists state.

Performance Optimizations in 2026

  • React 19: Use useOptimistic for instant tx feedback.
  • Next.js 15: Server-side wallet checks via cookies (never expose privkeys).
  • TanStack Query: Cache blockchain data with invalidation on chain changes.

import { useQuery } from '@tanstack/react-query';

const { data: balance } = useQuery({ queryKey: ['balance', address], queryFn: async () => { if (!signer) return 0n; // fetch }, enabled: !!address, });

Common Pitfalls and Fixes

Pitfall Fix
Stale closures in async Use useCallback with getState() in Zustand
SSR hydration mismatch Check typeof window in hooks
Chain ID mismatches Validate with switchChain
BigInt serialization Use toString() for JSON

Real-World Example: NFT Marketplace State

Manage bids, listings, and user portfolio:

interface NFTState { listings: Array<{ tokenId: bigint; price: bigint }>; myBids: Map<bigint, bigint>; }

// Full integration with above Web3Context

Fetch via provider, update via signer—all typed.

Testing Your Type-Safe Setup

// tests/Web3Context.test.tsx import { renderHook, act } from '@testing-library/react';

const { result } = renderHook(() => useWeb3()); await act(async () => result.current.connect());

Use msw for EIP-1193 mocks.

Scaling to Multi-Chain

Support L2s like Base, Optimism:

const CHAINS = { 1: { name: 'Ethereum', rpc: 'https://mainnet.infura.io/v3/YOUR_KEY' }, 8453: { name: 'Base', rpc: 'https://mainnet.base.org' }, } as Record<ChainId, { name: string; rpc: string }>;

Dynamic providers per chain.

  • Account Abstraction: Typed smartAccount states with ERC-4337.
  • Cross-Chain: LayerZero or Axelar integrations with union types.
  • AI Agents: Type-safe oracle feeds for on-chain AI data.

Your app will handle Web3's chaos gracefully.

Conclusion

This end-to-end type-safe state management turns Web3 dev from nightmare to joy. Deploy today—your users get buttery-smooth, error-free blockchain UIs. Fork the code, tweak for your protocol, and ship.

Next steps: Add wagmi for deeper integrations. Happy coding!

TypeScript Web3 Next.js